JavaFX weird (Key)EventBehavior

前端 未结 2 1575
夕颜
夕颜 2021-01-23 11:51

So I have been experimenting with it a litle bit with javaFX and I came across some rather weird behavior which might be linked to the TableView#edit() method.

2条回答
  •  有刺的猬
    2021-01-23 11:53

    This is kind of the poster child for Josh Bloch's "Inheritance breaks Encapsulation" mantra. What I mean by that is that when you create a subclass of an existing class (TableCell in this case), you need to know a lot about the implementation of that class in order to make the subclass play nicely with the superclass. You make a lot of assumptions in your code about the interaction between the TableView and its cells that are not true, and that (along with some bugs and general weird implementations of event handling in some controls) is why your code is breaking.

    I don't think I can address every single issue, but I can give some general pointers here and provide what I think is working code that achieves what you are trying to achieve.

    First, cells are reused. This is a good thing, because it makes the table perform very efficiently when there is a large amount of data, but it makes it complicated. The basic idea is that cells are essentially only created for the visible items in the table. As the user scrolls around, or as the table content changes, cells that are no longer needed are reused for different items that become visible. This massively saves on memory consumption and CPU time (if used properly). In order to be able to improve the implementation, the JavaFX team deliberately don't specify how this works, and how and when cells are likely to be reused. So you have to be careful about making assumptions about the continuity of the item or index fields of a cell (and conversely, which cell is assigned to a given item or index), particularly if you change the structure of the table.

    What you are basically guaranteed is:

    • Any time the cell is reused for a different item, the updateItem() method is invoked before the cell is rendered.
    • Any time the index of the cell changes (which may be because an item is inserted in the list, or may be because the cell is reused, or both), the updateIndex() method is invoked before the cell is rendered.

    However, note that in the case where both change, there is no guarantee of the order in which these are invoked. So, if your cell rendering depends on both the item and the index (which is the case here: you check both the item and the index in your updateItem(...) method), you need to ensure the cell is updated when either of those properties change. The best way (imo) to achieve this is to create a private method to perform the update, and to delegate to it from both updateItem() and updateIndex(). This way, when the second of those is invoked, your update method is invoked with consistent state.

    If you change the structure of the table, say by adding a new row, the cells will need to be rearranged, and some of them are likely to be reused for different items (and indexes). However, this rearrangement only happens when the table is laid out, which by default will not happen until the next frame rendering. (This makes sense from a performance perspective: imagine you make 1000 different changes to a table in a loop; you don't want the cells to be recalculated on every change, you just want them recalculated once the next time the table is rendered to the screen.) This means, if you add rows to the table, you cannot rely on the index or item of any cell being correct. This is why your call to table.edit(...) immediately after adding a new row is so unpredictable. The trick here is to force a layout of the table by calling TableView.layout() after adding the row.

    Note that pressing "Enter" when a table cell is focused will cause that cell to go into editing mode. If you handle commits on the text field in a cell with a key released event handler, these handlers will interact in an unpredictable way. I think this is why you see the strange key handling effects you see (also note that text fields consume the key events they process internally). The workaround for that is to use an onAction handler on the text field (which is arguably more semantic anyway).

    Don't make the button static (I have no idea why you would want to do this anyway). "Static" means that the button is a property of the class as a whole, not of the instances of that class. So in this case, all the cells share a reference to a single button. Since the cell reuse mechanism is unspecified, you don't know that only one cell will have the button set as its graphic. This can cause disaster. For example, if you scroll the cell with the button out of view and then back into view, there is no guarantee the same cell will be used to display that last item when it comes back into view. It is possible (I don't know the implementation) that the cell that previously displayed the last item is sitting unused (perhaps part of the virtual flow container, but clipped out of view) and is not updated. In that case, the button would then appear twice in the scene graph, which would either throw an exception or cause unpredictable behavior. There's basically no valid reason to ever make a scene graph node static, and here it's a particularly bad idea.

    To code functionality like this, you should read extensively the documentation for the cell mechanism and for TableView, TableColumn, and TableCell. At some point you might find you need to dig into the source code to see how the provided cell implementations work.

    Here's (I think, I'm not sure I've fully tested) a working version of what I think you were looking for. I made some slight changes to the structure (no need for StringPropertys as the data type, String works just fine as long as you have no identical duplicates), added an onEditCommit handler, etc.

    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 table = new TableView<>();
            table.setEditable(true);
    
            TableColumn column = new TableColumn<>("Data");
            column.setPrefWidth(150);
            table.getColumns().add(column);
    
            // use trivial wrapper for string data:
            column.setCellValueFactory(cellData -> new ObservableValueBase() {
                @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 {
            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);
        }
    }
    

提交回复
热议问题