Building a placard style PopupButton

July 11, 2008

Placard style button’s are a nice way to add JComboBox-like widgets to your application, but with less of the visual baggage that comes with a standard combo box. Apple briefly talks about placard style components here.

You might be thinking, “This should be easy, we’ll just override the paint method in JComboBox“. Unfortunately, its not quite that easy. On Mac, that might work reasonably well, though we’d have to layout and draw the the text and icon with custom code.

However, on Windows we would lose the popup-style behavior – that is, the selected item would not be displayed directly over the combo button. The logic that controls where the popup is displayed, is controlled in an implementation of ComboPopup, a member variable of BasicComboBoxUI.

I also briefly looked at trying to extend BasicComboBoxUI, which would have also required implementing BasicComboPopup (in order to control the popup placement). I gave up on this when I discovered bug 474094, which always causes the first item in a popup to be selected (marked as fixed, though it still exists on Mac).

Its all rather complicated. Thus, I decided it was simpler and easier to create a button with a right aligned icon, and do the popup showing and placement using an ActionListener.

Below you’ll find the PopupButton component, which behaves the same on all platforms (though I’ve only used it on the Mac). The component does not paint any background, as it is assumed that this will be provided by it’s container. Also note that the custom button below is needed to provide left aligned text with a right aligned button – you can’t normally have two different alignments for the icon and text.

The icon referenced in the code below can be found here.

public class PopupButton {

    private static final ImageIcon ARROWS_ICON = new ImageIcon(
            PopupButton.class.getResource(
                    "../images/up_down_arrows_small.png"));
    private JButton fButton = new CustomJButton();
    private List fPopupItemsList;
    private E fSelectedItem;
    private JPopupMenu fPopupMenu = new JPopupMenu();

    public PopupButton(E selectedItem, List popupItemsList) {
        if (selectedItem == null) {
            throw new IllegalArgumentException("The selected item cannot be " +
                    "null.");
        }
        if (popupItemsList == null) {
            throw new IllegalArgumentException("The list of items to add to" +
                    "the popup menu cannot be null.");
        }
        if (!popupItemsList.contains(selectedItem)) {
            throw new IllegalArgumentException("The item to select is not in" +
                    "the given list of items.");
        }

        fSelectedItem = selectedItem;
        fPopupItemsList = popupItemsList;
        init();
    }

    private void init() {
        Font oldPopupMenuFont = fButton.getFont();
        Font newPopupMenuFont = oldPopupMenuFont.deriveFont(
                oldPopupMenuFont.getSize() - 2.0f);

        ButtonGroup buttonGroup = new ButtonGroup();

        // add the given items to the popup menu.
        for (E item : fPopupItemsList) {
            JMenuItem menuItem = new JCheckBoxMenuItem(item.toString());
            menuItem.setFont(newPopupMenuFont);
            menuItem.addActionListener(createMenuItemListener(item));
            buttonGroup.add(menuItem);
            fPopupMenu.add(menuItem);
        }

        // set the selected item now that we've filled the popup menu with menu
        // items.
        setSelectedItem(fSelectedItem);

        fPopupMenu.pack();

        Font oldButtonFont = fButton.getFont();
        Font newButtonFont = oldButtonFont.deriveFont(
                oldButtonFont.getSize() - 2.0f);

        fButton.setFont(newButtonFont);
        fButton.setContentAreaFilled(false);
        fButton.setHorizontalAlignment(SwingConstants.LEFT);
        fButton.setBorder(BorderFactory.createEmptyBorder(2,4,2,
                ARROWS_ICON.getIconWidth() + 10));
        fButton.addActionListener(createButtonListener());

        // figure out how big the button should be. we're using the menu to help
        // us determine the width.
        Border border = UIManager.getBorder("MenuItem.border");
        Insets insets = border.getBorderInsets(new JMenuItem());
        int width = fPopupMenu.getPreferredSize().width - insets.left - insets.right;
        int height = fButton.getPreferredSize().height;

        fButton.setPreferredSize(new Dimension(width,height));
    }

    public JComponent getComponent() {
        return fButton;
    }

    private void setSelectedItem(E itemToSelect) {
        fSelectedItem = itemToSelect;
        ((JMenuItem)fPopupMenu.getComponent(
                fPopupItemsList.indexOf(fSelectedItem))).setSelected(true);
        fButton.setText(fSelectedItem.toString());
    }

    private ActionListener createButtonListener() {
        return new ActionListener() {
            public void actionPerformed(ActionEvent e) {

                // grab the right most location of the button.
                int buttonRightX = fButton.getWidth();

                // figure out how the height of a menu item.
                Insets insets = fPopupMenu.getInsets();
                int itemHeight_px = (fPopupMenu.getPreferredSize().height
                        - insets.top - insets.bottom)/fPopupItemsList.size();

                // calculate the x and y value at which to place the popup menu.
                // by default, this will place the selected menu item in the
                // popup item directly over the button.
                int x = buttonRightX - fPopupMenu.getPreferredSize().width;
                int y = fButton.getY() - insets.top
                        - (fPopupItemsList.indexOf(fSelectedItem)*itemHeight_px);

                // do a cursory check to make sure we're not placing the popup
                // off the bottom of the screen. note that Java on Mac won't
                // let the popup show up off screen no matter where you place it.
                Dimension size   = Toolkit.getDefaultToolkit().getScreenSize();
                Point bottomOfMenuOnScreen = new Point(0,y + fPopupMenu.getPreferredSize().height);
                SwingUtilities.convertPointToScreen(bottomOfMenuOnScreen, fButton);
                if (bottomOfMenuOnScreen.y > size.height) {
                    y = fButton.getHeight() - fPopupMenu.getPreferredSize().height;
                }

                // set the selected item in the popup menu.
                fPopupMenu.setSelected(fPopupMenu.getComponent(
                        fPopupItemsList.indexOf(fSelectedItem)));

                fPopupMenu.show(fButton, x, y);

                // force the correct item to be shown as selected. this is a
                // work around for Java bug 4740942, which has been fixed by
                // Sun, but not by Apple.
                int index = fPopupMenu.getSelectionModel().getSelectedIndex();
                MenuElement[] menuPath = new MenuElement[2];
                menuPath[0] = fPopupMenu;
                menuPath[1] = fPopupMenu.getSubElements()[index];
                MenuSelectionManager.defaultManager().setSelectedPath(menuPath);

            }
        };
    }

    private ActionListener createMenuItemListener(final E item) {
        return new ActionListener() {
            public void actionPerformed(ActionEvent e) {
                setSelectedItem(item);
            }
        };
    }

    ///////////////////////////////////////////////////////////////////////////
    // Custom JButton.
    ///////////////////////////////////////////////////////////////////////////

    private static class CustomJButton extends JButton {
        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);

            int x = getWidth() - ARROWS_ICON.getIconWidth() - 4;
            int y = getHeight()/2 - ARROWS_ICON.getIconHeight()/2;

            g.drawImage(ARROWS_ICON.getImage(), x, y, null);
        }
    }
}

Note that I’ve haven’t vigilantly checked the bounds of the popup to ensure it fits on the screen, nor have I used a scroll pane in the event that there are too many items to fit on screen – I’ve left that as an exercise for the reader.

Advertisements

8 Responses to “Building a placard style PopupButton”


  1. […] Orr writes about a custom implementation of placard button commonly found in Mac […]


  2. Hey Ken,

    nice post! Your blog is a great source of inspiration. I was building something quite similar the other day (though I’m using a custom button UI) and could use some bits of information to improve my implementation. I will definitely check out your unified toolbar examples. It’s great to see others care about the details. Keep your posts comming!

    Cheers,

    Marco

  3. Ken Says:

    Thanks Marco! Glad these posts are proving useful for you. To me, user interface is everything. I’d like to see Java UIs some day lose their stigma of being clunky and out of place.

    -Ken


  4. I have my doubts when it comes to the Java desktop future, but I’m all with you. From a developer’s point of view, Swing is a very nice toolkit and especially on OS X, it looks quite at home. Kudos to all the hard working engineers who make this happen!

  5. theocas.net Says:

    What did you add this component to? A SourceListBar or whatever it’s called? I want to place it below my syntax-highlighting textarea so the user can choose the syntax highlighter.

  6. Ken Says:

    It’s been in there for a while, not exactly sure when I added it. You can use it by creating a ComponentBottomBar:

    http://code.google.com/p/macwidgets/source/browse/trunk/source/com/explodingpixels/macwidgets/ComponentBottomBar.java

    • theocas.net Says:

      Thanks, that was the component I needed. I managed to implement one of those lines next to the popupbutton by using a JSeperator, but what way did you use? I’d like to do it so it looks as Mac-Like as possible, but there is a tiny space between the two components. Should it be like that?


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: