Search code examples
javaswing

Searchable JComboBox with custom items


I need to implement a "searchable" JComboBox: one that filters its displayed items as you type

Inspired by this answer, I wrote this implementation

package di;

import org.apache.commons.lang3.StringUtils;

import javax.swing.*;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Vector;

public class ComboBoxes {
    private ComboBoxes() {
    }

    public static <T> JComboBox<T> searchableComboBox() {
        return searchableComboBox(new ArrayList<>());
    }

    public static <T> JComboBox<T> searchableComboBox(Collection<T> items) {
        ComboBoxModel<T> model = new DefaultComboBoxModel<>(new Vector<>(items));
        JComboBox<T> comboBox = new JComboBox<>(model);
        comboBox.setEditable(true);
        ComboBoxEditor comboBoxEditor = comboBox.getEditor();
        JTextField editorTextField = (JTextField) comboBoxEditor.getEditorComponent();
        KeyAdapter searchableComboBoxKeyListener = createSearchableComboBoxKeyListener(comboBox, model);
        editorTextField.addKeyListener(searchableComboBoxKeyListener);
        return comboBox;
    }

    private static <T> KeyAdapter createSearchableComboBoxKeyListener(JComboBox<T> comboBox, ComboBoxModel<T> initialModel) {
        return new KeyAdapter() {
            @Override
            public void keyReleased(KeyEvent e) {
                JTextField editorTextField = (JTextField) comboBox.getEditor().getEditorComponent();
                String enteredText = editorTextField.getText();
                ArrayList<T> matchingElements = findMatchingElements(enteredText);
                DefaultComboBoxModel<T> modelWithOnlyMatchingElements = new DefaultComboBoxModel<>(new Vector<>(matchingElements));
                comboBox.setModel(modelWithOnlyMatchingElements);
                comboBox.setSelectedItem(enteredText);
                comboBox.showPopup();
            }

            private ArrayList<T> findMatchingElements(String enteredText) {
                ArrayList<T> matchingElements = new ArrayList<>();
                for (int i = 0; i < initialModel.getSize(); i++) {
                    T modelElement = initialModel.getElementAt(i);
                    if (StringUtils.containsIgnoreCase(modelElement.toString(), enteredText))
                        matchingElements.add(modelElement);
                }
                return matchingElements;
            }
        };
    }
}

It works great with strings. It works sort of ok with custom objects, but there's a small problem

The key listener sets the entered string as a new selected item. It can get away with that since setSelectedItem() accepts an Object, even though JComboBox is a parameterized type (as generics were introduced far later than Swing, JComboBox is not really generic)

So if you listen to new selections and expect selected items to be of JComboBox's type argument, you'll get a ClassCastException as strings cannot be cast to that type. Here's a demo

package demos.combo;

import di.ComboBoxes;

import javax.swing.*;
import java.awt.event.ItemEvent;
import java.util.ArrayList;

public class SearchableComboBoxDemo {
    public static void main(String[] args) {
        JFrame frame = new JFrame("Searchable Combo Box Demo");
        JPanel mainPanel = new JPanel();
        JComboBox<Plant> searchableCombo = ComboBoxes.searchableComboBox();
        searchableCombo.addItemListener(SearchableComboBoxDemo::onItemSelection);
        comboBoxItems().forEach(searchableCombo::addItem);
        mainPanel.add(searchableCombo);
        frame.setContentPane(mainPanel);
        frame.pack();
        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }

    private static void onItemSelection(ItemEvent event) {
        if (event.getStateChange() != ItemEvent.SELECTED) return;
        Plant selectedItem = (Plant) event.getItem(); // throws
        System.out.printf("Selected item: " + selectedItem + "\n");
    }

    private static ArrayList<Plant> comboBoxItems() {
        ArrayList<Plant> items = new ArrayList<>();
        items.add(new Plant("Potato"));
        items.add(new Plant("Peach"));
        items.add(new Plant("Banana"));
        items.add(new Plant("Orange"));
        items.add(new Plant("Carrot"));
        items.add(new Plant("Cabbage"));
        return items;
    }
}
package demos.combo;

public class Plant {
    private final String name;

    public Plant(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return name;
    }
}

The setSelectedItem() call is important. If you remove it, the text field will restore a valid value after you try to delete a character. It's a side effect of setting a new model

One would imagine that setting the text field's text directly would fix it

                comboBox.setModel(modelWithOnlyMatchingElements);
                editorTextField.setText(enteredText); // like this
                comboBox.showPopup();

but then JComboBox doesn't fire ItemEvents as expected. Try typing "o", then click "Orange", then select and delete all text, then pick "Potato". You won't receive a "potato selected" event. It's because when the field is cleared and a new model is set, the new model's selected item (the first item in this case) is automatically assigned to JComboBox's selectedItemReminder field

/*
the invoked DefaultComboBoxModel constructor automatically selects the first element,
there's nothing we can do about it
*/
    public DefaultComboBoxModel(Vector<E> v) {
        objects = v;

        if ( getSize() > 0 ) {
            selectedObject = getElementAt( 0 );
        }
    }

If the user's selection matches it (and it does), then no IventItem is ever fired (see javax.swing.JComboBox#setSelectedItem)

So, in summary, how do I implement a searchable JComboBox:

  1. ...with non-string elements
  2. ...that an ItemListener can listen to

?


Solution

  • One workaround is to set selectedItem to null

    comboBox.setModel(modelWithOnlyMatchingElements);
    comboBox.setSelectedItem(null);
    editorTextField.setText(enteredText);
    comboBox.showPopup();