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:
-
Clicking on or tabbing into the internal editable
TextField
should focus theSpinner
control.
Since the internal structure is hidden from users, it is obvious that clicking on or tabbing into the internalTextField
should focus the externalSpinner
control. This is achieved with two separate hacks:- Subclassing
TextField
and overriding therequestFocus()
methods to create the illusion of focus, even though the parent node is focused:
This requirespublic class FakeFocusTextField extends TextField { @Override public void requestFocus() { if (getParent() != null) { getParent().requestFocus(); } } public void setFakeFocus(boolean b) { setFocused(b); } }
SpinnerSkin
to set the "fake focus" flag whenSpinner.focused
changes:// move fake focus in to the textfield if the spinner is editable lh.addChangeListener(control.focusedProperty(), (op) -> { ((FakeFocusTextField)textField).setFakeFocus(control.isFocused()); });
- By carefully abusing the
Spinner.getProperties()
map as a side-channel for communication:- In
SpinnerSkin
, an event handler listens to theTextField.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 ownSpinner.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"); } } });
- In
- Subclassing
-
Events sent to the
Spinner
should also reach the internalTextField
.
Since theSpinner
is the focus owner, key events will be sent to this control. However, an editable spinner requires its internalTextField
to receive key events, as otherwise the text field will simply not work. This is achieved bySpinnerSkin
adding an event filter to bothSpinner
and the internalTextField
, and then duplicating and re-firing key events under specific conditions to target either the externalSpinner
control or the internalTextField
. This leads to a highly entangled implementation of multiple event listeners, a lack of separation betweenSpinnerSkin
and its behavior, and most importantly to externally observable defects.
Consider the follwing sample program, which only consists of aStackPane
that contains aSpinner
:@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
- Spinner
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.
- StackPane
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:
- Allow focus delegation from an external control to an internal control.
- Make events consistent for both external and internal observers, without erroneous duplication.
- Ensure that focus delegation and consistent events also work in nested scenarios, i.e. when an internal control has its own internal representation.
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;
}
}
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;
}
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 itsfocusTraversable
property set totrue
.box0
andbox2
are focus scopes.box0
delegates focus tobox2
, andbox2
delegates focus tobox4
.- All boxes except
box0
have theirhoistFocus
property set totrue
.
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:
- Since
box4
hoists focus requests, it immediately transfers focus tobox2
. - In turn,
box2
hoists the focus request even further up tobox0
. box0
delegates focus tobox2
.box2
delegates focus tobox4
.
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.
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:
- Change
Control.isFocusScope()
to always returntrue
. - Change
Spinner.getEditor()
to return a normalTextField
instead ofFakeFocusTextField
. - Set the
hoistFocus
flag on all nodes of the spinner substructure (text field, arrow buttons). - In
SpinnerSkin
, returntextField
fromSkinBase.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, ...]