Complete JavaFX 2 Editable Table Example

The JavaFX 2 documentation is generally very well written but I found the discussion of the TableView component to be slightly lacking. It demonstrates the construction of a basic table with multi-level column headers and it shows the most basic example of editing. Any real application will want a much more complete handling of cell editing which is what I discuss here.

Update

Since I wrote this article JavaFX 2.2 has been released and I’ve discovered a problem with the editable cell. The “problem” (it could be by design) affects the editable cells shipped with JavaFX as well as the implementation shown below. Fortunately the fix is pretty simple: fix for editing null cells.

First off let me describe my aims: I want to achieve a table that supports the simple textual editing of any numbers of cells, I want to be able to tab through the cells in a row editing each one in turn. I want columns that don’t support editing to be skipped and I would like a quick and simple way to activate cell editing. I will be building on the example found in the official documentation so if you haven’t read that I suggest you start there first.

First up is the Person class. As in the official example I’ve given the Person a primary and secondary email so that I can have multi-level column headers. I’ve exposed the properties of the Person class but they aren’t currently used in this example.

package samplefx;
import javafx.beans.property.SimpleStringProperty;
/**
 *
 * @author Graham Smith
 */
public class Person {
    private final SimpleStringProperty firstName;
    private final SimpleStringProperty lastName;
    private final SimpleStringProperty primaryEmail;
    private final SimpleStringProperty secondaryEmail;
    public Person(String firstName, String lastName, String primaryEmail, String secondaryEmail) {
        this.firstName = new SimpleStringProperty(firstName);
        this.lastName = new SimpleStringProperty(lastName);
        this.primaryEmail = new SimpleStringProperty(primaryEmail);
        this.secondaryEmail = new SimpleStringProperty(secondaryEmail);
    }
    public String getFirstName() {
        return firstName.get();
    }
    public void setFirstName(String firstName) {
        this.firstName.set(firstName);
    }
    public SimpleStringProperty firstNameProperty() {
        return firstName;
    }
    public String getLastName() {
        return lastName.get();
    }
    public void setLastName(String lastName) {
        this.lastName.set(lastName);
    }
    public SimpleStringProperty lastNameProperty() {
        return lastName;
    }
    public String getPrimaryEmail() {
        return primaryEmail.get();
    }
    
    public void setPrimaryEmail( String primaryEmail ) {
        this.primaryEmail.set( primaryEmail );
    }
    
    public SimpleStringProperty getPrimaryEmailProperty() {
        return primaryEmail;
    }
    public String getSecondaryEmail() {
        return secondaryEmail.get();
    }
    
    public void setSecondaryEmail( String secondaryEmail ) {
        this.secondaryEmail.set( secondaryEmail );
    }
    
    public SimpleStringProperty getSecondaryEmailProperty() {
        return secondaryEmail;
    }
}

Next is the SampleFX class which is defines the display. This, again, is similar to the official example. I’ve set some minimum column width to improve the display slightly and I’ve deliberately disabled editing in the primary email column.

package samplefx;
import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Group;
import javafx.scene.Scene;
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("Jacob", "Smith", "jacob.smith_at_example.com", "js_at_example.com"),
            new Person("Isabella", "Johnson", "isabella.johnson_at_example.com", "ij_at_example.com"),
            new Person("Ethan", "Williams", "ethan.williams_at_example.com", "ew_at_example.com"),
            new Person("Emma", "Jones", "emma.jones_at_example.com", "ej_at_example.com"),
            new Person("Michael", "Brown", "michael.brown_at_example.com", "mb_at_example.com"));
    /**
     * @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(650);
        stage.setHeight(500);
        final Label label = new Label("Address Book");
        label.setFont(new Font("Arial", 20));
        //Create a customer cell factory so that cells can support editing.
        Callback<TableColumn, TableCell> cellFactory = new Callback<TableColumn, TableCell>() {
            @Override
            public TableCell call(TableColumn p) {
                return new EditingCell();
            }
        };
        
        //Set up the columns
        TableColumn firstNameCol = new TableColumn("First Name");
        firstNameCol.setMinWidth( 100 );
        firstNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("firstName"));
        firstNameCol.setCellFactory(cellFactory);
        TableColumn lastNameCol = new TableColumn("Last Name");
        lastNameCol.setMinWidth( 100 );
        lastNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("lastName"));
        lastNameCol.setCellFactory(cellFactory);
//        lastNameCol.setEditable( false );
        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(cellFactory);
        //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(cellFactory);
//        secondaryEmailCol.setEditable( false );
        emailCol.getColumns().addAll(primaryEmailCol, secondaryEmailCol);
        //Add the columns and data to the table.
        table.setItems(data);
        table.getColumns().addAll(firstNameCol, lastNameCol, emailCol);
        //Make the table editable
        table.setEditable(true);
        //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());
            }
        });
        //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());
            }
        });
        //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());
            }
        });
        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();
    }
}

The bulk of the changes are in the EditingCell which is shown below. The first difference is that I have registered a focus listener on the text field as well as a key pressed handler. I think more often than not the user will want to commit an edit when they click away from a cell rather than lose their changes.

The next change is in the startEdit method where I have registered a runnable that will give the text field focus as soon as editing starts. Without this runnable the user has to click once to select a row, once to start the cell editing and then a third time to put focus in the text field. With the runnable the user has to click once to select the row and a second time to set the cell editing and give it focus. You might thing you could just call textCell.requestFocus directly but it doesn’t work as after startEdit is called the TableView requestsFocus.

Another change is in cancelEdit where I null out the reference to the text field. This is to prevent the focus listener double committing changes. Without this reference clearing  when the user presses enter they get a commit because of the enter and then when the cell returns to it’s regular state the text field loses focus and they get another commit. Normally this wouldn’t matter but if you have expensive commits this could be  big saving.

The big change though is to add support for tabbing through editable cells. Firstly the key pressed handler is expanded to catch the tab key as well. This commits the edit in the current cell and then calls getNextColumn to determine which is the next column that can be edited. The getNextColumn method in turn calls getLeaves which constructs an ordered list of leaf level columns in the table. By building a list in this way the order of the columns returned always respects the order of columns on the screen (don’t forget the columns can be re-ordered) and it ensures the we don’t try and set a parent column as editable. The list will only include those columns which are editable so it is possible that the list will only contain the current column. This situation is handled by returning null for the next column. The recursion is a little awkward because the TableView and TableColumn don’t share a common class but it works well enough and gives the correct result. It would be perfectly possible to end editing once you reached either end of a row but I’ve chosen to wrap tabbing around when it reaches the end. Try reordering the columns and commenting in the lines that disable other columns.

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;
/**
 *
 * @author Graham Smith
 */
public class EditingCell extends TableCell<Person, String> {
    private TextField textField;
    public EditingCell() {
    }
    @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.requestFocus();
                textField.selectAll();
            }
        });
    }
    @Override
    public void cancelEdit() {
        super.cancelEdit();
        setText((String) getItem());
        setContentDisplay(ContentDisplay.TEXT_ONLY);
    }
    @Override
    public void updateItem(String 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) {
                    commitEdit(textField.getText());
                } else if (t.getCode() == KeyCode.ESCAPE) {
                    cancelEdit();
                } else if (t.getCode() == KeyCode.TAB) {
                    commitEdit(textField.getText());
                    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) {
                if (!newValue && textField != null) {
                    commitEdit(textField.getText());
                }
            }
        });
    }
    private String getString() {
        return getItem() == null ? "" : getItem().toString();
    }
    /**
     *
     * @param forward true gets the column to the right, false the column to the left of the current column
     * @return
     */
    private TableColumn<Person, ?> getNextColumn(boolean forward) {
        List<TableColumn<Person, ?>> columns = new ArrayList<>();
        for (TableColumn<Person, ?> 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<Person, ?>> getLeaves(TableColumn<Person, ?> root) {
        List<TableColumn<Person, ?>> 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<Person, ?> column : root.getColumns()) {
                columns.addAll(getLeaves(column));
            }
            return columns;
        }
    }
}

That’s if for now. In the next few days I’ll put together an article describing a more generic cell editing factory which can be used to accept formatted input such as numbers.

Posted in JavaFX and tagged , .

10 Comments

  1. Great work, thanks!

    Did you notice this Copy&Paste:
    https://gist.github.com/abhinayagarwal/9383881

    I’ve got two suggestions:
    I added the following line to avoid a mis-handling when there is a scrollbar: If the “nextColumn” is outside the visible area a wrong field is focused.

    textField.setOnKeyPressed(…

    } else if (t.getCode() == KeyCode.TAB) {
    commitEdit(textField.getText());
    TableColumn nextColumn = getNextColumn(!t.isShiftDown());
    if (nextColumn != null) {
    // ————->
    getTableView().scrollToColumn(nextColumn);
    //
    updateItem(textField.getText(), textField.getText().isEmpty());
    // <———
    }

    because when the user clicks in a different row while editing the cell, the value is changed but the new value isn't shown 😮
    I dont't know whether it is a javaFX bug…

    Greetings
    Erich

  2. Great work, thanks!

    Did you notice this Copy&Paste:
    https://gist.github.com/abhinayagarwal/9383881

    I’ve got two suggestions:
    I added the following line to avoid a mis-handling when there is a scrollbar: If the “nextColumn” is outside the visible area a wrong field is focused.

    textField.setOnKeyPressed(…

    } else if (t.getCode() == KeyCode.TAB) {
    commitEdit(textField.getText());
    TableColumn nextColumn = getNextColumn(!t.isShiftDown());
    if (nextColumn != null) {
    // ————->
    getTableView().scrollToColumn(nextColumn);
    //
    updateItem(textField.getText(), textField.getText().isEmpty());
    // <———
    }

    because when the user clicks in a different row while editing the cell, the value is changed but the new value isn't shown 😮
    I dont't know whether it is a javaFX bug…

    Greetings
    Erich

  3. Some code got lost 🙁
    Sorry for the multiple postings.

    Here is the correct code for the second suggestion

    textField.focusedProperty().addListener(…

    if (!newValue && textField != null) {
    commitEdit(textField.getText());
    // ——–>
    updateItem(textField.getText(), textField.getText().isEmpty());
    // <———
    }

Leave a Reply

Your email address will not be published. Required fields are marked *