TableView doesn't commit values on focus lost event
Asked Answered
E

4

17

I'd like to create a table with the following features:

  • Edit on key press
  • Enter key = next row
  • Tab key = next column
  • Escape key = cancel edit

Below is a code which implements these features. The values should be committed on focus lost. Problem: They aren't committed. The focus change event is fired, the values would be correct according to the console output, but in the end the values in the table cells are the old ones.

Does anyone know how to prevent this and how do you get the current EditingCell object so that I can invoke commit manually? After all there should be some kind of verifier invoked which prevents changing the focus if the values aren't correct.

import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
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.TablePosition;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.VBox;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import javafx.util.Callback;

public class TableViewInlineEditDemo extends Application {

    private final TableView<Person> table = new TableView<>();
    private final ObservableList<Person> data =
            FXCollections.observableArrayList(
            new Person("Jacob", "Smith", "[email protected]"),
            new Person("Isabella", "Johnson", "[email protected]"),
            new Person("Ethan", "Williams", "[email protected]"),
            new Person("Emma", "Jones", "[email protected]"),
            new Person("Michael", "Brown", "[email protected]"));

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) {
        Scene scene = new Scene(new Group());
        stage.setWidth(450);
        stage.setHeight(550);

        final Label label = new Label("Address Book");
        label.setFont(new Font("Arial", 20));

        table.setEditable(true);

        Callback<TableColumn<Person, String>, TableCell<Person, String>> cellFactory = (TableColumn<Person, String> p) -> new EditingCell();

        TableColumn<Person, String> firstNameCol = new TableColumn<>("First Name");
        TableColumn<Person, String> lastNameCol = new TableColumn<>("Last Name");
        TableColumn<Person, String> emailCol = new TableColumn<>("Email");

        firstNameCol.setMinWidth(100);
        firstNameCol.setCellValueFactory(new PropertyValueFactory<>("firstName"));
        firstNameCol.setCellFactory(cellFactory);
        firstNameCol.setOnEditCommit((CellEditEvent<Person, String> t) -> {
            ((Person) t.getTableView().getItems().get(t.getTablePosition().getRow())).setFirstName(t.getNewValue());
        });

        lastNameCol.setMinWidth(100);
        lastNameCol.setCellValueFactory(new PropertyValueFactory<>("lastName"));
        lastNameCol.setCellFactory(cellFactory);
        lastNameCol.setOnEditCommit((CellEditEvent<Person, String> t) -> {
            ((Person) t.getTableView().getItems().get(t.getTablePosition().getRow())).setLastName(t.getNewValue());
        });

        emailCol.setMinWidth(200);
        emailCol.setCellValueFactory(new PropertyValueFactory<>("email"));
        emailCol.setCellFactory(cellFactory);
        emailCol.setOnEditCommit((CellEditEvent<Person, String> t) -> {
            ((Person) t.getTableView().getItems().get(t.getTablePosition().getRow())).setEmail(t.getNewValue());
        });

        table.setItems(data);
        table.getColumns().addAll(firstNameCol, lastNameCol, emailCol);


        // edit mode on keypress
        table.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
            @Override
            public void handle(KeyEvent e) {

                if( e.getCode() == KeyCode.TAB) { // commit should be performed implicitly via focusedProperty, but isn't
                    table.getSelectionModel().selectNext();
                    e.consume();
                    return;
                }
                else if( e.getCode() == KeyCode.ENTER) { // commit should be performed implicitly via focusedProperty, but isn't
                    table.getSelectionModel().selectBelowCell();
                    e.consume();
                    return;
                }

                // switch to edit mode on keypress, but only if we aren't already in edit mode
                if( table.getEditingCell() == null) {
                    if( e.getCode().isLetterKey() || e.getCode().isDigitKey()) {  

                        TablePosition focusedCellPosition = table.getFocusModel().getFocusedCell();
                        table.edit(focusedCellPosition.getRow(), focusedCellPosition.getTableColumn());

                    }
                }

            }
        });

        // single cell selection mode
        table.getSelectionModel().setCellSelectionEnabled(true);
        table.getSelectionModel().selectFirst();


        final VBox vbox = new VBox();
        vbox.getChildren().addAll(label, table);

        ((Group) scene.getRoot()).getChildren().addAll(vbox);

        stage.setScene(scene);
        stage.show();
    }


    class EditingCell extends TableCell<Person, String> {

        private TextField textField;

        public EditingCell() {
        }

        @Override
        public void startEdit() {
            if (!isEmpty()) {
                super.startEdit();
                createTextField();
                setText(null);
                setGraphic(textField);
                textField.requestFocus(); // must be before selectAll() or the caret would be in wrong position
                textField.selectAll();
            } 
        }

        @Override
        public void cancelEdit() {
            super.cancelEdit();
            setText((String) getItem());
            setGraphic(null);
        }

        @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());
                    }
                    setText(null);
                    setGraphic(textField);
                } else {
                    setText(getString());
                    setGraphic(null);
                }
            }
        }

        private void createTextField() {

            textField = new TextField(getString());
            textField.setMinWidth(this.getWidth() - this.getGraphicTextGap() * 2);

            // commit on focus lost
            textField.focusedProperty().addListener((ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) -> {

                if( oldValue = true && newValue == false) {

                    System.out.println( "Focus lost, current value: " + textField.getText());

                    commitEdit();

                }
            });

            // cancel edit on ESC
            textField.addEventFilter(KeyEvent.KEY_RELEASED, e -> {

                if( e.getCode() == KeyCode.ESCAPE) {
                    cancelEdit();
                }

            });

        }

        private String getString() {
            return getItem() == null ? "" : getItem().toString();
        }

        private boolean commitEdit() {
            super.commitEdit(textField.getText());
            return true; // TODO: add verifier and check if commit was possible
        }
    }

    public static class Person {

        private final SimpleStringProperty firstName;
        private final SimpleStringProperty lastName;
        private final SimpleStringProperty email;

        private Person(String fName, String lName, String email) {
            this.firstName = new SimpleStringProperty(fName);
            this.lastName = new SimpleStringProperty(lName);
            this.email = new SimpleStringProperty(email);
        }

        public String getFirstName() {
            return firstName.get();
        }

        public void setFirstName(String fName) {
            firstName.set(fName);
        }

        public String getLastName() {
            return lastName.get();
        }

        public void setLastName(String fName) {
            lastName.set(fName);
        }

        public String getEmail() {
            return email.get();
        }

        public void setEmail(String fName) {
            email.set(fName);
        }
    }

}

Thank you very much!

Edit: I've narrowed it down. It seems the problem is that the JavaFX code cancels the edit mode when the focus changes. That's bad.

public Cell() {
    setText(null); // default to null text, to match the null item
    // focusTraversable is styleable through css. Calling setFocusTraversable
    // makes it look to css like the user set the value and css will not 
    // override. Initializing focusTraversable by calling set on the 
    // CssMetaData ensures that css will be able to override the value.
    ((StyleableProperty<Boolean>)(WritableValue<Boolean>)focusTraversableProperty()).applyStyle(null, Boolean.FALSE);
    getStyleClass().addAll(DEFAULT_STYLE_CLASS);

    /**
     * Indicates whether or not this cell has focus. For example, a
     * ListView defines zero or one cell as being the "focused" cell. This cell
     * would have focused set to true.
     */
    super.focusedProperty().addListener(new InvalidationListener() {
        @Override public void invalidated(Observable property) {
            pseudoClassStateChanged(PSEUDO_CLASS_FOCUSED, isFocused()); // TODO is this necessary??

            // The user has shifted focus, so we should cancel the editing on this cell
            if (!isFocused() && isEditing()) {
                cancelEdit();
            }
        }
    });

    // initialize default pseudo-class state
    pseudoClassStateChanged(PSEUDO_CLASS_EMPTY, true);
}
Effie answered 11/4, 2015 at 10:4 Comment(0)
D
18

I got curious and did some background research.

You are facing the problem of a well-known bug in the JavaFX.

Background

When you call commitEdit(textField.getText()), the first thing it does is to check the value of isEditing() and returns if the value is false, without committing.

public void commitEdit(T newValue) {
    if (! isEditing()) return;

    ... // Rest of the things
}

Why does it return false?

As you have probably found out, as soon as you press TAB or ENTER to change your selection, cancelEdit() is called which sets the TableCell.isEditing() to false. By the time the commitEdit() inside textField's focus property listener is called, isEditing() is already returning false.

Solutions / Hacks

There have been on going discussion on the Topic in JavaFX community. People in there have posted hacks, which you are most welcome to look at.

There is a hack shown in a SO thread, which seems to get the job done, although I haven't tried it (yet).

Demibastion answered 11/4, 2015 at 12:0 Comment(3)
I think this bug is fixed now? docs.oracle.com/javafx/2/ui_controls/table-view.htm (code after the word "expected") is working for me.Farreaching
I found the following hack, and worked perfectly. so I'm sharing the link. It is made by James D. on GitHub: gist.github.com/james-d/be5bbd6255a4640a5357Odessaodetta
Try to use TableView2 from ControlsFX. It is a much richer control with less nuisance.Demibastion
C
5

I've run into the same issue and I solved it by combining these two code snippets:

Custom TableCell implementation

public class EditCell<S, T> extends TableCell<S, T> {
    private final TextField textField = new TextField();

    // Converter for converting the text in the text field to the user type, and vice-versa:
    private final StringConverter<T> converter;

    /**
     * Creates and initializes an edit cell object.
     * 
     * @param converter
     *            the converter to convert from and to strings
     */
    public EditCell(StringConverter<T> converter) {
        this.converter = converter;

        itemProperty().addListener((obx, oldItem, newItem) -> {
            setText(newItem != null ? this.converter.toString(newItem) : null);
        });

        setGraphic(this.textField);
        setContentDisplay(ContentDisplay.TEXT_ONLY);

        this.textField.setOnAction(evt -> {
            commitEdit(this.converter.fromString(this.textField.getText()));
        });
        this.textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> {
            if (!isNowFocused) {
                commitEdit(this.converter.fromString(this.textField.getText()));
            }
        });
        this.textField.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
            if (event.getCode() == KeyCode.ESCAPE) {
                this.textField.setText(this.converter.toString(getItem()));
                cancelEdit();
                event.consume();
            } else if (event.getCode() == KeyCode.TAB) {
                commitEdit(this.converter.fromString(this.textField.getText()));
                TableColumn<S, ?> nextColumn = getNextColumn(!event.isShiftDown());
                if (nextColumn != null) {
                    getTableView().getSelectionModel().clearAndSelect(getTableRow().getIndex(), nextColumn);
                    getTableView().edit(getTableRow().getIndex(), nextColumn);
                }
            }
        });
    }

    /**
     * Convenience converter that does nothing (converts Strings to themselves and vice-versa...).
     */
    public static final StringConverter<String> IDENTITY_CONVERTER = new StringConverter<String>() {

        @Override
        public String toString(String object) {
            return object;
        }

        @Override
        public String fromString(String string) {
            return string;
        }

    };

    /**
     * Convenience method for creating an EditCell for a String value.
     * 
     * @return the edit cell
     */
    public static <S> EditCell<S, String> createStringEditCell() {
        return new EditCell<S, String>(IDENTITY_CONVERTER);
    }

    // set the text of the text field and display the graphic
    @Override
    public void startEdit() {
        super.startEdit();
        this.textField.setText(this.converter.toString(getItem()));
        setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
        this.textField.requestFocus();
    }

    // revert to text display
    @Override
    public void cancelEdit() {
        super.cancelEdit();
        setContentDisplay(ContentDisplay.TEXT_ONLY);
    }

    // commits the edit. Update property if possible and revert to text display
    @Override
    public void commitEdit(T item) {
        // This block is necessary to support commit on losing focus, because the baked-in mechanism
        // sets our editing state to false before we can intercept the loss of focus.
        // The default commitEdit(...) method simply bails if we are not editing...
        if (!isEditing() && !item.equals(getItem())) {
            TableView<S> table = getTableView();
            if (table != null) {
                TableColumn<S, T> column = getTableColumn();
                CellEditEvent<S, T> event = new CellEditEvent<>(table,
                        new TablePosition<S, T>(table, getIndex(), column),
                        TableColumn.editCommitEvent(), item);
                Event.fireEvent(column, event);
            }
        }

        super.commitEdit(item);

        setContentDisplay(ContentDisplay.TEXT_ONLY);
    }

    /**
     * Finds and returns the next editable column.
     * 
     * @param forward
     *            indicates whether to search forward or backward from the current column
     * @return the next editable column or {@code null} if there is no next column available
     */
    private TableColumn<S, ?> getNextColumn(boolean forward) {
        List<TableColumn<S, ?>> columns = new ArrayList<>();
        for (TableColumn<S, ?> column : getTableView().getColumns()) {
            columns.addAll(getEditableColumns(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);
    }

    /**
     * Returns all editable columns of a table column (supports nested columns).
     * 
     * @param root
     *            the table column to check for editable columns
     * @return a list of table columns which are editable
     */
    private List<TableColumn<S, ?>> getEditableColumns(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(getEditableColumns(column));
            }
            return columns;
        }
    }
}

Controller

    @FXML
    private void initialize() {
        table.getSelectionModel().setCellSelectionEnabled(true);
        table.setEditable(true);

        table.getColumns().add(createColumn("First Name", Person::firstNameProperty));
        table.getColumns().add(createColumn("Last Name", Person::lastNameProperty));
        table.getColumns().add(createColumn("Email", Person::emailProperty));

        table.getItems().addAll(
                new Person("Jacob", "Smith", "[email protected]"),
                new Person("Isabella", "Johnson", "[email protected]"),
                new Person("Ethan", "Williams", "[email protected]"),
                new Person("Emma", "Jones", "[email protected]"),
                new Person("Michael", "Brown", "[email protected]")
        );

        table.setOnKeyPressed(event -> {
            TablePosition<Person, ?> pos = table.getFocusModel().getFocusedCell() ;
            if (pos != null && event.getCode().isLetterKey()) {
                table.edit(pos.getRow(), pos.getTableColumn());
            }
        });
    }

    private <T> TableColumn<T, String> createColumn(String title, Function<T, StringProperty> property) {
        TableColumn<T, String> col = new TableColumn<>(title);
        col.setCellValueFactory(cellData -> property.apply(cellData.getValue()));

        col.setCellFactory(column -> EditCell.createStringEditCell());
        return col;
    }
Confucianism answered 9/11, 2017 at 15:4 Comment(0)
R
2

My proposal to solve this atrocity is the following (sorry for missing JavaDoc).

This is a cancel-to-commit redirection solution. I tested it under LINUX with Java 1.8.0-121. Here, the only way how to discard a cell editor is to press ESCAPE.

import javafx.beans.binding.Bindings;
import javafx.scene.Node;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TableCell;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;

public abstract class AutoCommitTableCell<S,T> extends TableCell<S,T>
{
    private Node field;
    private boolean startEditing;
    private T defaultValue;


    /** @return a newly created input field. */
    protected abstract Node newInputField();

    /** @return the current value of the input field. */
    protected abstract T getInputValue();

    /** Sets given value to the input field. */
    protected abstract void setInputValue(T value);

    /** @return the default in case item is null, must be never null, else cell will not be editable. */
    protected abstract T getDefaultValue();

    /** @return converts the given value to a string, being the cell-renderer representation. */
    protected abstract String inputValueToText(T value);


    @Override
    public void startEdit() {
        try {
            startEditing = true;

            super.startEdit();  // updateItem() will be called

            setInputValue(getItem());
        }
        finally {
            startEditing = false;
        }
    }

    /** Redirects to commitEdit(). Leaving the cell should commit, just ESCAPE should cancel. */
    @Override
    public void cancelEdit() {
        // avoid JavaFX NullPointerException when calling commitEdit()
        getTableView().edit(getIndex(), getTableColumn());

        commitEdit(getInputValue());
    }

    private void cancelOnEscape() {
        if (defaultValue != null)    {   // canceling default means writing null
            setItem(defaultValue = null);
            setText(null);
            setInputValue(null);
        }
        super.cancelEdit();
    }

    @Override
    protected void updateItem(T newValue, boolean empty) {
        if (startEditing && newValue == null)
            newValue = (defaultValue = getDefaultValue());

        super.updateItem(newValue, empty);

        if (empty || newValue == null) {
            setText(null);
            setGraphic(null);
        }
        else {
            setText(inputValueToText(newValue));
            setGraphic(startEditing || isEditing() ? getInputField() : null);
        }
    }

    protected final Node getInputField()    {
        if (field == null)    {
            field = newInputField();

            // a cell-editor won't be committed or canceled automatically by JFX
            field.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
                if (event.getCode() == KeyCode.ENTER || event.getCode() == KeyCode.TAB)
                    commitEdit(getInputValue());
                else if (event.getCode() == KeyCode.ESCAPE)
                    cancelOnEscape();
            });

            contentDisplayProperty().bind(
                    Bindings.when(editingProperty())
                        .then(ContentDisplay.GRAPHIC_ONLY)
                        .otherwise(ContentDisplay.TEXT_ONLY)
                );
        }
        return field;
    }
}

You can extend this class to support any data type.

Example for a String field is (Person is an example bean):

import javafx.scene.Node;
import javafx.scene.control.TextField;
import jfx.examples.tablebinding.PersonsModel.Person;

public class StringTableCell extends AutoCommitTableCell<Person,String>
{
    @Override
    protected String getInputValue() {
        return ((TextField) getInputField()).getText();
    }

    @Override
    protected void setInputValue(String value) {
        ((TextField) getInputField()).setText(value);
    }

    @Override
    protected String getDefaultValue() {
        return "";
    }

    @Override
    protected Node newInputField() {
        return new TextField();
    }

   @Override
    protected String inputValueToText(String newValue) {
        return newValue;
    }
}

To be applied in this way:

final TableColumn<Person,String> nameColumn = new TableColumn<Person,String>("Name");
nameColumn.setCellValueFactory(
        cellDataFeatures -> cellDataFeatures.getValue().nameProperty());
nameColumn.setCellFactory(
        cellDataFeatures -> new StringTableCell());
Resonate answered 20/3, 2017 at 22:6 Comment(1)
For me this solution is not working. I have 2 columns with extends this class, When I start editing one column and try to edit other in same row, I get StackOverflowException on line: getTableView().edit(getIndex(), getTableColumn());Zettazeugma
P
0

I had found a simple solution which works in my case for TableCells. The idea is to forget about commitEdit at focus lost. Let javafx do its work, and then just update the value of the previously edited cell.

abstract class EditingTextCell<T, V> extends TableCell<T, V> {
    protected TextField textField;
    private T editedItem;

    @Override
    public void startEdit() {
        ...
        textField.focusedProperty().addListener((t, oldval, newval) -> {
            if (!newval) {
                setItemValue(editedItem, textField.getText());
            }
        });

        editedItem = (T) getTableRow().getItem();
    }
    public abstract void setItemValue(T item, String text);
    ...
}

so, the only trick is to implement the setItemValue() in such a way that it updates the correct part of the item.

Put answered 16/2, 2018 at 13:46 Comment(2)
hmm .. you don't necessarily get a focusLost for the textField - that's part of why the problem is so hard to hack around ;)Riffraff
@Riffraff I am getting it when I click on a different table cell while editing. The goal is to save the edits in such a case.Put

© 2022 - 2024 — McMap. All rights reserved.