Skip to content

Instantly share code, notes, and snippets.

@mstr2
Last active November 10, 2024 23:38
Show Gist options
  • Save mstr2/44d94f0bd5b5c030e26a47103063aa29 to your computer and use it in GitHub Desktop.
Save mstr2/44d94f0bd5b5c030e26a47103063aa29 to your computer and use it in GitHub Desktop.

Problem

Controls in JavaFX have an external representation (the Control), and an internal representation (the Skin). The internal representation is a "gray box" to the user, it is usually hidden from the developer-facing API. Let's consider the Spinner control: from the user perspective, it is a monolithic control that consists of a text field and two buttons to change the spinner value.

Its internal structure is as follows:

  • text-field — TextField
  • increment-arrow-button — StackPane
    • increment-arrow — Region
  • decrement-arrow-button — StackPane
    • decrement-arrow — Region

Making this conglomerate of controls work as a monolith is no easy task, and Spinner goes to great lenghts and uses many ugly hacks to achieve its goal:

  1. Clicking on or tabbing into the internal editable TextField should focus the Spinner control.
    Since the internal structure is hidden from users, it is obvious that clicking on or tabbing into the internal TextField should focus the external Spinner control. This is achieved with two separate hacks:

    1. Subclassing TextField and overriding the requestFocus() methods to create the illusion of focus, even though the parent node is focused:
      public class FakeFocusTextField extends TextField {
          @Override public void requestFocus() {
              if (getParent() != null) {
                  getParent().requestFocus();
              }
          }
      
          public void setFakeFocus(boolean b) {
              setFocused(b);
          }
      }
      This requires SpinnerSkin to set the "fake focus" flag when Spinner.focused changes:
        // move fake focus in to the textfield if the spinner is editable
        lh.addChangeListener(control.focusedProperty(), (op) -> {
            ((FakeFocusTextField)textField).setFakeFocus(control.isFocused());
        });
    2. By carefully abusing the Spinner.getProperties() map as a side-channel for communication:
      • In SpinnerSkin, an event handler listens to the TextField.focused property and adds a magic key to the properties map:
        lh.addChangeListener(textField.focusedProperty(), (op) -> {
            boolean hasFocus = textField.isFocused();
            control.getProperties().put("FOCUSED", hasFocus);
        });
      • Spinner observes changes to its properties map, and reacts to the arrival of the magic key by immediately removing it again, and setting its own Spinner.focused property in the process:
        getProperties().addListener((MapChangeListener<Object, Object>) change -> {
            if (change.wasAdded()) {
                if (change.getKey() == "FOCUSED") {
                    setFocused((Boolean)change.getValueAdded());
                    getProperties().remove("FOCUSED");
                }
            }
        });
  2. Events sent to the Spinner should also reach the internal TextField.
    Since the Spinner is the focus owner, key events will be sent to this control. However, an editable spinner requires its internal TextField to receive key events, as otherwise the text field will simply not work. This is achieved by SpinnerSkin adding an event filter to both Spinner and the internal TextField, and then duplicating and re-firing key events under specific conditions to target either the external Spinner control or the internal TextField. This leads to a highly entangled implementation of multiple event listeners, a lack of separation between SpinnerSkin and its behavior, and most importantly to externally observable defects.

    Consider the follwing sample program, which only consists of a StackPane that contains a Spinner:

    @Override
    public void start(Stage stage) {
        var spinner = new Spinner<>(0, 10, 5);
        spinner.setEditable(true);
        spinner.addEventFilter(KeyEvent.KEY_PRESSED, e -> System.out.print(("filter: " + e).indent(4)));
        spinner.addEventHandler(KeyEvent.KEY_PRESSED, e -> System.out.print(("handler: " + e).indent(4)));
    
        var textField = spinner.getEditor();
        textField.addEventFilter(KeyEvent.KEY_PRESSED, e -> System.out.print(("filter: " + e).indent(8)));
        textField.addEventHandler(KeyEvent.KEY_PRESSED, e -> System.out.print(("handler: " + e).indent(8)));
    
        var root = new StackPane(spinner);
        root.addEventFilter(KeyEvent.KEY_PRESSED, e -> System.out.println("filter: " + e));
        root.addEventHandler(KeyEvent.KEY_PRESSED, e -> System.out.println("handler: " + e));
    
        stage.setScene(new Scene(root));
        stage.show();
    }

    The scene graph of this program is as follows (arrow buttons omitted for brevity):

    • StackPane
      • Spinner
        • TextField

    If the spinner is focused and a key is pressed, the following sequence of events can be observed:

    // First event targeted at Spinner
    filter: KeyEvent [source = StackPane, target = Spinner, eventType = KEY_PRESSED, ...]
        filter: KeyEvent [source = Spinner, target = Spinner, eventType = KEY_PRESSED, ...]
    
    // Second event targeted at FakeFocusTextField
    filter: KeyEvent [source = StackPane, target = FakeFocusTextField, eventType = KEY_PRESSED, ...]
        filter: KeyEvent [source = Spinner, target = FakeFocusTextField, eventType = KEY_PRESSED, ...]
            filter: KeyEvent [source = FakeFocusTextField, target = FakeFocusTextField, eventType = KEY_PRESSED, ...]
            handler: KeyEvent [source = FakeFocusTextField, target = FakeFocusTextField, eventType = KEY_PRESSED, ...]

    This is an obvious defect, as external observers will see two events being fired, when in reality only a single key was pressed.

Solution

It has become clear that JavaFX lacks the tools necessary to properly support decoupled external/internal representations of controls. A solution must therefore solve several problems at once:

  1. Allow focus delegation from an external control to an internal control.
  2. Make events consistent for both external and internal observers, without erroneous duplication.
  3. Ensure that focus delegation and consistent events also work in nested scenarios, i.e. when an internal control has its own internal representation.

Focus delegation

When focus is delegated, both the external and the internal control must be focused. Both controls must work as if they were the only focused control in order to disentangle these two levels of abstraction, and make it easier to reason about the focus state without adding special-case code to either of the two.

We introduce a new method on the Node class:

public class Node {
    ...
    
    /**
     * Gets the focus delegate for this {@code Node}, which must be a descendant of this {@code Node}.
     * <p>
     * Focus delegation allows nodes to delegate events targeted at them to one of their descendants. This is
     * a technique employed by controls that need to isolate their internal structure (which may be defined by
     * a skin) from their external representation. The external representation delegates the input focus to an
     * internal control by returning the internal control from the {@link #getFocusDelegate()} method. In this
     * case, when the external control receives the input focus, the internal control is focused as well. When
     * an input event is sent to the focused control, the external control receives the event first. If the
     * event is not consumed, it is dispatched to the focus delegate. A focus delegate might delegate the input
     * focus even further, forming a chain of focus delegates.
     * <p>
     * If an implementation returns a node from this method that is not a descendant of this {@code Node},
     * JavaFX ignores the returned value and treats this {@code Node} as having no focus delegate.
     * <p>
     * Focus delegation is often combined with {@link #isFocusScope() focus scoping}.
     *
     * @return the focus delegate, which is a descendant of this {@code Node}
     * @since 24
     */
    Node getFocusDelegate() { // package-private in Node, protected in Parent
        return null;
    }
}

Focus scoping

The natural counterpart of focus delegation is focus scoping. It solves the problem that we discussed earlier, where clicking on an internal control should also focus the external control. The external control defines a focus scope by returning true from the new Node.isFocusScope() method:

public class Node {
    ...
    
    /**
     * Indicates whether this {@code Node} is eligible to receive focus when a child node {@link #hoistFocus hoists}
     * a focus request. A node that receives a focus hoisting request may decide to hoist the request even further
     * up the scene graph.
     * <p>
     * Focus scoping is a technique employed by controls that need to isolate their internal structure (which may
     * be defined by a skin) from their external representation. Consider a control with an internal structure
     * that contains an interactive and independently focusable control. When a user clicks on the internal
     * interactive control, it is often desired that the external representation receive the input focus, so
     * that users of the control can reason about it as a monolith instead of a composite with unknown parts.
     * <p>
     * Focus scoping is often combined with {@link #getFocusDelegate() focus delegation}.
     *
     * @return {@code true} if this {@code Node} is eligible to receive hoisted focus requests;
     *         {@code false} otherwise
     * @since 24
     */
    boolean isFocusScope() { // package-private in Node, protected in Parent
        return false;
    }
}

In addition to defining a focus scope, internal controls must cooperate by hoisting focus requests. For this purpose, a new property is added to the Node class:

public class Node
    ...
    
    /**
     * Specifies whether this {@code Node} should hoist focus requests to its closest focus scope.
     * When this property is set to {@code true}, calling the {@link #requestFocus()} method has no
     * effect on this node, but is equivalent to requesting focus for the closest ancestor for which
     * {@link #isFocusScope()} is {@code true}.
     *
     * @since 24
     */
    private BooleanProperty hoistFocus;
}

How it works

With these new tools, we can finally solve the problem in its entirety. Here is a sample program that consists of five nested boxes.

  • box4 has its focusTraversable property set to true.
  • box0 and box2 are focus scopes.
  • box0 delegates focus to box2, and box2 delegates focus to box4.
  • All boxes except box0 have their hoistFocus property set to true.

This program therefore simulates nested focus scopes, as it might happen when internal controls have a nested internal representation:

Source code
@Override
public void start(Stage stage) {
    class Box extends StackPane {
        final int i;

        Box(int i, boolean hoistFocus, Node... children) {
            super(children);
            this.i = i; double size = 300 - i * 50;
            setPrefWidth(size); setPrefHeight(size); setMaxWidth(size); setMaxHeight(size);

            setBackground(Background.fill(Color.hsb(i * 10 + 20, 1, 0.9)));
            getChildren().add(new Label("box" + i) {{ StackPane.setAlignment(this, Pos.TOP_CENTER);}});
            setHoistFocus(hoistFocus);
        }

        @Override public String toString() { return "box" + i; }
    }

    var box4 = new Box(4, true) {
        { setFocusTraversable(true); }
    };
    var box3 = new Box(3, true, box4);
    var box2 = new Box(2, true, box3) {
        @Override public boolean isFocusScope() { return true; }
        @Override public Node getFocusDelegate() { return box4; }
    };
    var box1 = new Box(1, true, box2);
    var box0 = new Box(0, false, box1) {
        @Override public boolean isFocusScope() { return true; }
        @Override public Node getFocusDelegate() { return box2; }
    };

    for (var box : new Box[] {box0, box1, box2, box3, box4}) {
        box.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
            String indent = "   ".repeat(box.i);
            System.out.println(indent + "filter: " + event);
        });

        box.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
            String indent = "   ".repeat(box.i);
            System.out.println(indent + "handler: " + event);
        });

        box.focusedProperty().subscribe(focused -> {
            System.out.println(box + ".focused: " + focused);
        });
    }

    stage.setScene(new Scene(box0));
    stage.sizeToScene();
    stage.show();
}

When the program is run and we look at the output, we see that initially the five boxes are not focused:

box0.focused: false
box1.focused: false
box2.focused: false
box3.focused: false
box4.focused: false

Then JavaFX selects box4 as the focus owner, since its focusTraversable flag ist set (we can also request focus for box4 by clicking on it). The following things happen:

  1. Since box4 hoists focus requests, it immediately transfers focus to box2.
  2. In turn, box2 hoists the focus request even further up to box0.
  3. box0 delegates focus to box2.
  4. box2 delegates focus to box4.

Looking at the program output, the result is that box0, box2, and box4 are simultaneously focused:

box4.focused: true
box2.focused: true
box0.focused: true

When we now press a key on the keyboard, the KeyEvent will be dispatched to the final target of focus delegation (box4), and it will travel through the scene graph as follows:

filter: KeyEvent [source = box0, target = box0, eventType = KEY_PRESSED, ...]
   filter: KeyEvent [source = box1, target = box2, eventType = KEY_PRESSED, ...]
      filter: KeyEvent [source = box2, target = box2, eventType = KEY_PRESSED, ...]
         filter: KeyEvent [source = box3, target = box4, eventType = KEY_PRESSED, ...]
            filter: KeyEvent [source = box4, target = box4, eventType = KEY_PRESSED, ...]
            handler: KeyEvent [source = box4, target = box4, eventType = KEY_PRESSED, ...]
         handler: KeyEvent [source = box3, target = box4, eventType = KEY_PRESSED, ...]
      handler: KeyEvent [source = box2, target = box2, eventType = KEY_PRESSED, ...]
   handler: KeyEvent [source = box1, target = box2, eventType = KEY_PRESSED, ...]
handler: KeyEvent [source = box0, target = box0, eventType = KEY_PRESSED, ...]

Note that the event is initially targeted at box0, since it has the outermost input focus. This provides a clear separation between different levels of abstraction, as an observer of box0 will not see that the event is targeted at its internal substructure. Observers of box0 can therefore reason about the event in isolation.

When the event crosses a focus delegation boundary, it is re-targeted to the next focus delegate. Note that box1 will observe that the event is targeted at box2, because box2 is the next node that has the input focus. Observers of box1 and box2 can therefore also reason about the event in isolation, not knowing that it is eventually targeted at the innermost focus delegate.

Then the event crosses the innermost focus delegation boundary, and is re-targeted again at the final target of focus delegation. If not consumed, the event will now begin to bubble up again, and at each boundary, will be targeted back to its original target.

Fixing Spinner and SpinnerSkin

We now apply those new tools to the problem of Spinner and SpinnerSkin we discussed earlier. We remove all of the hacks, listeners, and re-firing of events from both classes. Now we simply need to make the following changes:

  1. Change Control.isFocusScope() to always return true.
  2. Change Spinner.getEditor() to return a normal TextField instead of FakeFocusTextField.
  3. Set the hoistFocus flag on all nodes of the spinner substructure (text field, arrow buttons).
  4. In SpinnerSkin, return textField from SkinBase.getFocusDelegate():
    public class SpinnerSkin {
        ...
        @Override
        protected Node getFocusDelegate() {
            return textField;
        }
    }

As a reminder, here's the sequence of events that we observed earlier when a key is pressed on an editable spinner:

// First event targeted at Spinner
filter: KeyEvent [source = StackPane, target = Spinner, eventType = KEY_PRESSED, ...]
    filter: KeyEvent [source = Spinner, target = Spinner, eventType = KEY_PRESSED, ...]

// Second event targeted at FakeFocusTextField
filter: KeyEvent [source = StackPane, target = FakeFocusTextField, eventType = KEY_PRESSED, ...]
    filter: KeyEvent [source = Spinner, target = FakeFocusTextField, eventType = KEY_PRESSED, ...]
        filter: KeyEvent [source = FakeFocusTextField, target = FakeFocusTextField, eventType = KEY_PRESSED, ...]
        handler: KeyEvent [source = FakeFocusTextField, target = FakeFocusTextField, eventType = KEY_PRESSED, ...]

Now, with the changes as outlined above, the sequence of events is as follows:

filter: KeyEvent [source = StackPane, target = Spinner, eventType = KEY_PRESSED, ...]
    filter: KeyEvent [source = Spinner, target = Spinner, eventType = KEY_PRESSED, ...]
        filter: KeyEvent [source = TextField, target = TextField, eventType = KEY_PRESSED, ...]
        handler: KeyEvent [source = TextField, target = TextField, eventType = KEY_PRESSED, ...]

Note that only a single event is fired. Observers of Spinner will see that the event is targeted at the spinner, while observers of TextField will see that the event is targeted at the text field. Both observers can reason about the event in isolation.

In addition to that, SpinnerBehavior can easily add an event filter, and upon receiving the UP/DOWN key, increment or decrement the spinner:

public class SpinnerBehavior {
    ...
    
    private final EventHandler<KeyEvent> spinnerKeyHandler = e -> {
        boolean arrowsAreVertical = arrowsAreVertical();
        KeyCode increment = arrowsAreVertical ? KeyCode.UP : KeyCode.RIGHT;
        KeyCode decrement = arrowsAreVertical ? KeyCode.DOWN : KeyCode.LEFT;

        if (e.getCode() == increment) {
            increment(1);
            e.consume();
        }
        else if (e.getCode() == decrement) {
            decrement(1);
            e.consume();
        }
    };

    public SpinnerBehavior(Spinner<T> spinner) {
        super(spinner);
        ...

        spinner.addEventFilter(KeyEvent.KEY_PRESSED, spinnerKeyHandler);
    }
    
    ...
}

Observe the sequence of events upon pressing UP/DOWN:

filter: KeyEvent [source = StackPane, target = Spinner, eventType = KEY_PRESSED, ...]
    filter: KeyEvent [source = Spinner, target = Spinner, eventType = KEY_PRESSED, ...]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment