Button click masks

October 11, 2008

Java button click mask

Button click masks (known as hot spots in the web world) offer a way to transform rectangular artwork into non-rectangular clickable or hover-able areas. It’s often easier to draw graphics in Adobe Fireworks or Photoshop rather than Java 2D – either for expedience in prototyping or because the graphics are too complex to draw programmatically.

In OS X 10.4 and before, Apple drew all of it’s graphics from bitmaps and masks, but has recently started introducing vector based graphics. iTunes, though, is still drawn from bitmaps and masks, so this technique isn’t yet a relic of the past (also note that much of OS X 10.5.x hasn’t yet started using the vector based drawing).

Implementing masking in Java is straight forward – we need only provide an image from which to sample the alpha value. A fully transparent pixel (a pixel with an alpha value of zero) indicates a non-clickable area.

We can either use the image itself as the mask, if the image’s non-clickable area is fully transparent, or we can supply a second image of identical proportions to represent the clickable region (as the example below does). Note that if a mask is supplied, it needn’t be anti-aliased, as the mask is used simply to sample the presence or absence of an alpha value.

Here’s how to implement a JButton that contains a non-rectangular clickable image – note that the key to this implementation is in the maskContains(int x, int y) method:

public class ImageButton extends JButton {

    // create a static index for the alpha channel of a raster image. i'm not exactly sure where
    // it's specified that red = channel 0, green = channel 1, blue = channel 2, and
    // alpha = channel 3, but this have been the values i've observed.
    private final int ALPHA_BAND = 3;

    // a buffered image representing the mask for this button.
    private final BufferedImage fMask;

    public ImageButton(Icon icon, Icon mask) {
        super(icon);

        if (icon == null) {
            throw new IllegalArgumentException("The icon cannot be null.");
        }
        if (mask == null) {
            throw new IllegalArgumentException("The mask cannot be null.");
        }
        checkIconMatchesMaskBounds(icon, mask);

        // remove the margins from this button, request that the content area not be filled, and
        // indicate that the border not be painted.
        setMargin(new Insets(0,0,0,0));
        setContentAreaFilled(false);
        setBorderPainted(false);

        // create the mask from the supplied icon.
        fMask = createMask(mask);
    }

    private BufferedImage createMask(Icon mask) {
        // create a BufferedImage to paint the mask into so that we can later retrieve pixel data
        // out of the image.
        BufferedImage image = new BufferedImage(
                mask.getIconWidth(),mask.getIconHeight(),BufferedImage.TYPE_INT_ARGB);

        Graphics graphics = image.getGraphics();
        mask.paintIcon(null,graphics,0,0);
        graphics.dispose();

        return image;
    }

    @Override
    public void setIcon(Icon defaultIcon) {
        super.setIcon(defaultIcon);
        // if this class has already been initialized, ensure that the new icon matches the bounds
        // of the current mask.
        if (fMask != null) {
            checkIconMatchesMaskBounds(defaultIcon, new ImageIcon(fMask));
        }
    }

    @Override
    public void updateUI() {
        // install the custom ui delegate to track the icon rectangle and answer the contains
        // method.
        setUI(new CustomButtonUI());
    }

    private static void checkIconMatchesMaskBounds(Icon icon, Icon mask) {
        if (mask.getIconWidth() != icon.getIconWidth()
                || mask.getIconHeight() != icon.getIconHeight()) {
            throw new IllegalArgumentException("The mask must be the same size as the icon.");
        }
    }

    // CustomButtonUI implementation so that we can maintain the icon rectangle.

    private class CustomButtonUI extends BasicButtonUI {

        private Rectangle fIconRect;

        private boolean maskContains(int x, int y) {
            // if the given point is within the bounds of the icon, then realtavize the given x,y
            // coordinates and sample the alpha value at that pixel. if the pixel at the given point
            // is completley transparent, then indicate that this button does not contain the point.
            return fIconRect != null && fIconRect.contains(x,y)
                    && fMask.getRaster().getSample(x - fIconRect.x,y - fIconRect.y,ALPHA_BAND) > 0;
        }

        @Override
        public boolean contains(JComponent c, int x, int y) {
            return maskContains(x,y);
        }

        @Override
        protected void paintIcon(Graphics g, JComponent c, Rectangle iconRect) {
            super.paintIcon(g, c, iconRect);
            // capture where the icon is being painted within the bounds of this button so we can
            // later use this information in the contains calculation.
            if (fIconRect == null || !fIconRect.equals(iconRect)) {
                fIconRect = new Rectangle(iconRect);    
            }
        }
    }
}

Try out the above button implementation with the following demo code:

Scroller
Scroller pressed
Scroller mask
public class DImageButton {

    public static void main(String[] args) {

        SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                Icon icon = new ImageIcon(
                        DImageButton.class.getResource("./icons/scroller.png"));
                Icon pressedIcon = new ImageIcon(
                        DImageButton.class.getResource("./icons/scroller_pressed.png"));
                Icon mask = new ImageIcon(
                        DImageButton.class.getResource("./icons/scroller_mask.png"));
                
                ImageButton button = new ImageButton(icon, mask);
                button.setPressedIcon(pressedIcon);

                JFrame frame = new JFrame();
                frame.add(button, BorderLayout.CENTER);
                frame.setSize(150, 100);
                frame.setLocationRelativeTo(null);
                frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
                frame.setVisible(true);
            }
        });
    }
}
Advertisements

10 Responses to “Button click masks”

  1. Eric Burke Says:

    You don’t want to call setUI from the constructor. Instead, you should override the updateUI method and set your custom UI delegate from that method. We made this mistake on many of our custom components and found some subtle bugs later on because of it. For example, if anyone ever calls SwingUtilities.updateComponentTreeUI, updateUI will be called again. If you only set your UI in the constructor, the UI will revert back to the one defined by Swing. Furthermore, we’ve seen cases where Windows users, for example, can start a remote desktop connection to their PC, and updateUI may be called. It’s also called when you do things like change the Windows theme from XP to Classic while your app is running. These factors are outside the control of your application code, so even if you never use the SwingUtilities method, the updateUI method may be called.

    Search for the “How to Write a Custom Swing Component” article by Kirill Grouchnikov, this shows the correct way to plug in custom UI delegates.

  2. Ken Says:

    Thanks for the tip Eric. I’ve updated the code accordingly.

  3. Pavan kumar Says:

    What changes would be needed if the images were vector based instead.

  4. Ken Says:

    Hi Pavan,

    If your drawing the vector based art into a BufferedImage before sending it to the screen, then no changes would be needed.

    How are you producing your vector art?

    -Ken


  5. […] Orr writes about image-based method to create non-rectangular active (clickable) areas for Swing buttons. While this […]

  6. Pavan kumar Says:

    Hi Ken,

    The vector images are produced using Adobe Illustrator.

    -Pavan

  7. Ken Says:

    Note that there was a bug in the original code I posted, which I’ve fixed in the blog entry above. CustomButtonUI.paintIcon was saving a refernece to the iconRect variable rather than creating a new rectangle from the values iconRect contained.

    This was problematic as the iconRect variable was statically defined in ButtonUI and reused to layout each button. Thus fIconRect would always have the icon bounds for the last painted button.

  8. Don Says:

    Hey Ken,

    I came across this article and I thought it interesting.
    I actually have implemented the specific look to mimic the iTunes scroll bars on windows just using Java2D. It is part of an iTunes clone demo. You can see it this week on Pushing Pixels.

    Don

  9. Ken Says:

    Hey Don,

    Interestingly enough, I was just reading Kirill’s interview with you this morning and gave your iTunes clone demo a try.

    How does the Java2D painting code tie in with your sdf files? I don’t see any Java source here.

    Neat work by the way!

    -Ken

  10. Don Says:

    Ken,

    Thanks, the engine reads the sdf files and creates the visuals defined in the files. In the specific case of the scrollbars, there is a custom UI class for the scrollbars. Line #6 of application.sdf tells the engine to use that ui for all scrollbars. The source for that UI is part of the source bundle on the site.

    Don


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: