Extending the JavaFX ComboBox and ChoiceBox

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.

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!

4 thoughts on “Extending the JavaFX ComboBox and ChoiceBox

  1. 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.

  2. 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);
    }

    }

Please leave a comment