All, I would like to ask some questions (and provide feedback) w.r.t. the current TableView control in JavaFX. If this is not the *right* place to ask such questions please let me know and I apologize.
The desire of a table (spreadsheet) control is to be editable and usable also with the keyboard. Moreover if using it with a mouse a focus loss is also mostly likely seen as a commit by a user (and not a revert as it is now) Some of the afore mentioned points are not (yet) realized and people seem to be aware of the issue (see [1]). I am not sure if there is work planned w.r.t. JDK-8089514 given that the work seems stalled... Attached is an EditableTableCell application that supports some of the desired features (but far not all). It uses some very dirty hacks to achieve for example editing and walking through a table (I tend to say some (all?) features should be built-in already). With this email I would like to 1. Raise the question whether future work is planned in the area of TableView (e.g., TableView2 that used to be part of ControlsFX but I think belongs to the core TableView) 2. Show some use cases people might have in mind when using a table control. The attached example allows one to - walk through a table when in editing mode (up/down with cursor, left/right with tabs) e.g., double click in upper left corner and walk on with tab key - (optionally) register a callable that adds a *new* row if end is reached - skip some columns that are not editable (like pulldown boxes) Missing features - better F2 edit support - use the keyboard also with combo boxes or other cell controls Please let me know whether there is interest in having such capabilities in the built-in TableView control and how I can help. I am open for suggestions. (i.e. I think JDK-8089514 could be solved at least for TableView. Not sure though about the other mentioned controls were I don't have the big picture) Sorry again for the lengthy email and any feedback is welcome! Thanks, -- Daniel [1] https://bugs.openjdk.java.net/browse/JDK-8089514 ############################# import javafx.application.Application; import javafx.application.Platform; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.concurrent.Task; import javafx.event.Event; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.stage.Stage; import javafx.util.Pair; import javafx.util.StringConverter; import javafx.util.converter.DefaultStringConverter; import javafx.util.converter.IntegerStringConverter; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; public class EditableTableCellApplication extends Application { public class EditableTableCell<S, T> extends TableCell<S, T> { public final Object NOT_TRAVERSABLE = new Object(); // Text field for editing // TODO: allow this to be a pluggable control final TextField textField = new TextField(); // converter for converting the text in the text field to the user type, and vice-versa final StringConverter<T> converter; // callable to create new row final Callable<Void> callableOnRowEnd; public EditableTableCell(StringConverter<T> converter) { this(converter, false); } public EditableTableCell(StringConverter<T> converter, boolean traverseEditableCell) { this(converter, traverseEditableCell, null); } public EditableTableCell(StringConverter<T> converter, boolean traverseEditableCell, Callable<Void> callableOnRowEnd) { this.converter = converter; this.callableOnRowEnd = callableOnRowEnd; itemProperty().addListener((obx, oldItem, newItem) -> { if (newItem == null) { setText(null); } else { setText(converter.toString(newItem)); } }); setGraphic(textField); setContentDisplay(ContentDisplay.TEXT_ONLY); textField.setOnAction(evt -> commitEdit(this.converter.fromString(textField.getText()))); textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> { if (!isNowFocused) { commitEdit(this.converter.fromString(textField.getText())); } }); textField.addEventFilter(KeyEvent.KEY_PRESSED, event -> { if (event.getCode() == KeyCode.ESCAPE) { textField.setText(converter.toString(getItem())); cancelEdit(); event.consume(); } else if ((!event.isShiftDown() && event.getCode() == KeyCode.TAB)) { event.consume(); getTableView().getSelectionModel().selectRightCell(); if (traverseEditableCell) { Pair<TableColumn<S, ?>, Boolean> pairTcEnd = getNextVisibleColumn(true); if (pairTcEnd.getValue() != null && pairTcEnd.getValue()) { // reached end -> new line TableColumn<S, ?> tc0 = getFirstTraversableColumn(); if (tc0 != null) { //noinspection ConstantConditions moveRowDown(tc0, event, traverseEditableCell); } } else { // move right in row makeCellEditable(getTableRow().getIndex(), getTableView(), pairTcEnd.getKey()); } } } else if ((event.isShiftDown() && event.getCode() == KeyCode.TAB)) { if (traverseEditableCell) { Pair<TableColumn<S, ?>, Boolean> pairTcEnd = getNextVisibleColumn(false); if (pairTcEnd.getValue() != null && pairTcEnd.getValue()) { // reached end -> new line TableColumn<S, ?> tcLast = getLastTraversableColumn(); if (tcLast != null) { //noinspection ConstantConditions moveRowUp(tcLast, event, traverseEditableCell); } } else { // move left in row makeCellEditable(getTableRow().getIndex(), getTableView(), pairTcEnd.getKey()); } } } else if (event.getCode() == KeyCode.UP) { moveRowUp(getTableColumn(), event, traverseEditableCell); } else if (event.getCode() == KeyCode.DOWN) { moveRowDown(getTableColumn(), event, traverseEditableCell); } }); } // set the text of the text field and display the graphic @Override public void startEdit() { if (!editableProperty().get()) { return; } super.startEdit(); textField.setText(converter.toString(getItem())); setContentDisplay(ContentDisplay.GRAPHIC_ONLY); 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 fails if we are not editing... if (!isEditing() && item != null && !item.equals(getItem())) { TableView<S> table = getTableView(); if (table != null) { TableColumn<S, T> column = getTableColumn(); TableColumn.CellEditEvent<S, T> event = new TableColumn.CellEditEvent<>(table, new TablePosition<>(table, getIndex(), column), TableColumn.editCommitEvent(), item); Event.fireEvent(column, event); } } super.commitEdit(item); setContentDisplay(ContentDisplay.TEXT_ONLY); } private void makeCellEditable(int row2, TableView<S> tw, TableColumn<S, ?> tc) { if (tc != null) { // HACK, JavaFX BUG!!! Task<Void> task = new Task<Void>() { @Override protected Void call() throws Exception { Thread.sleep(10); Platform.runLater(() -> { if (row2 >= 0 && row2 < tw.getItems().size()) { tw.getSelectionModel().clearAndSelect(row2, tc); tw.edit(row2, tc); } }); return null; } }; Thread th = new Thread(task); th.start(); } } private TableColumn<S, ?> getFirstTraversableColumn() { for (TableColumn<S, ?> tc : getTableView().getColumns()) { if (isTraversable(tc)) { return tc; } } return null; } private TableColumn<S, ?> getLastTraversableColumn() { for (int i = getTableView().getColumns().size() - 1; i >= 0; i--) { TableColumn<S, ?> tc = getTableView().getColumns().get(i); if (isTraversable(tc)) { return tc; } } return null; } private void moveRowDown(TableColumn<S, ?> tc, Event event, boolean traverseEditableCell) { int row2 = getTableRow().getIndex() + 1; //noinspection StatementWithEmptyBody if (row2 < getTableView().getItems().size()) { // still ok --> normal select } else { // expand rows if (callableOnRowEnd != null) { try { callableOnRowEnd.call(); } catch (Exception e) { e.printStackTrace(); } } } // select getTableView().getSelectionModel().clearAndSelect(row2, tc); commitEdit(converter.fromString(textField.getText())); // needed (otherwise data lost) event.consume(); if (traverseEditableCell) { makeCellEditable(row2, getTableView(), tc); } } private void moveRowUp(TableColumn<S, ?> tc, Event event, boolean traverseEditableCell) { int row2 = getTableRow().getIndex() - 1; // select getTableView().getSelectionModel().clearAndSelect(row2, tc); commitEdit(converter.fromString(textField.getText())); // needed (otherwise data lost) event.consume(); if (traverseEditableCell) { makeCellEditable(row2, getTableView(), tc); } } private Pair<TableColumn<S, ?>, Boolean> getNextVisibleColumn(boolean forward) { List<TableColumn<S, ?>> columns = new ArrayList<>(); for (TableColumn<S, ?> column : getTableView().getColumns()) { columns.addAll(getLeaves(column)); } int nextIndex = columns.indexOf(getTableColumn()); TableColumn<S, ?> tc; boolean reachedEdge = false; do { if (forward) { nextIndex++; if (nextIndex > columns.size() - 1) { nextIndex = columns.size() - 1; reachedEdge = true; } } else { nextIndex--; if (nextIndex < 0) { nextIndex = 0; reachedEdge = true; } } tc = columns.get(nextIndex); } while (!tc.isVisible() && !reachedEdge); return new Pair<>(tc, reachedEdge); } 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 / traversable if (isTraversable(root)) { columns.add(root); } } else { for (TableColumn<S, ?> column : root.getColumns()) { columns.addAll(getLeaves(column)); } } return columns; } private boolean isTraversable(TableColumn<S, ?> tc) { if (tc != null) { return (tc.isEditable() && tc.getUserData() != NOT_TRAVERSABLE); } return false; } } public class Person { final StringProperty firstName; final StringProperty lastName; final ObjectProperty<Integer> age; public Person() { this.firstName = new SimpleStringProperty(); this.lastName = new SimpleStringProperty(); this.age = new SimpleObjectProperty<>(); } public Person(String firstName, String lastName, int age) { this(); this.firstName.set(firstName); this.lastName.set(lastName); this.age.set(age); } public StringProperty firstNameProperty() { return this.firstName; } public StringProperty lastNameProperty() { return this.lastName; } public ObjectProperty<Integer> ageProperty() { return this.age; } } class CallableAdd implements Callable<Void> { @Override public Void call() { detailData.add(new Person()); return null; } } // The table's data ObservableList<Person> detailData = FXCollections.observableArrayList(); @Override public void start(Stage primaryStage) throws IOException { TableView<Person> tableView = new TableView<>(); tableView.setEditable(true); // without the following cell selection setting SHIFT+TAB does not work tableView.getSelectionModel().setCellSelectionEnabled(true); boolean traversable = true; CallableAdd callableAdd = new CallableAdd(); // remove empty row highlighting to better illustrate that *new* rows are added by tabbing through File f = File.createTempFile("style", ".css"); Files.write(Paths.get(f.getPath()), ".table-row-cell:empty {-fx-background-color: white;} .table-row-cell:empty .table-cell {-fx-border-width: 0px;}".getBytes()); tableView.getStylesheets().add("file:///" + f.getAbsolutePath().replace("\\", "/")); TableColumn<Person, String> columnFirstName = new TableColumn<>("First Name"); columnFirstName.setCellValueFactory(param -> param.getValue().firstNameProperty()); columnFirstName.setCellFactory(column -> new EditableTableCell<>(new DefaultStringConverter(), traversable, callableAdd)); columnFirstName.setEditable(true); TableColumn<Person, String> columnLastName = new TableColumn<>("Last Name"); columnLastName.setCellValueFactory(param -> param.getValue().lastNameProperty()); columnLastName.setCellFactory(column -> new EditableTableCell<>(new DefaultStringConverter(), traversable, callableAdd)); columnLastName.setEditable(true); TableColumn<Person, Integer> columnAge = new TableColumn<>("Age"); columnAge.setCellValueFactory(param -> param.getValue().ageProperty()); columnAge.setCellFactory(column -> new EditableTableCell<>(new IntegerStringConverter(), traversable, callableAdd)); columnAge.setEditable(true); tableView.getColumns().addAll(columnFirstName, columnLastName, columnAge); SortedList<Person> sortedData = new SortedList<>(detailData); tableView.setItems(sortedData); detailData.add(new Person("John", "Doe", 25)); detailData.add(new Person("Jane", "Deer", 30)); // create and show scene Scene scene = new Scene(tableView, 600, 400); primaryStage.setTitle("Editable TableCell Application"); primaryStage.setScene(scene); primaryStage.show(); } public static void main(String[] args) { launch(args); } }