JavaFX 2 Generic Editable Table Cells

A few days ago I wrote an article which gave a more complete example of editable table cells in JavaFX 2.0. In that article I promised another article discussing how to make generic editable tables cells since the first article just expected everything to be a string. I’m fairly happy with this solution to the problem of making formatted input cells but if anyone can suggest improvements I’d love to hear them.

First up with the SampleFX main class. This is broadly similar to the previous version but I’ve moved things about to make it more readable for the example.

package samplefx;
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumn.CellEditEvent;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import javafx.util.Callback;
/**
 *
 * @author Graham Smith
 */
public class SampleFX extends Application {
	private TableView table = new TableView();
	private final ObservableList<Person> data =
			FXCollections.observableArrayList(
			new Person(true, "Jacob", "Smith", "jacob.smithexample.com", "jsexample.com", 26),
			new Person(true, "Isabella", "Johnson", "isabella.johnsonexample.com", "ijexample.com", 19),
			new Person(true, "Ethan", "Williams", "ethan.williamsexample.com", "ewexample.com", 56),
			new Person(true, "Emma", "Jones", "emma.jonesexample.com", "ejexample.com", 44),
			new Person(false, "Michael", "Brown", "michael.brownexample.com", "mbexample.com", 36));
	/**
	 * @param args the command line arguments
	 */
	public static void main(String[] args) {
		launch(args);
	}
	@Override
	public void start(Stage stage) {
		Scene scene = new Scene(new Group());
		stage.setTitle("Table View Sample");
		stage.setWidth(850);
		stage.setHeight(500);
		final Label label = new Label("Address Book");
		label.setFont(new Font("Arial", 20));
		//Create a custom cell factory so that cells can support editing.
		Callback<TableColumn, TableCell> editableFactory = new Callback<TableColumn, TableCell>() {
			@Override
			public TableCell call(TableColumn p) {
				return new EditableTableCell();
			}
		};
		//A custom cell factory that creates cells that only accept numerical input.
		Callback<TableColumn, TableCell> numericFactory = new Callback<TableColumn, TableCell>() {
			@Override
			public TableCell call(TableColumn p) {
				return new NumericEditableTableCell();
			}
		};
		//Create the columns
		TableColumn firstNameCol = createFirstNameColumn(editableFactory);
		TableColumn lastNameCol = createLastNameColumn(editableFactory);
		TableColumn emailCol = createEmailColumns(editableFactory);
		TableColumn ageCol = createAgeColumn(numericFactory);
		//Add the columns and data to the table.
		table.setItems(data);
		table.getColumns().addAll(firstNameCol, lastNameCol, emailCol, ageCol);
		//Make the table editable
		table.setEditable(true);
		final VBox vbox = new VBox();
		vbox.setSpacing(5);
		vbox.getChildren().addAll(label, table);
		vbox.setPadding(new Insets(10, 0, 0, 10));
		((Group) scene.getRoot()).getChildren().addAll(vbox);
		stage.setScene(scene);
		stage.show();
	}
	private TableColumn createActiveColumn(Callback<TableColumn, TableCell> checkBoxFactory) {
		TableColumn activeCol = new TableColumn("Active");
		activeCol.setMinWidth(25);
		activeCol.setCellValueFactory(new PropertyValueFactory<Person, Boolean>("active"));
		activeCol.setCellFactory(checkBoxFactory);
		activeCol.setOnEditCommit(new EventHandler<CellEditEvent<Person, Boolean>>() {
			@Override
			public void handle(CellEditEvent<Person, Boolean> arg0) {
				for (Person p : data) {
					p.setActive(!p.isActive());
				}
			}
		});
		return activeCol;
	}
	private TableColumn createFirstNameColumn(Callback<TableColumn, TableCell> editableFactory) {
		TableColumn firstNameCol = new TableColumn("First Name");
		firstNameCol.setMinWidth(100);
		firstNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("firstName"));
		firstNameCol.setCellFactory(editableFactory);
		//Modifying the firstName property
		firstNameCol.setOnEditCommit(new EventHandler<CellEditEvent<Person, String>>() {
			@Override
			public void handle(CellEditEvent<Person, String> t) {
				((Person) t.getTableView().getItems().get(t.getTablePosition().getRow())).setFirstName(t.getNewValue());
			}
		});
		return firstNameCol;
	}
	private TableColumn createLastNameColumn(Callback<TableColumn, TableCell> editableFactory) {
		TableColumn lastNameCol = new TableColumn("Last Name");
		lastNameCol.setMinWidth(100);
		lastNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("lastName"));
		lastNameCol.setCellFactory(editableFactory);
		//Modifying the lastName property
		lastNameCol.setOnEditCommit(new EventHandler<CellEditEvent<Person, String>>() {
			@Override
			public void handle(CellEditEvent<Person, String> t) {
				((Person) t.getTableView().getItems().get(t.getTablePosition().getRow())).setLastName(t.getNewValue());
			}
		});
		return lastNameCol;
	}
	private TableColumn createEmailColumns(Callback<TableColumn, TableCell> editableFactory) {
		//Email as a two depth layer header
		TableColumn emailCol = new TableColumn("Email");
		emailCol.setMinWidth(400);
		TableColumn primaryEmailCol = new TableColumn("Primary Email");
		primaryEmailCol.setMinWidth(200);
		primaryEmailCol.setCellValueFactory(new PropertyValueFactory<Person, String>("primaryEmail"));
		primaryEmailCol.setCellFactory(editableFactory);
		//Make this column un-editable		
		primaryEmailCol.setEditable(false);
		TableColumn secondaryEmailCol = new TableColumn("Secondary Email");
		secondaryEmailCol.setMinWidth(200);
		secondaryEmailCol.setCellValueFactory(new PropertyValueFactory<Person, String>("secondaryEmail"));
		secondaryEmailCol.setCellFactory(editableFactory);
		emailCol.getColumns().addAll(primaryEmailCol, secondaryEmailCol);
		//Modifying the primary email property
		primaryEmailCol.setOnEditCommit(new EventHandler<CellEditEvent<Person, String>>() {
			@Override
			public void handle(CellEditEvent<Person, String> t) {
				((Person) t.getTableView().getItems().get(t.getTablePosition().getRow())).setPrimaryEmail(t.getNewValue());
			}
		});
		//Modifying the secondary email property
		secondaryEmailCol.setOnEditCommit(new EventHandler<CellEditEvent<Person, String>>() {
			@Override
			public void handle(CellEditEvent<Person, String> t) {
				((Person) t.getTableView().getItems().get(t.getTablePosition().getRow())).setSecondaryEmail(t.getNewValue());
			}
		});
		return emailCol;
	}
	private TableColumn createAgeColumn(Callback<TableColumn, TableCell> numericFactory) {
		//Age field is set to accept only numeric values.
		TableColumn ageCol = new TableColumn("Age");
		ageCol.setMinWidth(50);
		ageCol.setCellValueFactory(new PropertyValueFactory<Person, String>("age"));
		ageCol.setCellFactory(numericFactory);
		//Modifying the age property
		//This must be a long since that is what is created by the parse operation. If you set it
		//to Integer (as you would expect) you'll get a class cast excepton when you call getNewValue
		//as Long can't be cast to Integer.
		ageCol.setOnEditCommit(new EventHandler<CellEditEvent<Person, Long>>() {
			@Override
			public void handle(CellEditEvent<Person, Long> t) {
				int newAge = t.getNewValue().intValue();
				((Person) t.getTableView().getItems().get(t.getTablePosition().getRow())).setAge(newAge);
			}
		});
		
		return ageCol;
	}
}

There are two new properties on the Person class active, which is a boolean and age which is an integer. I won’t repeat the Person class here as it’s very simple. Check out the previous article for the basic code (I’ve had a couple of comments about this last line so I’ve linked the previous article. The changes really are as simple as adding “private final SimpleBooleanProperty active;” with get and set methods to the class.)

The secret to creating generic editable table cells is to abstract all the bits that make a cell editable. I therefore created the AbstractEditableTableCell class as shown below.

package samplefx;
import java.util.ArrayList;
import java.util.List;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.EventHandler;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
/**
 * Provides the basis for an editable table cell using a text field. Sub-classes can provide formatters for display and a
 * commitHelper to control when editing is committed.
 *
 * @author Graham Smith
 */
public abstract class AbstractEditableTableCell<S, T> extends TableCell<S, T> {
	protected TextField textField;
	public AbstractEditableTableCell() {
	}
	/**
	 * Any action attempting to commit an edit should call this method rather than commit the edit directly itself. This
	 * method will perform any validation and conversion required on the value. For text values that normally means this
	 * method just commits the edit but for numeric values, for example, it may first parse the given input. <p> The only
	 * situation that needs to be treated specially is when the field is losing focus. If you user hits enter to commit the
	 * cell with bad data we can happily cancel the commit and force them to enter a real value. If they click away from the
	 * cell though we want to give them their old value back.
	 *
	 * @param losingFocus true if the reason for the call was because the field is losing focus.
	 */
	protected abstract void commitHelper(boolean losingFocus);
	/**
	 * Provides the string representation of the value of this cell when the cell is not being edited.
	 */
	protected abstract String getString();
	@Override
	public void startEdit() {
		super.startEdit();
		if (textField == null) {
			createTextField();
		}
		setGraphic(textField);
		setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
		Platform.runLater(new Runnable() {
			@Override
			public void run() {
				textField.selectAll();
				textField.requestFocus();
			}
		});
	}
	@Override
	public void cancelEdit() {
		super.cancelEdit();
		setText(getString());
		setContentDisplay(ContentDisplay.TEXT_ONLY);
		//Once the edit has been cancelled we no longer need the text field
		//so we mark it for cleanup here. Note though that you have to handle
		//this situation in the focus listener which gets fired at the end
		//of the editing.
		textField = null;
	}
	@Override
	public void updateItem(T item, boolean empty) {
		super.updateItem(item, empty);
		if (empty) {
			setText(null);
			setGraphic(null);
		} else {
			if (isEditing()) {
				if (textField != null) {
					textField.setText(getString());
				}
				setGraphic(textField);
				setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
			} else {
				setText(getString());
				setContentDisplay(ContentDisplay.TEXT_ONLY);
			}
		}
	}
	private void createTextField() {
		textField = new TextField(getString());
		textField.setMinWidth(this.getWidth() - this.getGraphicTextGap() * 2);
		textField.setOnKeyPressed(new EventHandler<KeyEvent>() {
			@Override
			public void handle(KeyEvent t) {
				if (t.getCode() == KeyCode.ENTER) {
					commitHelper(false);
				} else if (t.getCode() == KeyCode.ESCAPE) {
					cancelEdit();
				} else if (t.getCode() == KeyCode.TAB) {
					commitHelper(false);
					
					TableColumn nextColumn = getNextColumn(!t.isShiftDown());
					if (nextColumn != null) {
						getTableView().edit(getTableRow().getIndex(), nextColumn);
					}
				}
			}
		});
		textField.focusedProperty().addListener(new ChangeListener<Boolean>() {
			@Override
			public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
				//This focus listener fires at the end of cell editing when focus is lost
				//and when enter is pressed (because that causes the text field to lose focus).
				//The problem is that if enter is pressed then cancelEdit is called before this
				//listener runs and therefore the text field has been cleaned up. If the
				//text field is null we don't commit the edit. This has the useful side effect
				//of stopping the double commit.
				if (!newValue && textField != null) {
					commitHelper(true);
				}
			}
		});
	}
	/**
	 *
	 * @param forward true gets the column to the right, false the column to the left of the current column
	 * @return
	 */
	private TableColumn<S, ?> getNextColumn(boolean forward) {
		List<TableColumn<S, ?>> columns = new ArrayList<>();
		for (TableColumn<S, ?> column : getTableView().getColumns()) {
			columns.addAll(getLeaves(column));
		}
		//There is no other column that supports editing.
		if (columns.size() < 2) {
			return null;
		}
		int currentIndex = columns.indexOf(getTableColumn());
		int nextIndex = currentIndex;
		if (forward) {
			nextIndex++;
			if (nextIndex > columns.size() - 1) {
				nextIndex = 0;
			}
		} else {
			nextIndex--;
			if (nextIndex < 0) {
				nextIndex = columns.size() - 1;
			}
		}
		return columns.get(nextIndex);
	}
	private List<TableColumn<S, ?>> getLeaves(TableColumn<S, ?> root) {
		List<TableColumn<S, ?>> columns = new ArrayList<>();
		if (root.getColumns().isEmpty()) {
			//We only want the leaves that are editable.
			if (root.isEditable()) {
				columns.add(root);
			}
			return columns;
		} else {
			for (TableColumn<S, ?> column : root.getColumns()) {
				columns.addAll(getLeaves(column));
			}
			return columns;
		}
	}
}

There are two abstract method commitHelper and getString. The getString method is used when the cell is in it’s normal state to generate a string representation of the contents. In the case of a string field this typically just displays the value but in a numeric field it may format the number as, for example, a monetary value.

The commitHelper is where all the magic happens as it is what ensures the cell only accepts the appropriate type of input. The commitHelper method gets called when commitEdit would normally be called, this gives commitHelper a change to check the data for validity and accept or reject it. Losing focus has to be handled carefully hence the boolean arguement on the commitHelper. The desired behaviour is: if the user enters bad data and tries to commit it with the enter key block the commit and stay in editing mode but if the user enters bad data and wants to leave the cell (e.g. by tabbing or clicking away) give them the old data back. Without the flag the two situations can’t be distingushed and you can end up trapping the user in the cell. I would like to get rid of the flag but I can’t see a clean way of detecting the two different situations.

The textual editor now simplifies down to just the code shown below.

package samplefx;
/**
 *
 * @author Graham Smith
 */
public class EditableTableCell<S extends Object, T extends String> extends AbstractEditableTableCell<S, T> {
	public EditableTableCell() {
	}
	@Override
	protected String getString() {
		return getItem() == null ? "" : getItem().toString();
	}
	@Override
	protected void commitHelper( boolean losingFocus ) {
		commitEdit(((T) textField.getText()));
	}
	
}

A cell that accepts only numeric input is a little more complex but is shown here

package samplefx;
import java.text.NumberFormat;
import java.text.ParseException;
import java.text.ParsePosition;
/**
 *
 * @author Graham Smith
 */
public class NumericEditableTableCell<S extends Object, T extends Number> extends AbstractEditableTableCell<S, T> {
	private final NumberFormat format;
	private boolean emptyZero;
	private boolean completeParse;
	/**
	 * Creates a new {@code NumericEditableTableCell} which treats empty strings as zero,
	 * will parse integers only and will fail if is can't parse the whole string.
	 */
	public NumericEditableTableCell() {
		this( NumberFormat.getInstance(), true, true, true );
	}
	
	/**
	 * The integerOnly and completeParse settings have a complex relationship and care needs
	 * to be take to get the correct result. 
	 * <ul>
	 * <li>If you want to accept only integers and you want to parse the whole string then 
	 * set both integerOnly and completeParse to true. Strings such as 1.5 will be rejected
	 * as invalid. A string such as 1000 will be accepted as the number 1000.</li>
	 * <li>If you only want integers but don't care about parsing the whole string set
	 * integerOnly to true and completeParse to false. This will parse a string such as
	 * 1.5 and provide the number 1. The downside of this combination is that it will accept 
	 * the string 1x and return the number 1 also.</li>
	 * <li>If you want to accept decimals and want to parse the whole string set integerOnly
	 * to false and completeParse to true. This will accept a string like 1.5 and return
	 * the number 1.5. A string such as 1.5x will be rejected.</li>
	 * <li>If you want to accept decimals and don't care about parsing the whole string set
	 * both integerOnly and completeParse to false. This will accept a string like 1.5x and
	 * return the number 1.5. A string like x1.5 will be rejected because ti doesn't start
	 * with a number. The downside of this combination is that a string like 1.5x3 will 
	 * provide the number 1.5.</li>
	 * </ul>
	 * 
	 * @param format the {@code NumberFormat} to use to format this cell.
	 * @param emptyZero if true an empty cell will be treated as zero.
	 * @param integerOnly if true only the integer part of the string is parsed.
	 * @param completeParse  if true an exception will be thrown if the whole string given can't be parsed.
	 */
	public NumericEditableTableCell( NumberFormat format, boolean emptyZero, boolean integerOnly, boolean completeParse ) {
		this.format = format;
		this.emptyZero = emptyZero;
		this.completeParse = completeParse;
		format.setParseIntegerOnly(integerOnly);
	}
	@Override
	protected String getString() {
		return getItem() == null ? "" : format.format(getItem());
	}
	
	/**
	 * Parses the value of the text field and if matches the set format 
	 * commits the edit otherwise it returns the cell to it's previous value.
	 */
	@Override
	protected void commitHelper( boolean losingFocus ) {
		if( textField == null ) {
			return;
		}
		
		try {
			String input = textField.getText();
			if (input == null || input.length() == 0) {
				if(emptyZero) {
					setText( format.format(0) );
					commitEdit( (T)new Integer( 0 ));
				}
				return;
			}
			
			int startIndex = 0;
			ParsePosition position = new ParsePosition(startIndex);
			Number parsedNumber = format.parse(input, position);
			
			if (completeParse && position.getIndex() != input.length()) {
				throw new ParseException("Failed to parse complete string: " + input, position.getIndex());
			}
			
			if (position.getIndex() == startIndex ) {
				throw new ParseException("Failed to parse a number from the string: " + input, position.getIndex());
			}
			commitEdit( (T)parsedNumber );
		} catch (ParseException ex) {
			//Most of the time we don't mind if there is a parse exception as it
			//indicates duff user data but in the case where we are losing focus
			//it means the user has clicked away with bad data in the cell. In that
			//situation we want to just cancel the editing and show them the old
			//value.
			if( losingFocus ) {
				cancelEdit();
			}
		}
	}
}