This article is about adding a small feature to the JavaFX ComboBox
and ChoiceBox
: We want to give the user the possibility to quickly select a specific entry in large lists by using the keyboard.
Contents
Introduction
We are migrating a fairly large government application from an in-house ui toolkit based on wxWidgets to JavaFX. One of the first things our users complained about was that it wasn’t possible anymore to select an entry from a choice box or combo box by using the keyboard. Our users know the part of the application the use daily very well and are used to working mostly by keyboard. Every grip to the mouse is annoying. Since this was possible on the native Windows combo box control we need to find a way to make it work with JavaFX.
The user interface is defined in fxml (or at least it will be in the future – at the time of writing we are using an xsl transformation to convert the wxML design to fxml on the fly). We didn’t want to introduce new ComboBox
and ChoiceBox
classes for this small feature which could very well be part of the framework soon (see JDK-8092270). So we opted to write a customizer which searches the widget tree of a freshly loaded fxml searching for instances of ComboBox
and ChoiceBox
to install the “prefix selection feature”. (See Walking the JavaFX Scene Graph on how we search the widget tree.)
Prefix Selection
For the rest of the document I will only talk about the “combo box” omitting the “uneditable” and the “choice box” for brevity. Please remember that the combo box needs to be uneditable for this discussion to be applicable and that everything is also applicable to a choice box.
Specification
What we want to do is enable the user to type letters or digits on the keyboard while the combo box has the focus. The combo box will then attempt to select the first item it can find with a matching prefix ignoring case.
Let’s look at an example to clarify this. The combo box offers the items [“Aaaaa”, “Abbbb”, “Abccc”, “Abcdd”, “Abcde”]. The user now types “abc” in quick succession (and then stops typing). The combo box will select a new entry on every key pressed. The first entry it will select is “Aaaaa” since it is the first entry that starts with an “a” (case ignored). It will then select “Abbbb” since this is the first entry that started with “ab” and will finally settle for “Abccc”.
Keys typed | Final element selected | |
---|---|---|
a | Aaaaa | |
ab | Abbbb | |
abc | Abccc | |
aaa | Aaaaa | |
abx | Abbbb | |
xyz | – |
Implementation
The feature is implemented in a separate class that can handle both the ComboBox
as well as the ChoiceBox
. Let’s call it PrefixSelectionCustomizer
and give it static methods to extend already existing combo box instances.
At the time of writing the source is available on my fork of the ControlsFX repository.
To install the feature we add an event handler to the combo box so we get notified on every key the user presses. We then need to check if the user has already typed before so we can add the new letter or digit to the saved prefix selection sequence. Afterwards we can the traverse the item list of the combo box and compare the included items with our sequence. At this stage we need to take care to use the converter installed in the combo box so that we compare to what the user actually sees on the screen. If we find an item we tell the combo box to select it.
Here the part from the implementation that records the prefix selection sequence and tries to find a fitting item:
private <T> T getEntryWithKey(String letter, StringConverter<T> converter, ObservableList<T> items, Control control) { T result = null; // The converter is null by default for the ChoiceBox. The ComboBox has a default converter if (converter == null) { converter = new StringConverter<T>() { @Override public String toString(T t) { return t == null ? null : t.toString(); } @Override public T fromString(String string) { return null; } }; } String selectionPrefixString = (String) control.getProperties().get(SELECTION_PREFIX_STRING); if (selectionPrefixString == null) { selectionPrefixString = letter.toUpperCase(); } else { selectionPrefixString += letter.toUpperCase(); } control.getProperties().put(SELECTION_PREFIX_STRING, selectionPrefixString); for (T item : items) { String string = converter.toString(item); if (string != null && string.toUpperCase().startsWith(selectionPrefixString)) { result = item; break; } } return result; }
Sometimes the user might not find the item he or she was looking for or wants to start fresh since he or she mistyped. We wanted to implement this so it behaves similar to the Windows combo box. In Windows, if you wait a short amount of time the recorded prefix will be cleared. To achieve this we submit an appropriate task to a scheduled executor. If a new key is pressed we can cancel a possibly present task and create a new one.
ScheduledFuture<?> task = (ScheduledFuture<?>) control.getProperties().get(SELECTION_PREFIX_TASK); if (task != null) { task.cancel(false); } // The method `getExecutorService()` returns an instance of `ScheduledExecutorService` task = getExecutorService().schedule( () -> control.getProperties().put(SELECTION_PREFIX_STRING, ""), 500, TimeUnit.MILLISECONDS); control.getProperties().put(SELECTION_PREFIX_TASK, task);
After a few tests we settled for half of a second (500 milliseconds) delay before we clear the prefix selection sequence. I am very interested in how this works for you, so please let me know what you think.
Using it
From a developers point of view the usage is very simple. All you need to do is call the appropriate PrefixSelectionCustomizer#customize
method and pass your combo box or choice box.
The only thing of note is that the feature should only be installed for a ComboBox
when the combo box is not editable.
public static void customize(ComboBox<?> comboBox) { if (!comboBox.isEditable()) { comboBox.addEventHandler(KeyEvent.KEY_PRESSED, handler); } comboBox.editableProperty().addListener((o, oV, nV) -> { if (!nV) { comboBox.addEventHandler(KeyEvent.KEY_PRESSED, handler); } else { comboBox.removeEventHandler(KeyEvent.KEY_PRESSED, handler); } }); } public static void customize(ChoiceBox<?> choiceBox) { choiceBox.addEventHandler(KeyEvent.KEY_PRESSED, handler); }
Or you can use extensions of ComboBox
or ChoiceBox
which are simply implemented by calling the PrefixSelectionCustomizer
.
public class PrefixSelectionChoiceBox<T> extends ChoiceBox<T> { public PrefixSelectionChoiceBox() { PrefixSelectionCustomizer.customize(this); } } public class PrefixSelectionComboBox<T> extends ComboBox<T> { public PrefixSelectionComboBox() { setEditable(false); PrefixSelectionCustomizer.customize(this); } }
Conclusion
From a testers point of view this feature seems to work very nicely, but I don’t have any real users playing with this yet, so there might be some problems to sort out in the future when we set it loose.
I would very much like to see this feature in JavaFX. Following a comment on JDK-8092270 by Jonathan I contributed this feature to ControlsFX in pull request #496, which has just been merged. Please try it and join in on the discussion!
Great extension, exactly what I have been looking for 🙂
Just a small remark: to exactly copy the behaviour of the old combo box, it’s not sufficient to check for letters and digits, but you also need to check for spaces.
Thanks for the input. I updated my pull request accordingly.
Thanks for the article. It was really helpful.
here’s my adaptation of your code. I decided to extend ChoiceBox for my project instead of creating a customizer that applies an event handler.
public class PrefixChoiceBox extends ChoiceBox {
private static final String SELECTION_PREFIX_TASK = “task”;
private static final String SELECTION_PREFIX_STRING = “prefix”;
private final ScheduledExecutorService executorService =
Executors.newScheduledThreadPool(
1,
runnabble -> {
Thread result = new Thread(runnabble);
result.setDaemon(true);
return result;
});
public PrefixChoiceBox() {
super();
addEventHandler(KeyEvent.KEY_TYPED, getKeyEventHandler());
}
private EventHandler getKeyEventHandler() {
return (KeyEvent event) -> {
String letter = event.getCharacter();
KeyCode code = KeyCode.getKeyCode(letter);
if (code.isLetterKey() ||
code.isDigitKey() ||
code.isWhitespaceKey()) {
String item = getEntryWithKey(letter);
if (item != null) {
setValue(item);
}
}
};
}
private String getEntryWithKey(String letter) {
String prefix = (String)getProperties().get(SELECTION_PREFIX_STRING);
if (prefix == null) {
prefix = letter.toUpperCase();
} else {
prefix += letter.toUpperCase();
}
getProperties().put(SELECTION_PREFIX_STRING, prefix);
for (String item : getItems()) {
if (item != null) {
int itemLength = item.length();
if (itemLength < prefix.length()) {
continue;
}
String itemPrefix = item.substring(0, prefix.length());
if (prefix.equalsIgnoreCase(itemPrefix)) {
scheduleClearEvent();
return item;
}
}
}
return null;
}
private void scheduleClearEvent() {
ScheduledFuture task =
(ScheduledFuture)getProperties().get(SELECTION_PREFIX_TASK);
if (task != null) {
task.cancel(false);
}
task = executorService.schedule(
() -> getProperties().put(SELECTION_PREFIX_STRING, “”), 500,
TimeUnit.MILLISECONDS);
getProperties().put(SELECTION_PREFIX_TASK, task);
}
}
Hi, I’m having trouble implementing this. If you have time, please look at my question at SO: http://stackoverflow.com/questions/39685038/javafx-how-to-apply-prefixselectioncombobox-to-combobox
Thank you!
Pingback: Java desktop links of the week, August 17 – Jonathan Giles