JavaFX 怪异的 (Key)EventBehavior

JavaFX weird (Key)EventBehavior

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

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

我将尝试自己解释所有行为,尽管您自己看更容易。基本上,使用 TableView#edit() 方法时事件会被搞乱。

1:

如果您使用 contextMenu 添加新项目,'escape' 和 'Enter' 键(可能还有方向键,虽然我现在不使用它们)的 keyEvents 是在它们触发单元格上的事件(例如 textField 和单元格 KeyEvents!)之前消耗掉了。尽管它在父节点上触发了 keyEvent。 (在本例中为 AnchorPane)。

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

2:

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

3:

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

4:

当您编辑一个项目,然后添加一个项目(任何一种方式都会导致这个问题)时,它会将 editingCellProperty 更新到当前单元格,但当停止编辑时,它会以某种方式恢复到最后一个单元格?!?那是发生有趣事情的部分,我真的无法解释。

请注意,startEdit() 和 cancelEdit() 方法在奇怪的时刻被调用,并且在错误的单元格上被调用!

现在我不明白这个逻辑。如果这是有意为之的行为,将不胜感激!

这是例子:

package testpacket;

import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
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.AnchorPane;
import javafx.stage.Stage;



public class EditStateTest extends Application
{
    private static ObservableList<SimpleStringProperty> exampleList = FXCollections.observableArrayList();
    //Placeholder for the button
    private static SimpleStringProperty PlaceHolder = new SimpleStringProperty();

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

    @Override
    public void start(Stage primaryStage) throws Exception
    {
        // basic ui setup
        AnchorPane parent = new AnchorPane();
        Scene scene = new Scene(parent);
        primaryStage.setScene(scene);

        //fill backinglist with data
        for(int i = 0 ; i < 20; i++)
            exampleList.add(new SimpleStringProperty("Hello Test"));
        exampleList.add(PlaceHolder);

        //create a basic tableView
        TableView<SimpleStringProperty> listView = new TableView<SimpleStringProperty>();
        listView.setEditable(true);

        TableColumn<SimpleStringProperty, String> column = new TableColumn<SimpleStringProperty, String>();
        column.setCellFactory(E -> new TableCellTest<SimpleStringProperty, String>());
        column.setCellValueFactory(E -> E.getValue());
        column.setEditable(true);

        // set listViews' backing list
        listView.setItems(exampleList);


        listView.getColumns().clear();
        listView.getColumns().add(column);
        parent.getChildren().add(listView);

        parent.setOnKeyReleased(E -> System.out.println("Parent - KeyEvent"));


        primaryStage.show();
    }

    // basic editable cell example
    public static class TableCellTest<S, T> extends TableCell<S, T>
    {
        // The editing textField.
        protected static Button addButton = new Button("Add");
        protected TextField textField = new TextField();;
        protected ContextMenu menu;


        public TableCellTest()
        {
            this.setOnContextMenuRequested(E -> {
                if(this.getTableView().editingCellProperty().get() == null)
                    this.menu.show(this, E.getScreenX(), E.getScreenY());
            });
            this.menu = new ContextMenu();

            MenuItem createNew = new MenuItem("create New");
            createNew.setOnAction(E -> {
                System.out.println("Cell ContextMenu " + this.getIndex() + " - createNew: onAction");
                this.onNewItem(this.getIndex() + 1);
            });
            MenuItem edit = new MenuItem("edit");
            edit.setOnAction(E -> {
                System.out.println("Cell ContextMenu " + this.getIndex() + " - edit: onAction");
                this.startEdit();
            });

            this.menu.getItems().setAll(createNew, edit);

            addButton.addEventHandler(ActionEvent.ACTION, E -> {
                if(this.getIndex() == EditStateTest.exampleList.size() - 1)
                {
                    System.out.println("Cell " + this.getIndex() + " - Button: onAction");
                    this.onNewItem(this.getIndex());
                }
            });
            addButton.prefWidthProperty().bind(this.widthProperty());

            this.setOnKeyReleased(E -> System.out.println("Cell " + this.getIndex() + " - KeyEvent"));
        }

        public void onNewItem(int index)
        {
            EditStateTest.exampleList.add(index, new SimpleStringProperty("New Item"));
            this.getTableView().edit(index, this.getTableColumn());
            textField.requestFocus();
        }

        @Override
        public void startEdit()
        {
            if (!isEditable()
                    || (this.getTableView() != null && !this.getTableView().isEditable())
                    || (this.getTableColumn() != null && !this.getTableColumn().isEditable()))
                return;

            System.out.println("Cell " + this.getIndex() + " - StartEdit");
            super.startEdit();

            this.createTextField();

            textField.setText((String)this.getItem());
            this.setGraphic(textField);
            textField.selectAll();
            this.setText(null);
        }

        @Override
        public void cancelEdit()
        {
            if (!this.isEditing())
                return;

            System.out.println("Cell " + this.getIndex() + " - CancelEdit");
            super.cancelEdit();

            this.setText((String)this.getItem());
            this.setGraphic(null);
        }

        @Override
        protected void updateItem(T item, boolean empty)
        {
            System.out.println("Cell " + this.getIndex() + " - UpdateItem");
            super.updateItem(item, empty);

            if(empty || item == null)
            {
                if(this.getIndex() == EditStateTest.exampleList.size() - 1)
                {
                    this.setText("");
                    this.setGraphic(addButton);
                }
                else
                {
                    this.setText(null);
                    this.setGraphic(null);
                }
            }
            else
            {
                // These checks are needed to make sure this cell is the specific cell that is in editing mode.
                // Technically this#isEditing() can be left out, as it is not accurate enough at this point.
                if(this.getTableView().getEditingCell() != null 
                        && this.getTableView().getEditingCell().getRow() == this.getIndex())
                {
                    //change to TextField
                    this.setText(null);
                    this.setGraphic(textField);
                }
                else
                {
                    //change to actual value
                    this.setText((String)this.getItem());
                    this.setGraphic(null);
                }
            }
        }

        @SuppressWarnings("unchecked")
        public void createTextField()
        {
            textField.setOnKeyReleased(E -> {
                System.out.println("TextField " + this.getIndex() + " - KeyEvent");
                System.out.println(this.getTableView().getEditingCell());
//              if(this.getTableView().getEditingCell().getRow() == this.getIndex())
                    if(E.getCode() == KeyCode.ENTER)
                    {
                        this.setItem((T) textField.getText());
                        this.commitEdit(this.getItem());
                    }
                    else if(E.getCode() == KeyCode.ESCAPE)
                        this.cancelEdit();
            });
        }
    }
}

我希望有人可以帮助我进一步解决这个问题。如果您对此有 suggestions/solutions 或解决方法,请告诉我! 感谢您的宝贵时间!

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

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

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

你基本保证的是:

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

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

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

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

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

要编写这样的功能,您应该广泛阅读 cell mechanism and for TableView, TableColumn, and TableCell. At some point you might find you need to dig into the source code 的文档,了解所提供的单元实现的工作原理。

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

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

我今天的重要学习项目(从 自由总结和稍微扩展):

仅当所有单元格都处于 stable 状态并且目标单元格可见时,

view.edit(...) 才能安全调用。大多数时候我们可以通过调用view.layout()

强制进入stable状态

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

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

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

  • table 之外还有一些按钮可供试验:addAndEdit 和 scrollAndEdit。后者是为了证明 "instable cell state" 可以通过不同于修改项目的路径到达。

目前,我倾向于将 TableView 子类化并覆盖其 edit(...) 以强制重新布局。类似于:

public static class TTableView<S> extends TableView<S> {

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

}

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

示例:

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", "jacob.smith@example.com"),
                    new Person("Isabella", "Johnson", "isabella.johnson@example.com"),
                    new Person("Ethan", "Williams", "ethan.williams@example.com"),
                    new Person("Emma", "Jones", "emma.jones@example.com"),
                    new Person("Michael", "Brown", "michael.brown@example.com")
                    , 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<S> 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());
}

更新

不应该太惊讶 - was discussed half a year ago (and produced a bug report)