javajavafx

Explain the code in Cell that cancels edit on focus loss in JavaFX


Please consider the following code from javafx.scene.control.Cell (link):

super.focusedProperty().addListener(new InvalidationListener() {
    @Override public void invalidated(Observable property) {
        pseudoClassStateChanged(PSEUDO_CLASS_FOCUSED, isFocused()); // TODO is this necessary??

        // The user has shifted focus, so we should cancel the editing on this cell
        if (!isFocused() && isEditing()) {
            cancelEdit();
        }
    }
});

This code (as I understand) cancels edit when the cell becomes unfocused. However, if the cell is editable we can assume it will have another control inside, for example TextField. And according to the code above when this TextField becomes focused then cancelEdit should happen.

But as we know we can enter a new value in TextField (so, the TextField has the focus) and cancelEdit is not called. Could anyone explain it? What is the magic here?


Solution

  • This issue looks interesting. So I tried to give a check at the flow of the operations and came to the below conclusions. I am writing in purpose of general explanation. Some might already know some of these concepts.

    The listener code you mentioned in javafx.scene.control.Cell, is in the super class of all cell implementations. So this is the general logic that applies to all cell implementations. Now the tricky part is: this gets triggered only when the focus is changed/updated. If a cell never updated its focused value, then this part of logic never gets executed.

    To check this, I set ids to TableRow & TableCell (in the factories) and I included focusedProperty listeners to TableCell, TableRow and also a listener to focusOwnerProperty of Scene.

    enter image description here

    Output log:

    TableRow: 'Row1' changed focused to : true
    TableRow: 'Row1' changed focused to : false
    TableRow: 'Row3' changed focused to : true
    Scene focus owner :: CheckBox@147ec98b[styleClass=check-box]'Test Focus'
    Scene focus owner :: TableView@4efd58d5[styleClass=table-view]
    TableRow: 'Row3' changed focused to : false
    TableRow: 'Row5' changed focused to : true
    TableRow: 'Row3' changed focused to : true
    TableRow: 'Row5' changed focused to : false
    TableRow: 'Row3' changed focused to : false
    TableRow: 'Row5' changed focused to : true
    TableRow: 'Row3' changed focused to : true
    TableRow: 'Row5' changed focused to : false
    Scene focus owner :: TextField@2093a517[styleClass=text-input text-field]
    TableRow: 'Row3' changed focused to : false
    TableRow: 'Row5' changed focused to : true
    Scene focus owner :: TableView@4efd58d5[styleClass=table-view]
    

    In the above gif/output, you can see that the TableRow focused gets updated and Scene's focusOwner gets updated, but the TableCell focus never gets updated or printed.

    This is because, it is never been updated to true. To check this you need to refer to the updateFocus() method of TableCell or ListCell class.

    TableCell:

    private void updateFocus() {
        final boolean isFocused = isFocused();
        if (! isInCellSelectionMode()) {
            if (isFocused) {
                setFocused(false);
            }
            return;
        }
    
        final TableView<S> tableView = getTableView();
        final TableRow<S> tableRow = getTableRow();
        final int index = getIndex();
        if (index == -1 || tableView == null || tableRow == null) return;
    
        final TableViewFocusModel<S> fm = tableView.getFocusModel();
        if (fm == null) {
            setFocused(false);
            return;
        }
    
        setFocused(fm.isFocused(index, getTableColumn()));
    }
    

    ListCell:

    private void updateFocus() {
        int index = getIndex();
        ListView<T> listView = getListView();
        if (index == -1 || listView == null) return;
    
        FocusModel<T> fm = listView.getFocusModel();
        if (fm == null) {
            setFocused(false);
            return;
        }
    
        setFocused(fm.isFocused(index));
    }
    

    From the above code, the only case a TableCell or ListCell gets focused updated to true is only if it has focusModel set and the focusedCell of focusModel is set to this cell. In all other cases the focused will always be false and will never get a chance to trigger the listener in Cell class.

    Ok, now you may get the doubt that what if somehow it has focusModel and focusedCell set to the current clicked Cell. This should set the focus to true and when the TextField gets focused this should call cancelEdit(). Right?.

    If you check the code of indexChanged() method in TableCell or ListCell:

    TableCell:

    @Override void indexChanged(int oldIndex, int newIndex) {
        super.indexChanged(oldIndex, newIndex);
    
        // Ideally we would just use the following two lines of code, rather
        // than the updateItem() call beneath, but if we do this we end up with
        // JDK-8126803 where all the columns are collapsed.
        // itemDirty = true;
        // requestLayout();
        updateItem(oldIndex);
        updateSelection();
        updateFocus();
    
        // Fix for JDK-8150525
        updateEditing();
    }
    

    ListCell:

    @Override  void indexChanged(int oldIndex, int newIndex) {
        super.indexChanged(oldIndex, newIndex);
    
        if (isEditing() && newIndex == oldIndex) {
            // no-op
            // Fix for JDK-8123482 - if we (needlessly) update the index whilst the
            // cell is being edited it will no longer be in an editing state.
            // This means that in certain (common) circumstances that it will
            // appear that a cell is uneditable as, despite being clicked, it
            // will not change to the editing state as a layout of VirtualFlow
            // is immediately invoked, which forces all cells to be updated.
        } else {
            updateItem(oldIndex);
            updateSelection();
            updateFocus();
            updateEditing();
        }
    }
    

    The updateFocus() method is called before updateEditing(). So if the updateFocus has turned on/off the focus of cell and triggered the listener(to cancel the edit), the next execution is to call the updateEditing(). So that is the reason you will never see impact of cancelEdit when focus change happens from Cell to TextField.

    Another thing to note in the provided gif is that, the Scene's focusOwner never get updated to TableRow or TableCell. This is because a Scene's focusOwner gets updated only when the Node's requestFocus() method is called. For TableRow & TableCell ( in fact a Cell) implementation never have a call to requestFocus() for that cell.

    It is now clear that multiple nodes can have focused set to true. In this case a TableRow and TextField will have its focused to true at the same time. I think the Node.focusedProperty API is more specific about focusOwner of Scene.

    I hope this provides some clarification. I am open for any further corrections or suggestions regarding these observations. :)