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;
}
}
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;
}
}
}
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()));
}
}
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();
}
}
}
}