TableView Future/Enhancements

Daniel Peintner daniel.peintner at gmail.com
Tue Oct 13 12:19:31 UTC 2020


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


More information about the openjfx-dev mailing list