关于事件:JavaFX 怪异(Key)EventBehavior

JavaFX weird (Key)EventBehavior

所以我一直在用 javaFX 进行一些试验,我遇到了一些可能与 TableView#edit() 方法有关的相当奇怪的行为。

我将再次在这篇文章的底部发布一个工作示例,这样你就可以看到哪个单元格上到底发生了什么(包括调试!)。

我会尝试自己解释所有的行为,尽管这样更容易让你自己看到。基本上,使用 TableView#edit() 方法时事件会搞砸。

1:

如果您使用 contextMenu 添加新项目,则"escape"和"Enter"键(可能还有箭头键,尽管我现在不使用它们)的 keyEvents 在触发事件之前被消耗在单元格上(例如 textField 和单元格 KeyEvents!)虽然它正在触发父节点上的 keyEvent。 (在这种情况下为 AnchorPane)。

现在我知道了这些键被 contextMenu 默认行为捕获和使用的事实。虽然它不应该发生,因为在添加新项目后 contextMenu 已经隐藏。此外,textField 应该接收事件,尤其是当它集中时!

2:

当您使用 TableView 底部的按钮添加新项目时,会在 Parent 节点(AnchorPane)和 Cell 上触发 keyEvents。尽管 textField (即使聚焦时)根本没有收到任何 keyEvents。我无法解释为什么 TextField 即使在输入时也不会收到任何事件,所以我认为这肯定是一个错误?

3:

通过双击编辑单元格时,它会正确更新TableView的editingCellProperty(我检查了几次)。虽然通过 contextMenu 项目开始编辑(仅调用 startEdit() 用于测试目的)它不会正确更新编辑状态!有趣的是,它允许 keyEvents 像往常一样继续,不像情况 1


这是 Josh Bloch 的"继承打破封装"口头禅的典型代表。我的意思是,当您创建现有类的子类(在本例中为 TableCell)时,您需要了解很多关于该类的实现,以使子类与超类很好地配合。您在代码中对 TableView 与其单元格之间的交互做出了很多假设,这些假设是不正确的,并且(以及一些错误和某些控件中事件处理的一般奇怪实现)是您的代码破坏的原因。

我认为我不能解决每一个问题,但我可以在这里给出一些一般性的指导,并提供我认为可以实现您想要实现的目标的工作代码。

首先,细胞被重复使用。这是一件好事,因为它使表在有大量数据时执行得非常高效,但它使它变得复杂。基本思想是单元格基本上只为表格中的可见项创建。随着用户滚动,或随着表格内容的变化,不再需要的单元格被重新用于不同的可见项目。这大大节省了内存消耗和 CPU 时间(如果使用得当)。为了能够改进实现,JavaFX 团队故意不指定这是如何工作的,以及单元格可能被重用的方式和时间。因此,您必须小心假设单元格的项目或索引字段的连续性(反之,将哪个单元格分配给给定项目或索引),特别是如果您更改表格的结构。

你基本保证的是:

  • 每当单元格被重新用于不同的项目时,都会在渲染单元格之前调用 updateItem() 方法。
  • 每当单元格的索引发生变化时(可能是因为在列表中插入了一个项目,或者可能是因为单元格被重用,或两者兼而有之),在渲染单元格之前调用 updateIndex() 方法。

但是,请注意,如果两者都更改,则无法保证调用它们的顺序。因此,如果您的单元格渲染同时依赖于项目和索引(这里就是这种情况:您在 updateItem(...) 方法中检查项目和索引),您需要确保单元格在任一情况下更新这些属性的变化。实现这一点的最佳方法 (imo) 是创建一个私有方法来执行更新,并从 updateItem() 和 updateIndex() 委托给它。这样,当调用其中的第二个时,您的更新方法将以一致的状态调用。

如果您更改表格的结构,例如添加新行,则需要重新排列单元格,其中一些单元格可能会重复用于不同的项目(和索引)。但是,这种重新排列仅在表格布局时发生,默认情况下直到下一帧渲染才会发生。 (从性能的angular来看,这是有道理的:假设您在循环中对表格进行了 1000 次不同的更改;您不希望在每次更改时重新计算单元格,您只希望在下次将表格呈现为时重新计算它们屏幕。)这意味着,如果您向表中添加行,则不能依赖任何单元格的索引或项目是否正确。这就是为什么在添加新行后立即调用 table.edit(...) 是如此不可预测的原因。这里的技巧是在添加行后通过调用 TableView.layout() 来强制表格的布局。

请注意,当表格单元格获得焦点时按"Enter"将导致该单元格进入编辑模式。如果您使用键释放事件处理程序处理单元格中文本字段的提交,这些处理程序将以不可预知的方式进行交互。我认为这就是为什么您会看到奇怪的键处理效果(另请注意,文本字段会消耗它们在内部处理的键事件)。解决方法是在文本字段上使用 onAction 处理程序(无论如何,这可以说更具语义)。

不要让按钮静态(我不知道你为什么要这样做)。"静态"意味着按钮是整个类的属性,而不是该类的实例的属性。所以在这种情况下,所有单元格共享一个对单个按钮的引用。由于未指定单元重用机制,因此您不知道只有一个单元将按钮设置为其图形。这可能会导致灾难。例如,如果您将带有按钮的单元格滚动到视图之外,然后再返回到视图中,则无法保证当最后一个项目返回到视图中时,将使用相同的单元格来显示它。可能(我不知道实现方式)先前显示最后一个项目的单元格未被使用(可能是虚拟流容器的一部分,但被裁剪掉)并且没有更新。在这种情况下,该按钮将在场景图中出现两次,这将引发异常或导致不可预知的行为。基本上没有正当理由将场景图节点设为静态,这是一个特别糟糕的主意。

要编写这样的功能,您应该广泛阅读有关单元机制和 TableViewTableColumnTableCell 的文档。在某些时候,您可能会发现您需要深入研究源代码以了解所提供的单元实现是如何工作的。

这是(我认为,我不确定我是否已经完全测试过)我认为您正在寻找的工作版本。我对结构做了一些细微的改动(不需要 StringPropertys 作为数据类型,只要你没有相同的重复项,String 就可以正常工作),添加了一个 onEditCommit 处理程序等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
import javafx.application.Application;
import javafx.beans.value.ObservableValueBase;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
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.layout.BorderPane;
import javafx.stage.Stage;

public class TableViewWithAddAtEnd extends Application {

    @Override
    public void start(Stage primaryStage) {
        TableView<String> table = new TableView<>();
        table.setEditable(true);

        TableColumn<String, String> column = new TableColumn<>("Data");
        column.setPrefWidth(150);
        table.getColumns().add(column);

        // use trivial wrapper for string data:
        column.setCellValueFactory(cellData -> new ObservableValueBase<String>() {
            @Override
            public String getValue() {
                return cellData.getValue();
            }
        });

        column.setCellFactory(col -> new EditingCellWithMenuEtc());

        column.setOnEditCommit(e ->
            table.getItems().set(e.getTablePosition().getRow(), e.getNewValue()));

        for (int i = 1 ; i <= 20; i++) {
            table.getItems().add("Item"+i);
        }
        // blank for"add" button:
        table.getItems().add("");

        BorderPane root = new BorderPane(table);
        primaryStage.setScene(new Scene(root, 600, 600));
        primaryStage.show();

    }

    public static class EditingCellWithMenuEtc extends TableCell<String, String> {
        private TextField textField ;
        private Button button ;
        private ContextMenu contextMenu ;

        // The update relies on knowing both the item and the index
        // Since we don't know (or at least shouldn't rely on) the order
        // in which the item and index are updated, we just delegate
        // implementations of both updateItem and updateIndex to a general
        // method. This way doUpdate() is always called last with consistent
        // state, so we are guaranteed to be in a consistent state when the
        // cell is rendered, even if we are temporarily in an inconsistent
        // state between the calls to updateItem and updateIndex.

        @Override
        protected void updateItem(String item, boolean empty) {
            super.updateItem(item, empty);
            doUpdate(item, getIndex(), empty);
        }

        @Override
        public void updateIndex(int index) {
            super.updateIndex(index);
            doUpdate(getItem(), index, isEmpty());
        }

        // update the cell. This updates the text, graphic, context menu
        // (empty cells and the special button cell don't have context menus)
        // and editable state (empty cells and the special button cell can't
        // be edited)
        private void doUpdate(String item, int index, boolean empty) {
            if (empty) {
                setText(null);
                setGraphic(null);
                setContextMenu(null);
                setEditable(false);
            } else {
                if (index == getTableView().getItems().size() - 1) {
                    setText(null);
                    setGraphic(getButton());
                    setContextMenu(null);
                    setEditable(false);
                } else if (isEditing()) {
                    setText(null);
                    getTextField().setText(item);
                    setGraphic(getTextField());
                    getTextField().requestFocus();
                    setContextMenu(null);
                    setEditable(true);
                } else {
                    setText(item);
                    setGraphic(null);
                    setContextMenu(getMenu());
                    setEditable(true);
                }
            }
        }

        @Override
        public void startEdit() {
            if (! isEditable()
                    || ! getTableColumn().isEditable()
                    || ! getTableView().isEditable()) {
                return ;
            }
            super.startEdit();
            getTextField().setText(getItem());
            setText(null);
            setGraphic(getTextField());
            setContextMenu(null);
            textField.selectAll();
            textField.requestFocus();
        }

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

        @Override
        public void commitEdit(String newValue) {
            // note this fires onEditCommit handler on column:
            super.commitEdit(newValue);
            setText(getItem());
            setGraphic(null);
            setContextMenu(getMenu());
        }

        private void addNewItem(int index) {
            getTableView().getItems().add(index,"New Item");
            // force recomputation of cells:
            getTableView().layout();
            // start edit:
            getTableView().edit(index, getTableColumn());
        }

        private ContextMenu getMenu() {
            if (contextMenu == null) {
                createContextMenu();
            }
            return contextMenu ;
        }

        private void createContextMenu() {
            MenuItem addNew = new MenuItem("Add new");
            addNew.setOnAction(e -> addNewItem(getIndex() + 1));
            MenuItem edit = new MenuItem("Edit");
            // note we call TableView.edit(), not this.startEdit() to ensure
            // table's editing state is kept consistent:
            edit.setOnAction(e -> getTableView().edit(getIndex(), getTableColumn()));
            contextMenu = new ContextMenu(addNew, edit);
        }

        private Button getButton() {
            if (button == null) {
                createButton();
            }
            return button ;
        }

        private void createButton() {
            button = new Button("Add");
            button.prefWidthProperty().bind(widthProperty());
            button.setOnAction(e -> addNewItem(getTableView().getItems().size() - 1));
        }

        private TextField getTextField() {
            if (textField == null) {
                createTextField();
            }
            return textField ;
        }

        private void createTextField() {
            textField = new TextField();
            // use setOnAction for enter, to avoid conflict with enter on cell:
            textField.setOnAction(e -> commitEdit(textField.getText()));
            // use key released for escape: note text fields do note consume
            // key releases they don't handle:
            textField.setOnKeyReleased(e -> {
                if (e.getCode() == KeyCode.ESCAPE) {
                    cancelEdit();
                }
            });
        }
    }

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


我今天的重要学习项目(根据 James 的回答自由总结并略微扩展):

view.edit(...) 只有在所有单元格都处于稳定状态并且目标单元格可见时才能安全调用。大多数时候我们可以通过调用 view.layout()

来强制稳定状态

下面是另一个可以玩的例子:

  • 正如我的一条评论中已经提到的,它与 James 的不同之处在于,在监听器中开始编辑项目:可能并不总是最好的地方,具有单一位置的优势(至少就列表而言)涉及突变)用于布局调用。一个缺点是我们需要确定 viewSkin 的项目侦听器在我们之前被调用。为了保证这一点,只要皮肤发生变化,我们自己的监听器就会重新/注册。

  • 作为重用练习,我扩展了 TextFieldTableCell 以额外处理按钮/菜单并根据行项目更新单元格的可编辑性。

  • 表格外还有一些按钮可以试验:addAndEdit 和 scrollAndEdit。后者是为了证明"不稳定的细胞状态"可以通过不同于修改项目的路径来达到。

目前,我倾向于继承 TableView 并覆盖它的 edit(...) 以强制重新布局。类似于:

1
2
3
4
5
6
7
8
9
10
11
12
public static class TTableView extends TableView {

    /**
     * Overridden to force a layout before calling super.
     */
    @Override
    public void edit(int row, TableColumn<S, ?> column) {
        layout();
        super.edit(row, column);
    }

}

做,减轻客户端代码的负担。不过,剩下的就是确保目标单元格滚动到可见区域。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
public class TablePersonAddRowAndEdit extends Application {

    private PersonStandIn standIn = new PersonStandIn();
    private final ObservableList<Person> data =
            // Person from Tutorial - with Properties exposed!
            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]")
                    , standIn
                    );


    private Parent getContent() {

        TableView<Person> table = new TableView<>();
        table.setItems(data);
        table.setEditable(true);

        TableColumn<Person, String> firstName = new TableColumn<>("First Name");
        firstName.setCellValueFactory(new PropertyValueFactory<>("firstName"));

        firstName.setCellFactory(v -> new MyTextFieldCell<>());
        ListChangeListener l = c -> {
            while (c.next()) {
                // true added only
                if (c.wasAdded() && ! c.wasRemoved()) {
                    // force the re-layout before starting the edit
                    table.layout();
                    table.edit(c.getFrom(), firstName);
                    return;
                }
            };
        };
        // install the listener to the items after the skin has registered
        // its own
        ChangeListener skinListener = (src, ov, nv) -> {
            table.getItems().removeListener(l);
            table.getItems().addListener(l);
        };
        table.skinProperty().addListener(skinListener);

        table.getColumns().addAll(firstName);

        Button add = new Button("AddAndEdit");
        add.setOnAction(e -> {
            int standInIndex = table.getItems().indexOf(standIn);
            int index = standInIndex < 0 ? table.getItems().size() : standInIndex;
            index =1;
            Person person = createNewItem("edit", index);
            table.getItems().add(index, person);

        });
        Button edit = new Button("Edit");
        edit.setOnAction(e -> {
            int index = 1;//table.getItems().size() -2;
            table.scrollTo(index);
            table.requestFocus();
            table.edit(index, firstName);
        });
        HBox buttons = new HBox(10, add, edit);
        BorderPane content = new BorderPane(table);
        content.setBottom(buttons);
        return content;
    }

    /**
     * A cell that can handle not-editable items. Has to update its
     * editability based on the rowItem. Must be done in updateItem
     * (tried a listener to the tableRow's item, wasn't good enough - doesn't
     * get notified reliably)
     *
     */
    public static class MyTextFieldCell extends TextFieldTableCell<S, String> {

        private Button button;

        public MyTextFieldCell() {
            super(new DefaultStringConverter());
            ContextMenu menu = new ContextMenu();
            menu.getItems().add(createMenuItem());
            setContextMenu(menu);
        }

        private boolean isStandIn() {
            return getTableRow() != null && getTableRow().getItem() instanceof StandIn;
        }

        /**
         * Update cell's editable based on the rowItem.
         */
        private void doUpdateEditable() {
            if (isEmpty() || isStandIn()) {
                setEditable(false);
            } else {
                setEditable(true);
            }
        }

        @Override
        public void updateItem(String item, boolean empty) {
            super.updateItem(item, empty);
            doUpdateEditable();
            if (isStandIn()) {
                if (isEditing()) {
                    LOG.info("shouldn't be editing - has StandIn");
                }
                if (button == null) {
                    button = createButton();
                }
                setText(null);
                setGraphic(button);
            }
        }

        private Button createButton() {
            Button b = new Button("Add");
            b.setOnAction(e -> {
                int index = getTableView().getItems().size() -1;
                getTableView().getItems().add(index, createNewItem("button", index));
            });
            return b;
        }

        private MenuItem createMenuItem() {
            MenuItem item = new MenuItem("Add");
            item.setOnAction(e -> {
                if (isStandIn()) return;
                int index = getIndex();
                getTableView().getItems().add(index, createNewItem("menu", index));
            });
            return item;
        }


        private S createNewItem(String text, int index) {
            return (S) new Person(text + index, text + index, text);
        }

    }

    private Person createNewItem(String text, int index) {
        return new Person(text + index, text + index, text);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        primaryStage.setScene(new Scene(getContent()));
        primaryStage.setTitle(FXUtils.version());
        primaryStage.show();
    }

    /**
     * Marker-Interface to denote a class as not mutable.
     */
    public static interface StandIn {
    }

    public static class PersonStandIn extends Person implements StandIn{

         public PersonStandIn() {
            super("standIn","","");
        }

    }

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

    @SuppressWarnings("unused")
    private static final Logger LOG = Logger
            .getLogger(TablePersonAddRowAndEdit.class.getName());
}

更新

不应该太惊讶 - 半年前讨论了一个相关问题(并产生了错误报告)