为 TableView 中的 CheckBox 单元格实现选项卡功能

Implementing tab functionality for CheckBox cells in TableView

我创建了一个 TableView,其中每个单元格都包含一个 TextField 或一个 CheckBox。在 TableView 中,您应该能够使用 TAB 和 SHIFT+TAB 在单元格之间左右导航,并使用向上键和向下键在单元格之间上下导航。

当文本字段单元格获得焦点时,这非常有效。但是当一个复选框单元格被聚焦时,选项卡功能表现得很奇怪。您可以在您从中选择标签的单元格的相反方向进行标签,但您不能切换标签方向。

例如,如果您仅使用 TAB 键切换到复选框单元格,则 SHIFT+TAB 将不起作用。但是,如果您使用 TAB 键切换到下一个单元格,然后使用 SHIFT+TAB 键返回(假设下一个单元格是文本字段单元格),则 TAB 键将不起作用。

我已经尝试 运行 使用 Platform.runLater() 将任何代码处理集中在 UI 线程上,没有任何显着差异。我所知道的是 TAB KeyEvent 被正确捕获,但复选框单元格和复选框无论如何都不会失去焦点。例如,我尝试通过执行例如手动删除其焦点getParent().requestFocus() 但这只会导致父级而不是下一个单元格被聚焦。奇怪的是,当您在您来自的单元格的相反方向上切换时,相同的代码会被执行并正常工作。

这是关于此问题的 MCVE。可悲的是,它并没有真正达到缩写的 "M":

import java.util.List;

import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.CheckBox;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;

public class AlwaysEditableTable extends Application {
    public void start(Stage stage) {

        TableView<ObservableList<StringProperty>> table = new TableView<>();
        table.setEditable(true);
        table.getSelectionModel().setCellSelectionEnabled(true);
        table.setPrefWidth(510);

        // Dummy columns
        ObservableList<String> columns = FXCollections.observableArrayList("Column1", "Column2", "Column3", "Column4",
                "Column5");

        // Dummy data
        ObservableList<StringProperty> row1 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
                new SimpleStringProperty("Cell2"), new SimpleStringProperty("0"), new SimpleStringProperty("Cell4"),
                new SimpleStringProperty("0"));
        ObservableList<StringProperty> row2 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
                new SimpleStringProperty("Cell2"), new SimpleStringProperty("1"), new SimpleStringProperty("Cell4"),
                new SimpleStringProperty("0"));
        ObservableList<StringProperty> row3 = FXCollections.observableArrayList(new SimpleStringProperty("Cell1"),
                new SimpleStringProperty("Cell2"), new SimpleStringProperty("1"), new SimpleStringProperty("Cell4"),
                new SimpleStringProperty("0"));
        ObservableList<ObservableList<StringProperty>> data = FXCollections.observableArrayList(row1, row2, row3);

        for (int i = 0; i < columns.size(); i++) {
            final int j = i;
            TableColumn<ObservableList<StringProperty>, String> col = new TableColumn<>(columns.get(i));
            col.setCellValueFactory(param -> param.getValue().get(j));
            col.setPrefWidth(100);

            if (i == 2 || i == 4) {
                col.setCellFactory(e -> new CheckBoxCell(j));
            } else {
                col.setCellFactory(e -> new AlwaysEditingCell(j));
            }

            table.getColumns().add(col);
        }

        table.setItems(data);

        Scene scene = new Scene(table);
        stage.setScene(scene);
        stage.show();
    }

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

    /**
     * A cell that contains a text field that is always shown.
     */
    public static class AlwaysEditingCell extends TableCell<ObservableList<StringProperty>, String> {

        private final TextField textField;

        public AlwaysEditingCell(int columnIndex) {

            textField = new TextField();

            this.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) -> {
                if (isNowEmpty) {
                    setGraphic(null);
                } else {
                    setGraphic(textField);
                }
            });

            // The index is not changed until tableData is instantiated, so this
            // ensure the we wont get a NullPointerException when we do the
            // binding.
            this.indexProperty().addListener((obs, oldValue, newValue) -> {

                ObservableList<ObservableList<StringProperty>> tableData = getTableView().getItems();
                int oldIndex = oldValue.intValue();
                if (oldIndex >= 0 && oldIndex < tableData.size()) {
                    textField.textProperty().unbindBidirectional(tableData.get(oldIndex).get(columnIndex));
                }
                int newIndex = newValue.intValue();
                if (newIndex >= 0 && newIndex < tableData.size()) {
                    textField.textProperty().bindBidirectional(tableData.get(newIndex).get(columnIndex));
                    setGraphic(textField);
                } else {
                    setGraphic(null);
                }

            });

            // Every time the cell is focused, the focused is passed down to the
            // text field and all of the text in the textfield is selected.
            this.focusedProperty().addListener((obs, oldValue, newValue) -> {
                if (newValue) {
                    textField.requestFocus();
                    textField.selectAll();
                    System.out.println("Cell focused!");
                }
            });

            // Switches focus to the cell below if ENTER or the DOWN arrow key
            // is pressed, and to the cell above if the UP arrow key is pressed.
            // Works like a charm. We don't have to add any functionality to the
            // TAB key in these cells because the default tab behavior in
            // JavaFX works here.
            this.addEventFilter(KeyEvent.KEY_RELEASED, e -> {
                if (e.getCode().equals(KeyCode.UP)) {
                    getTableView().getFocusModel().focus(getIndex() - 1, this.getTableColumn());
                    e.consume();
                } else if (e.getCode().equals(KeyCode.DOWN)) {
                    getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
                    e.consume();
                } else if (e.getCode().equals(KeyCode.ENTER)) {
                    getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
                    e.consume();
                }
            });
        }
    }

    /**
     * A cell containing a checkbox. The checkbox represent the underlying value
     * in the cell. If the cell value is 0, the checkbox is unchecked. Checking
     * or unchecking the checkbox will change the underlying value.
     */
    public static class CheckBoxCell extends TableCell<ObservableList<StringProperty>, String> {
        private final CheckBox box;

        public CheckBoxCell(int columnIndex) {

            this.box = new CheckBox();

            this.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) -> {
                if (isNowEmpty) {
                    setGraphic(null);
                } else {
                    setGraphic(box);
                }
            });

            this.indexProperty().addListener((obs, oldValue, newValue) -> {
                // System.out.println("Row: " + getIndex() + ", Column: " +
                // columnIndex + ". Old index: " + oldValue
                // + ". New Index: " + newValue);

                ObservableList<ObservableList<StringProperty>> tableData = getTableView().getItems();
                int newIndex = newValue.intValue();
                if (newIndex >= 0 && newIndex < tableData.size()) {
                    // If active value is "1", the check box will be set to
                    // selected.
                    box.setSelected(tableData.get(getIndex()).get(columnIndex).equals("1"));

                    // We add a listener to the selected property. This will
                    // allow us to execute code every time the check box is
                    // selected or deselected.
                    box.selectedProperty().addListener((observable, oldVal, newVal) -> {
                        if (newVal) {
                            // If newValue is true the checkBox is selected, and
                            // we set the corresponding cell value to "1".
                            tableData.get(getIndex()).get(columnIndex).set("1");
                        } else {
                            // Otherwise we set it to "0".
                            tableData.get(getIndex()).get(columnIndex).set("0");
                        }
                    });

                    setGraphic(box);
                } else {
                    setGraphic(null);
                }

            });

            // If I listen to KEY_RELEASED instead, pressing tab next to a
            // checkbox will make the focus jump past the checkbox cell. This is
            // probably because the default TAB functionality is invoked on key
            // pressed, which switches the focus to the check box cell, and then
            // upon release this EventFilter catches it and switches focus
            // again.
            this.addEventFilter(KeyEvent.KEY_PRESSED, e -> {
                if (e.getCode().equals(KeyCode.UP)) {
                    System.out.println("UP key pressed in checkbox");
                    getTableView().getFocusModel().focus(getIndex() - 1, this.getTableColumn());
                    e.consume();
                } else if (e.getCode().equals(KeyCode.DOWN)) {
                    System.out.println("DOWN key pressed in checkbox");
                    getTableView().getFocusModel().focus(getIndex() + 1, this.getTableColumn());
                    e.consume();
                } else if (e.getCode().equals(KeyCode.TAB)) {
                    System.out.println("Checkbox TAB pressed!");
                    TableColumn<ObservableList<StringProperty>, ?> nextColumn = getNextColumn(!e.isShiftDown());
                    if (nextColumn != null) {
                        getTableView().getFocusModel().focus(getIndex(), nextColumn);
                    }
                    e.consume();

                    // ENTER key will set the check box to selected if it is
                    // unselected and vice versa.
                } else if (e.getCode().equals(KeyCode.ENTER)) {
                    box.setSelected(!box.isSelected());
                    e.consume();
                }
            });

            // Tracking the focused property of the check box for debug
            // purposes.
            box.focusedProperty().addListener((obs, oldValue, newValue) ->

            {
                if (newValue) {
                    System.out.println("Box focused on index " + getIndex());
                } else {
                    System.out.println("Box unfocused on index " + getIndex());
                }
            });

            // Tracking the focused property of the check box for debug
            // purposes.
            this.focusedProperty().addListener((obs, oldValue, newValue) ->

            {
                if (newValue) {
                    System.out.println("Box cell focused on index " + getIndex());
                    box.requestFocus();
                } else {
                    System.out.println("Box cell unfocused on index " + getIndex());
                }
            });
        }

        /**
         * Gets the column to the right or to the left of the current column
         * depending no the value of forward.
         * 
         * @param forward
         *            If true, the column to the right of the current column
         *            will be returned. If false, the column to the left of the
         *            current column will be returned.
         */
        private TableColumn<ObservableList<StringProperty>, ?> getNextColumn(boolean forward) {
            List<TableColumn<ObservableList<StringProperty>, ?>> columns = getTableView().getColumns();
            // If there's less than two columns in the table view we return null
            // since there can be no column to the right or left of this
            // column.
            if (columns.size() < 2) {
                return null;
            }

            // We get the index of the current column and then we get the next
            // or previous index depending on forward.
            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;
                }
            }

            // We return the column on the next index.
            return columns.get(nextIndex);
        }
    }
}

TableView 源代码中进行一些挖掘后,我发现了这个问题。这是 focus(int row, TableColumn<S, ?> column) 方法的源代码:

@Override public void focus(int row, TableColumn<S,?> column) {
            if (row < 0 || row >= getItemCount()) {
                setFocusedCell(EMPTY_CELL);
            } else {
                TablePosition<S,?> oldFocusCell = getFocusedCell();
                TablePosition<S,?> newFocusCell = new TablePosition<>(tableView, row, column);
                setFocusedCell(newFocusCell);

                if (newFocusCell.equals(oldFocusCell)) {
                    // manually update the focus properties to ensure consistency
                    setFocusedIndex(row);
                    setFocusedItem(getModelItem(row));
                }
            }
        }

newFocusCelloldFocusCell 进行比较时会出现问题。当切换到复选框单元格时,该单元格由于某种原因不会被设置为焦点单元格。因此,由 getFocusedCell() 编辑的 focusedCell 属性 return 将成为我们在复选框单元格之前聚焦的单元格。因此,当我们再次尝试聚焦前一个单元格时,newFocusCell.equals(oldFocusCell) 将 return 为真,并且焦点将通过以下操作再次设置为当前聚焦的单元格:

setFocusedIndex(row);     
setFocusedItem(getModelItem(row));`

所以我必须做的是确保当我们想要聚焦时单元格不是 focusedCell 属性 的值。我通过在尝试从复选框单元格切换焦点之前手动将焦点设置为整个 table 来解决这个问题:

table.requestFocus();