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.
July 14, 2008 at 4:37 pm
[...] Orr writes about a custom implementation of placard button commonly found in Mac [...]
July 22, 2008 at 11:15 pm
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
July 22, 2008 at 11:19 pm
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
July 22, 2008 at 11:41 pm
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!