viniciussss
viniciussss

Reputation: 4652

JComboBox popup appears and hide immediately when clicking on its border (Bad User Experience)

When you have a swing JComboBox and click on its border, the popup appears and disappears immediately. When I say click, I mean press the left button of the mouse and release immediately.

enter image description here

It may be considered a bad user experience because no user would expect it to happen. Any user would expect one of the following behaviors when clicking on a border of a combobox:

  1. The popup to open and remain opened,
  2. Or it not to open at all.

Surely no user would expect the popup to be opened and closed immediately.

The user does not click on the border on purpose. But it may happen frequently when the combobox is small and he tries to click on it quickly.

In the year 2000 somebody registered this behavior as a bug in openjdk site: https://bugs.openjdk.java.net/browse/JDK-4346918

They've recognized it as a bug, but closed it with the resolution: "Won't fix", with the following observation:

I've been able to reproduce the problem but it's not significant so I'm not going to fix it. The problem is that the drop down portion of the combo box will hide when the mouse is released after clicking on the border. This bug doesn't have a very major impact.

I agree with them, that it doesn't have a very major impact. But I still think that it leads to a bad user experience and I would like to know if there is a simple workaround to make the popup either to remain opened or not to open at all when the user clicks on its border.

The described behavior can be reproduced by clicking the left mouse button on the border of any JComboBox. See below a simple code where it can be reproduced:

import java.awt.FlowLayout;
import javax.swing.*;

public class JComboBoxUX{
    public static void main(String[] args){
        SwingUtilities.invokeLater(new Runnable(){
            @Override
            public void run(){
                JComboBox<String> combobox = new JComboBox<String>(
                        new String[]{"aaaaaaaaaa","bbbbbbbb","ccccccccc"});

                JPanel panel = new JPanel(new FlowLayout(FlowLayout.CENTER, 10, 10));
                panel.add(combobox);

                JFrame frame = new JFrame("JComboBox UX");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.setContentPane(panel);
                frame.setSize(300, 150);
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }
}

Upvotes: 10

Views: 2648

Answers (2)

viniciussss
viniciussss

Reputation: 4652

AJNeufeld's suggestion worked perfectly. Thank you!

Below is the code, if someone needs it.

JComboBoxGoodBorder.java:

import java.awt.Component;
import java.awt.Dimension;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.util.Vector;
import javax.swing.ComboBoxModel;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.SwingUtilities;
import javax.swing.plaf.ComponentUI;
import javax.swing.plaf.basic.BasicComboPopup;
import javax.swing.plaf.basic.ComboPopup;
import javax.swing.plaf.metal.MetalComboBoxUI;

public class JComboBoxGoodBorder<T> extends JComboBox<T> {

    public JComboBoxGoodBorder(){
        super();
    }

    public JComboBoxGoodBorder(ComboBoxModel<T> aModel){
        super(aModel);
    }

    public JComboBoxGoodBorder(T[] items){
        super(items);
    }

    public JComboBoxGoodBorder(Vector<T> items){
        super(items);
    }

    @Override
    public void updateUI(){
        setUI(MetalComboBoxUIGoodBorder.createUI(this));
    }

    private static class MetalComboBoxUIGoodBorder extends MetalComboBoxUI {
        public static ComponentUI createUI(JComponent c) {
            return new MetalComboBoxUIGoodBorder();         
        }

        @Override
        protected ComboPopup createPopup() {
            return new BasicComboPopup(comboBox) {
                @Override
                protected MouseListener createMouseListener(){
                    return new MouseAdapter(){
                        @Override
                        public void mousePressed(MouseEvent e) {
                            if (e.getSource() == list) {
                                return;
                            }
                            if (!SwingUtilities.isLeftMouseButton(e) || !comboBox.isEnabled())
                                return;

                            if ( comboBox.isEditable() ) {
                                Component comp = comboBox.getEditor().getEditorComponent();
                                if ((!(comp instanceof JComponent)) || ((JComponent)comp).isRequestFocusEnabled()) {
                                    comp.requestFocus();
                                }
                            }
                            else if (comboBox.isRequestFocusEnabled()) {
                                comboBox.requestFocus();
                            }
                            togglePopup();
                        }

                        @Override
                        public void mouseReleased(MouseEvent e) {
                            if (e.getSource() == list) {
                                if (list.getModel().getSize() > 0) {
                                    // JList mouse listener
                                    if (comboBox.getSelectedIndex() != list.getSelectedIndex()) {
                                        comboBox.setSelectedIndex( list.getSelectedIndex() );
                                    } else {
                                        comboBox.getEditor().setItem( list.getSelectedValue() );
                                    }
                                }
                                comboBox.setPopupVisible(false);
                                // workaround for cancelling an edited item (bug 4530953)
                                if (comboBox.isEditable() && comboBox.getEditor() != null) {
                                    comboBox.configureEditor(comboBox.getEditor(),
                                            comboBox.getSelectedItem());
                                }
                                return;
                            }
                            // JComboBox mouse listener
                            Component source = (Component)e.getSource();
                            Dimension size = source.getSize();
                            Rectangle bounds = new Rectangle( 0, 0, size.width, size.height);
                            if ( !bounds.contains( e.getPoint() ) ) {
                                MouseEvent newEvent = convertMouseEvent( e );
                                Point location = newEvent.getPoint();
                                Rectangle r = new Rectangle();
                                list.computeVisibleRect( r );
                                if ( r.contains( location ) ) {
                                    if (comboBox.getSelectedIndex() != list.getSelectedIndex()) {
                                        comboBox.setSelectedIndex( list.getSelectedIndex() );
                                    } else {
                                        comboBox.getEditor().setItem( list.getSelectedValue() );
                                    }
                                }
                                comboBox.setPopupVisible(false);
                            }
                            hasEntered = false;
                            stopAutoScrolling();
                        }
                    };
                }
            };         
        }
    }
}

Test.java:

import java.awt.FlowLayout;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;

public class Test{
    public static void main(String[] args){
        SwingUtilities.invokeLater(new Runnable(){
            @Override
            public void run(){
                JComboBoxGoodBorder<String> combobox = new JComboBoxGoodBorder<String>(
                        new String[]{"aaaaaaaaaa","bbbbbbbb","ccccccccc"});

                JPanel panel = new JPanel(new FlowLayout(FlowLayout.CENTER, 10, 10));
                panel.add(combobox);

                JFrame frame = new JFrame("JComboBox Good Border");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.setContentPane(panel);
                frame.setSize(300, 300);
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }
}

Upvotes: 1

AJNeufeld
AJNeufeld

Reputation: 8695

The problem seems to be in:

class BasicComboPopup extends ... {

    private Handler getHandler() {
        if (handler == null) {
            handler = new Handler();
        }
        return handler;
    }

    private class Handler implements ... MouseListener ... {

        public void mouseReleased(MouseEvent e) {
            //...
            Component source = (Component)e.getSource();
            Dimension size = source.getSize();
            Rectangle bounds = new Rectangle( 0, 0, size.width - 1, size.height - 1 );
            if ( !bounds.contains( e.getPoint() ) ) {
                //...
                comboBox.setPopupVisible(false);
            }
        }
    }
}

By subtracting one from size.width and size.height, the mouse falls outside of the bounds of the arrow button, and the popup menu is hidden.

Fixing the issue is problematic. The Handler class is private, so we can't extend it, the getHandler() is private, so we can't override that in BasicComboPopup either.

One could extend MetalComboBoxUI and override createPopup() to return a custom ComboPopup, such as one extending BasicComboPopup but extending createMouseListener() to return a similar class to the Handler above, but without the subtract ones.

Oh, and do the same thing for each LAF you wish to support. Yuk.

Attacking the problem from the other direction, one could extend the MetalComboBoxButton (which is returned by e.getSource()) and override the getSize() method to return a dimension one pixel larger in both directions, when the menu is displayed. Of course, you'd still need to extend and override the MetalComboBoxUI to create and install this custom button.

And again, you'd need to do the same thing for each LAF you wish to support. Again, yuk.

Unfortunately, it does not appear that Swing has the needed hooks to easily override the needed functionalities, and has marked various classes as private internal implementation details, preventing their reuse (in order to prevent breakage later if they want to change the internals).

Upvotes: 7

Related Questions