Skinning a scroll bar – Part 2

December 29, 2008

scrollbar_zipper1
In the first part of the “Skinning a scroll bar” series, I showed the ScrollBarOrientation class, which serves at the foundation of the skinning mechanism. In part 2, we’ll look at the ScrollBarSkin interface, which is responsible for building and laying out the scroll bar.

Lets start by taking a look at the interface:

/**
 * An interface that allows implementors to control the appearance of a 
 * {@link JScrollBar}.
 */
public interface ScrollBarSkin {

    /**
     * Called once at the begining of the skin's life cycle. Implementors 
     * should add components that will later be controlled in 
     * {@link #layoutTrackOnly(JScrollBar, ScrollBarOrientation)} and 
     * {@link #layoutEverything(JScrollBar, ScrollBarOrientation)}.
     *
     * @param scrollBar the {@link JScrollBar} that the skin will be painting.
     */
    void installComponents(JScrollBar scrollBar);

    /**
     * Called once at the begining of the skin's life cycle. Implementors should 
     * attach these mouse listners to the controls that decrement and increment 
     * the scroll bar's value.
     *
     * @param decrementMoustListener the {@link MouseListener} to be notified
     *        when a control is pressed that should result in the scroll bar's 
     *        value decrementing.
     * @param incrementMouseListener the {@link MouseListener} to be notified
     *        when a control is pressed that should result in the scroll bar's 
     *        value incrementing.
     */
    void installMouseListenersOnButtons(MouseListener decrementMoustListener,
                                        MouseListener incrementMouseListener);

    /**
     * Called when only the track should be laid out by the skin. This occurs when a
     * {@link JScrollBar} has been set to 
     * {@link javax.swing.JScrollPane#VERTICAL_SCROLLBAR_ALWAYS} or 
     * {@link javax.swing.JScrollPane#HORIZONTAL_SCROLLBAR_ALWAYS} and the
     * corresponding view is showing all the content. Note that there are, in fact, no
     * restrictions on what this method lays out. That is, if this skin wishes to layout 
     * more than just an empty track when there is no content to scroll, it may do so.
     *
     * @param scrollBar the {@link JScrollBar} that the skin is painting.
     * @param orientation the orientation of the scroll bar.
     */
    void layoutTrackOnly(JScrollBar scrollBar, ScrollBarOrientation orientation);

    /**
     * Called when the scroll bar should be laid out by the skin.
     *
     * @param scrollBar   the {@link JScrollBar} that the skin is painting.
     * @param orientation the orientation of the scroll bar.
     */
    void layoutEverything(JScrollBar scrollBar, ScrollBarOrientation orientation);

    /**
     * The smallest size that the scroll thumb can be.
     *
     * @return the mimimum size of the scroll thumb.
     */
    Dimension getMinimumThumbSize();

    /**
     * The preferred size of the painter, which will control the preferred size of the
     * associated {@link JScrollBar}. For vertical scroll bars, this value will drive the 
     * width. For horizontal scroll bars, this value will drive the height.
     *
     * @return the preferred size of this painter, and thus the corresponding 
     * {@code JScrollBar}.
     */
    Dimension getPreferredSize();

    /**
     * Gets the current bounds of the scroll thumb, which are controlled by the layout
     * provided by this skin.
     *
     * @return the current bounds of the scroll thumb.
     */
    Rectangle getScrollThumbBounds();

    /**
     * Sets the bounds of the scroll thumb. This method will be called, for example, 
     * when the associated {@link JScrollBar}'s {@link javax.swing.BoundedRangeModel}
     * is updated.
     *
     * @param bounds the new bounds of the scroll thumb.
     */
    void setScrollThumbBounds(Rectangle bounds);

    /**
     * Gets the current bounds of the track, which are controlled by the layout 
     * provided by this skin. Note that the bounds returned by this method should be
     * the actual scrollable bounds that the scroll thumb can move in. That is, this 
     * value should not just return the bounds of the associated {@link JScrollBar}, but
     * only the bounds that are valid for the scroll thumb to exist in.
     *
     * @return the current bounds of the track.
     */
    Rectangle getTrackBounds();
}

ScrollBarSkin‘s first responsibility is to install it’s components on the given scroll bar. This includes adding increment and decrement buttons (if those are desired). The interface implementor has full control over what is added – no buttons could be added or multiple increment and decrement buttons could be added (maybe a set at the top and bottom). This lets us shed the restriction imposed by BasicScrollBarUI, which expects there to be exactly one increment button and exactly one decrement button.

The ScrollBarSkin‘s installMouseListenerOnButtons method is called after installComponents is called. It is expected that the implementor will hook up the given MouseListeners to the increment and decrement mechanisms (usually buttons). Letting the implementor install the listeners allows for more natural support of multiple increment and decrement buttons, as well as support for non-button increment decrement facilities, like custom JComponents.

The ScrollBarSkin‘s next responsibility is to layout the scroll bar. There are two methods for laying out the scroll bar, layoutTrackOnly and layoutEverything. layoutTrackOnly is called when a scroll bar has been requested to always be shown, but no scroller is necessary. In this case, usually only the track is shown. layoutEverything is called when a scroll thumb is visible and all components within the scroll bar need to be laid out (the typical case).

The getMinimumThumbSize and getPreferredSize are essentially passthroughs from BasicScrollBarUI. These values are dependent on the orientation of the scroll bar, but are usually static and can thusly be passed in in the ScrollBarSkin‘s constructor.

The getScrollThumbBounds method will be queried for hit testing. The skin need not ever manually adjust the scroll thumb bounds, as the setScrollThumbBounds will be called when an update is necessary. Giving the skin responsibility for maintaing the scroll thumbs bounds is natural as it will likely want to add a component to the scroll bar to represent the scroll thumb.

Lastly, the ScrollBarSkin must respond to the getTrackBounds method. These bounds will be used for hit testing and will also house the scroll thumb. The returned bounds are relative to the parent scroll bar.

To help elucidate the usage of the ScrollBarSkin interface, let’s look at my first implementation of it: ButtonsTogetherScrollBarSkin. This implementation adds the following components to the given scroll bar:

  1. the top-cap (the little image at the top of the scroll bar)
  2. the increment and decrement buttons at the bottom
  3. the track area, which overlaps the top-cap and decrement button but sits underneath those components
  4. the scroll thumb container, which has exactly the same size and position as the track, but sits above the top-cap and decrement button
  5. the scroll thumb, which is added to the scroll thumb container

Here’s a visualization of the component layout:

scrollbar_layers

In the implementation listing below, you’ll note that each part of the scroll bar is a distinct component that is added to the JScrollBar in the installComponents method. These components are then laid out in the layoutEverything method. I found this approach to be conceptually simpler than the typical calculation based painting used in most ScrollBarUI implementations (typically, almost everything is done in the paint method). I tried to use standard Swing practices (the containment hierarchy) in order make it easier to understand the skin implementations.

I find the ButtonsTogetherScrollBarSkin to be very compact and understandable. I have sacrificed some reusability for the sake of clarity. I envision writing a similar skin entitled ButtonsApartScrollBarSkin which would not include a top-cap, and would put the decrement button at the top of the scroll bar. Though some parts of the implementation would be very similar, too much clarity is lost when abstracting out the commonalities. I think I’ve chose the right balance between understandability and reusability, as the below implementation is completely pluggable with different painters.

Here’s the ButtonsTogetherScrollBarSkin implementation, which will be available in the next release (0.9.4) of Mac Widgets for Java. Note that the below implementation uses some custom classes from Mac Widgets for Java, which you can find here.

/**
 * A {@link ScrollBarSkin} with the buttons placed at the bottom or right of the scroll 
 * bar.
 */
public class ButtonsTogetherScrollBarSkin implements ScrollBarSkin {

    private JComponent fCap;
    private JComponent fThumbContainer = new JPanel();
    private EPPanel fThumb = new EPPanel();
    private EPPanel fTrack = new EPPanel();
    private AbstractButton fDecrementButton;
    private AbstractButton fIncrementButton;
    private Dimension fMinimumThumbSize;
    private int fScrollBarCapRecess;
    private int fDecrementButtonTrackRecess;
    private final Dimension fPreferredSize;
    private static final Rectangle EMPTY_BOUNDS = new Rectangle(0, 0, 0, 0);

   /**
     * Creates a {@code ButtonsTogetherScrollBarSkin} using the given parameters.
     *
     * @param scrollBarCap the component to draw adjacent to the scroll tracks 
     *         minimum value side.
     * @param decrementButton the button to cause a decrement in the scroll bar to 
     *         occur.
     * @param incrementButton the button to cause a increment in the scroll bar to 
     *         occur.
     * @param trackPainter the {@link Painter} to use to paint the track.
     * @param scrollThumbPainter the {@link Painter} to use to paint the scroll thumb.
     * @param scrollBarCapRecess the number of pixels to allow the scrollbar to "recess"
     * into the scroll bar cap. this is useful when using scroll bars with rounded ends.
     * @param decrementButtonRecess the number of pixels to allow the scrollbar to 
     *         "recess" into the decrement button. this is useful when using scroll bars with
     *         rounded ends.
     * @param minimumThumbSize the minimum size that the scroll thumb can be.
     * @param preferredSize the preferred size of this skin.
     */
    public ButtonsTogetherScrollBarSkin(
            JComponent scrollBarCap, AbstractButton decrementButton, 
            AbstractButton incrementButton, Painter trackPainter, 
            Painter scrollThumbPainter, int scrollBarCapRecess, 
            int decrementButtonRecess, Dimension minimumThumbSize,
            Dimension preferredSize) {

        fCap = scrollBarCap;
        fDecrementButton = decrementButton;
        fIncrementButton = incrementButton;
        fTrack.setBackgroundPainter(trackPainter);
        fThumb.setBackgroundPainter(scrollThumbPainter);
        fScrollBarCapRecess = scrollBarCapRecess;
        fDecrementButtonTrackRecess = decrementButtonRecess;
        fMinimumThumbSize = minimumThumbSize;
        fPreferredSize = preferredSize;

        fThumbContainer.setLayout(null);
        fThumbContainer.setOpaque(false);

        fThumb.setOpaque(false);
    }

    // ScrollBarSkin implementation. //////////////////////////////////////////////

    public Dimension getMinimumThumbSize() {
        return fMinimumThumbSize;
    }

    public Dimension getPreferredSize() {
        return fPreferredSize;
    }

    public Rectangle getScrollThumbBounds() {
        return fThumb.getBounds();
    }

    public Rectangle getTrackBounds() {
        return fThumbContainer.getBounds();
    }

    public void installComponents(JScrollBar scrollBar) {
        // add the components to the scrollbar. order matters here - components added first, are
        // drawn last (on top).
        scrollBar.add(fThumbContainer);
        scrollBar.add(fCap);
        scrollBar.add(fIncrementButton);
        scrollBar.add(fDecrementButton);
        scrollBar.add(fTrack);

        // add the actual scroller thumb (the component that will be painted) to the scroller thumb
        // container.
        fThumbContainer.add(fThumb);
    }

    public void layoutTrackOnly(JScrollBar scrollBar, ScrollBarOrientation orientation) {
        fCap.setBounds(EMPTY_BOUNDS);
        fIncrementButton.setBounds(EMPTY_BOUNDS);
        fDecrementButton.setBounds(EMPTY_BOUNDS);
        fThumbContainer.setBounds(EMPTY_BOUNDS);

        Rectangle r = scrollBar.getBounds();
        fTrack.setBounds(0, 0, r.width, r.height);
    }

    public void layoutEverything(JScrollBar scrollBar, ScrollBarOrientation orientation) {
        // 1) layout the scroll bar cap.
        int capLength = orientation.getLength(fCap.getPreferredSize());
        fCap.setBounds(orientation.createBounds(scrollBar, 0, capLength));

        // 2) layout the scrollbar buttons.
        int incrementButtonHeight = orientation.getLength(fIncrementButton.getPreferredSize());
        int decrementButtonHeight = orientation.getLength(fDecrementButton.getPreferredSize());
        int scrollBarLength = orientation.getLength(scrollBar.getSize());
        int incrementButtonPosition = scrollBarLength - incrementButtonHeight;
        int decrementButtonPosition = incrementButtonPosition - decrementButtonHeight;

        fIncrementButton.setBounds(
                orientation.createBounds(scrollBar, incrementButtonPosition, incrementButtonHeight));
        fDecrementButton.setBounds(
                orientation.createBounds(scrollBar, decrementButtonPosition, decrementButtonHeight));

        // 3) layout the track and the scroller thumb container.
        // start the track and the scroller bar container overlapping the top scroller cap, if a
        // top cap recess has been specified. this handles a top cap that isn't square and is
        // intended to receive part of the scroller thumb.
        int trackAndThumbPosition = capLength - fScrollBarCapRecess;
        // the height of the track and scroll bar container should be slightly greater than that of
        // the empty space between the top cap, and the decrement button, if recesses have been
        // specified. that is, the track and scroll bar container will overlap the top cap and
        // decrement buttons if a non-zero recess has been specified.
        int trackLength = decrementButtonPosition + fDecrementButtonTrackRecess - trackAndThumbPosition;
        Rectangle trackAndThumbBounds =
                orientation.createBounds(scrollBar, trackAndThumbPosition, trackLength);
        fTrack.setBounds(trackAndThumbBounds);
        fThumbContainer.setBounds(trackAndThumbBounds);
    }

    public void installMouseListenersOnButtons(MouseListener decrementMoustListener,
                                               MouseListener incrementMouseListener) {
        fDecrementButton.addMouseListener(decrementMoustListener);
        fIncrementButton.addMouseListener(incrementMouseListener);
    }

    public void setScrollThumbBounds(Rectangle bounds) {
        fThumb.setBounds(bounds);
    }

}

One Response to “Skinning a scroll bar – Part 2”


  1. […] Ken at Exploding Pixels continues his series on skinning the scroll bar in Swing applications. This is part three, and whilst quite code-heavy, it shows how to plug into the Swing UI delegation framework via an extension of BasicScrollBarUI. For more context on this series, see parts one and two. […]


Leave a comment