Wednesday, 11 March 2015

JavaFX Filter ComboBox

Quite a lot of posts exist on the web about how hard it is to do a filter / filtered / filtering combo box in JavaFX.  Using one of these as a basis here is an example of what to do.  The key part is creating a StringConverter so that the strings that are displayed in the ComboBox can be translated to and from the underlying objects.

FilterComboBox

This class is a extension of the standard JavaFX ComboBox. The list of initialItems needs to be populated either through the constructor or by using the setInitialItems() method so that there is a definitive list to go back to when necessary.

import java.util.ArrayList;

/**
 * A control which provides a filtered combo box.  As the user
 * enters values into the combo editor the list is filtered automatically.
 *
 * @param <T> the object type that is held by this FilteredComboBox
 */
public class FilterComboBox<T extends Object> extends ComboBox<T>
{
    /**
     * The default / initial list that is in the combo when nothing
     * is entered in the editor.
     */
    private Collection<T> initialList = new ArrayList<>();

    /**
     * Check type.  True if this is startsWith, false if it is contains.
     */
    private final boolean startsWithCheck;

    /**
     * Constructs a new FilterComboBox with the given parameters.
     *
     * @param startsWithCheck true if this is a 'startsWith' check false if it is 'contains' check
     */
    public FilterComboBox(final boolean startsWithCheck)
    {
        super();
        this.startsWithCheck = startsWithCheck;

        super.setEditable(true);

        this.configAutoFilterListener();
    }

    /**
     * Constructs a new FilterComboBox with the given parameters.
     *
     * @param items The initial items
     * @param startsWithCheck true if this is a 'startsWith' check false if it is 'contains' check
     */
    public FilterComboBox(final ObservableList<T> items, final boolean startsWithCheck)
    {
        super(items);
        this.startsWithCheck = startsWithCheck;
        super.setEditable(true);
        initialList = items;

        this.configAutoFilterListener();
    }

    /**
     * Set the initial list of items into this combo box.
     *
     * @param initial The initial list
     */
    public void setInitialItems(final Collection<T> initial)
    {
        super.getItems().clear();
        super.getItems().addAll(initial);
        this.initialList = initial;
    }

    /**
     * Set up the auto filter on the combo.
     */
    private void configAutoFilterListener()
    {
        this.getEditor().textProperty().addListener(new ChangeListener<String>()
        {
            @Override
            public void changed(final ObservableValue<? extends String> observable, final String oldValue, final String newValue)
            {
                final T selected = getSelectionModel().getSelectedItem();
                if (selected == null
                        || !getConverter().toString(selected).equals(getEditor().getText()))
                {
                    filterItems(newValue);

                    if (getItems().size() == 1)
                    {
                        setUserInputToOnlyOption();
                        hide();
                    }
                    else if (!getItems().isEmpty())
                    {
                        show();
                    }
                }
            }
        });
    }

    /**
     * Method to filter the items and update the combo.
     *
     * @param filter The filter string to use.
     */
    private void filterItems(final String filter)
    {
        final ObservableList<T> filteredList = FXCollections.observableArrayList();
        for (T item : initialList)
        {
            if (startsWithCheck && getConverter().toString(item).toLowerCase().startsWith(filter.toLowerCase()))
            {
                filteredList.add(item);
            }
            else if (!startsWithCheck && getConverter().toString(item).toLowerCase().contains(filter.toLowerCase()))
            {
                filteredList.add(item);
            }
        }

        setItems(filteredList);
    }

    /**
     * If there is only one item left in the combo then we assume this is correct.
     * Put the item into the editor but select the end of the string that the user
     * hasn't actually entered.
     */
    private void setUserInputToOnlyOption()
    {
        final String onlyOption = getConverter().toString(getItems().get(0));
        final String currentText = getEditor().getText();
        if (onlyOption.length() > currentText.length())
        {
            getEditor().setText(onlyOption);
            Platform.runLater(new Runnable()
            {
                @Override
                public void run()
                {
                    getEditor().selectAll();
                }
            });
        }
    }
}

StringConverter

The key to this working correctly is a StringConverter object which allows JavaFX to convert properly from the Object in the ComboBox to the String which is displayed in the ComboBox.  The StringConverter is a standard JavaFX object which has a toString() and fromString() method.  The easiest way to get this working is to construct a StringConverter and provide the same list of items to it that is provided to the ComboBox.


public class MyObjectStringConverter extends StringConverter<MyObject> 
{
    /** The list of objects to do the conversions with. */
    private List<MyObject> myObjList;

    /**
     * Construct this object with the list for converting.
     *
     * @param items The items list
     *
     */
    public MyStringConverter(List<MyObject> items)
    {
        this.myObjList = items;
    }

    @Override
    public String toString(final MyObject myObj)
    {
        if (myObj != null)
        {
            return myObj.getName();
        }
        return null;
    }

    @Override
    public MyObject fromString(final String item)
    {
        for (MyObject myObj : myObjList)
        {
            if (myObj.getName().equals(item))
            {
                return myObj;
            }
        }
        return null;
    }
}

Usage

The usage of this combo is extremely easy.  Other than the standard ComboBox options in JavaFX the only things really necessary are making sure the initialList is set through the constructor or directly and making sure that the converter is used.

    final List<MyObject> myObjs = new ArrayList<>();
    ...

    final MyObjectStringConverter converter = new MyObjectStringConverter(myObjs);

    final FilterComboBox comboBox = new FilterComboBox(true);
    comboBox.setInitialItems(myObjs);
    comboBox.setConverter(converter);