2

I want to implement an autocomplete functionality. Currently I have a JPanel containing JTextField and, when user starts typing, an autocomplete (JPopupMenu) appears, containing several options.

The problem is that it takes focus from text field and user no longer can type. When I return focus to text field, user no longer have navigation between options (using up and down buttons). Also having focus on menu does not allow me to intercept its KeyListener (don't know why), and when I try processing input on text field side I have issues with trying to select menu items.

So that I want to have:

  1. A popup menu with options which dynamically changes when user changes text in textfield, still having menu active
  2. User can navigate between options using up and down arrow keys, as well as Enter and Escape keys to use option or close popup respectively.

Is it possible to process keyboard events on menu and forward typing events back to text field?

What is the proper way approaching my problem?

Here is the code below. Thanks in advance!

import javax.swing.*;
import java.awt.*;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;


class TagVisual extends JPanel {

    private JTextField editField;

    public TagVisual() {

        FlowLayout layout = new FlowLayout();
        layout.setHgap(0);
        layout.setVgap(0);
        setLayout(layout);

        editField = new JTextField();
        editField.setBackground(Color.RED);

        editField.setPreferredSize(new Dimension(200, 20));

        editField.addKeyListener(new KeyListener() {
            @Override
            public void keyTyped(KeyEvent e) {
                JPopupMenu menu = new JPopupMenu();
                menu.add("Item 1");
                menu.add("Item 2");
                menu.add("Item 3");
                menu.addKeyListener(new KeyListener() {
                    @Override
                    public void keyTyped(KeyEvent e) {
                        JOptionPane.showMessageDialog(TagVisual.this, "keyTyped");
                    }

                    @Override
                    public void keyPressed(KeyEvent e) {
                        JOptionPane.showMessageDialog(TagVisual.this, "keyPressed");
                    }

                    @Override
                    public void keyReleased(KeyEvent e) {
                        JOptionPane.showMessageDialog(TagVisual.this, "keyReleased");
                    }
                });
                menu.show(editField, 0, getHeight());
            }

            @Override
            public void keyPressed(KeyEvent e) {

            }

            @Override
            public void keyReleased(KeyEvent e) {

            }
        });

        add(editField, FlowLayout.LEFT);
    }

    public void place(JPanel panel) {
        panel.add(this);

        editField.grabFocus();
    }
}

public class MainWindow {

    private JPanel mainPanel;
    private JFrame frame;

    public MainWindow(JFrame frame) {

        mainPanel = new JPanel(new FlowLayout());
        TagVisual v = new TagVisual();
        v.place(mainPanel);

        this.frame = frame;
    }

    public static void main(String[] args) {
        JFrame frame = new JFrame("TextFieldPopupIssue");

        frame.setContentPane(new MainWindow(frame).mainPanel);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.pack();
        frame.setVisible(true);
    }
}
  • Or use a third party library. See for example https://stackoverflow.com/q/14186955/1076463 – Robin Aug 02 '17 at 13:02

2 Answers2

3

I would personally suggest using a popup or a customized JWindow instead of JPopupMenu as the latter initially only intended for displaying only menu items. It does work in general for other things, but it is not the best practice to use it differently.

For instance, you have a few menu items in your example as the autocomplete options - that works just fine if there are just a few results. But what if there will be 10 of them? What if 50? Or 500? You will have to create additional workarounds for those cases somehow - either put items into scroll pane (oh god, that would look ugly) or cut results down to a few best (which is not the best options either).

So I made a small example using JWindow as a popup for the AutocompleteField. It is quite simple but does a few basic things you would expect from it and also the ones you have mentioned:

import javax.swing.*;
import javax.swing.border.EmptyBorder;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.*;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * @author Mikle Garin
 * @see https://stackoverflow.com/questions/45439231/implementing-autocomplete-with-jtextfield-and-jpopupmenu
 */

public final class AutocompleteField extends JTextField implements FocusListener, DocumentListener, KeyListener
{
    /**
     * {@link Function} for text lookup.
     * It simply returns {@link List} of {@link String} for the text we are looking results for.
     */
    private final Function<String, List<String>> lookup;

    /**
     * {@link List} of lookup results.
     * It is cached to optimize performance for more complex lookups.
     */
    private final List<String> results;

    /**
     * {@link JWindow} used to display offered options.
     */
    private final JWindow popup;

    /**
     * Lookup results {@link JList}.
     */
    private final JList list;

    /**
     * {@link #list} model.
     */
    private final ListModel model;

    /**
     * Constructs {@link AutocompleteField}.
     *
     * @param lookup {@link Function} for text lookup
     */
    public AutocompleteField ( final Function<String, List<String>> lookup )
    {
        super ();
        this.lookup = lookup;
        this.results = new ArrayList<> ();

        final Window parent = SwingUtilities.getWindowAncestor ( this );
        popup = new JWindow ( parent );
        popup.setType ( Window.Type.POPUP );
        popup.setFocusableWindowState ( false );
        popup.setAlwaysOnTop ( true );

        model = new ListModel ();
        list = new JList ( model );

        popup.add ( new JScrollPane ( list )
        {
            @Override
            public Dimension getPreferredSize ()
            {
                final Dimension ps = super.getPreferredSize ();
                ps.width = AutocompleteField.this.getWidth ();
                return ps;
            }
        } );

        addFocusListener ( this );
        getDocument ().addDocumentListener ( this );
        addKeyListener ( this );
    }

    /**
     * Displays autocomplete popup at the correct location.
     */
    private void showAutocompletePopup ()
    {
        final Point los = AutocompleteField.this.getLocationOnScreen ();
        popup.setLocation ( los.x, los.y + getHeight () );
        popup.setVisible ( true );
    }

    /**
     * Closes autocomplete popup.
     */
    private void hideAutocompletePopup ()
    {
        popup.setVisible ( false );
    }

    @Override
    public void focusGained ( final FocusEvent e )
    {
        SwingUtilities.invokeLater ( () -> {
            if ( results.size () > 0 )
            {
                showAutocompletePopup ();
            }
        } );
    }

    private void documentChanged ()
    {
        SwingUtilities.invokeLater ( () -> {
            // Updating results list
            results.clear ();
            results.addAll ( lookup.apply ( getText () ) );

            // Updating list view
            model.updateView ();
            list.setVisibleRowCount ( Math.min ( results.size (), 10 ) );

            // Selecting first result
            if ( results.size () > 0 )
            {
                list.setSelectedIndex ( 0 );
            }

            // Ensure autocomplete popup has correct size
            popup.pack ();

            // Display or hide popup depending on the results
            if ( results.size () > 0 )
            {
                showAutocompletePopup ();
            }
            else
            {
                hideAutocompletePopup ();
            }
        } );
    }

    @Override
    public void focusLost ( final FocusEvent e )
    {
        SwingUtilities.invokeLater ( this::hideAutocompletePopup );
    }

    @Override
    public void keyPressed ( final KeyEvent e )
    {
        if ( e.getKeyCode () == KeyEvent.VK_UP )
        {
            final int index = list.getSelectedIndex ();
            if ( index != -1 && index > 0 )
            {
                list.setSelectedIndex ( index - 1 );
            }
        }
        else if ( e.getKeyCode () == KeyEvent.VK_DOWN )
        {
            final int index = list.getSelectedIndex ();
            if ( index != -1 && list.getModel ().getSize () > index + 1 )
            {
                list.setSelectedIndex ( index + 1 );
            }
        }
        else if ( e.getKeyCode () == KeyEvent.VK_ENTER )
        {
            final String text = ( String ) list.getSelectedValue ();
            setText ( text );
            setCaretPosition ( text.length () );
        }
        else if ( e.getKeyCode () == KeyEvent.VK_ESCAPE )
        {
            hideAutocompletePopup ();
        }
    }

    @Override
    public void insertUpdate ( final DocumentEvent e )
    {
        documentChanged ();
    }

    @Override
    public void removeUpdate ( final DocumentEvent e )
    {
        documentChanged ();
    }

    @Override
    public void changedUpdate ( final DocumentEvent e )
    {
        documentChanged ();
    }

    @Override
    public void keyTyped ( final KeyEvent e )
    {
        // Do nothing
    }

    @Override
    public void keyReleased ( final KeyEvent e )
    {
        // Do nothing
    }

    /**
     * Custom list model providing data and bridging view update call.
     */
    private class ListModel extends AbstractListModel
    {
        @Override
        public int getSize ()
        {
            return results.size ();
        }

        @Override
        public Object getElementAt ( final int index )
        {
            return results.get ( index );
        }

        /**
         * Properly updates list view.
         */
        public void updateView ()
        {
            super.fireContentsChanged ( AutocompleteField.this, 0, getSize () );
        }
    }

    /**
     * Sample {@link AutocompleteField} usage.
     *
     * @param args run arguments
     */
    public static void main ( final String[] args )
    {
        final JFrame frame = new JFrame ( "Sample autocomplete field" );

        // Sample data list
        final List<String> values = Arrays.asList ( "Frame", "Dialog", "Label", "Tree", "Table", "List", "Field" );

        // Simple lookup based on our data list
        final Function<String, List<String>> lookup = text -> values.stream ()
                .filter ( v -> !text.isEmpty () && v.toLowerCase ().contains ( text.toLowerCase () ) && !v.equals ( text ) )
                .collect ( Collectors.toList () );

        // Autocomplete field itself
        final AutocompleteField field = new AutocompleteField ( lookup );
        field.setColumns ( 15 );

        final JPanel border = new JPanel ( new BorderLayout () );
        border.setBorder ( new EmptyBorder ( 50, 50, 50, 50 ) );
        border.add ( field );
        frame.add ( border );

        frame.setDefaultCloseOperation ( WindowConstants.EXIT_ON_CLOSE );
        frame.pack ();
        frame.setLocationRelativeTo ( null );
        frame.setVisible ( true );
    }
}

So in this example popup JWindow itself is not active (not focused) and cannot gain focus as its forcefully configured to be so. That allows us to keep focus within JTextField and keep typing.

In this example we also capture key events like UP/DOWN arrows in the field to navigate through the autocomplete results. And ENTER and ESCAPE are used to accept/cancel result choice.

This code could also be slightly rewritten to use Swing PopupFactory as a source of the autocomplete popup window, but it would still be the same in the essense since HeavyWeightWindow used by PopupFactory simply extends JWindow and adds a few settings.

Mikle Garin
  • 10,083
  • 37
  • 59
1

The simplest solution is making menu not-focusable:

menu.setFocusable(false);

and handle keys in the editor

editField.addKeyListener(new KeyAdapter() {
            @Override
            public void keyPressed(KeyEvent e) {
                if(KeyEvent.VK_DOWN == e.getKeyCode()) {
                    ...
Simon Sadetsky
  • 544
  • 2
  • 5