Skinning a scroll bar – Part 3

January 18, 2009

In part one we looked at the ScrollBarOrientation class, where we saw how abstracting away the actual scrolling dimension would help remove code redundancy. In part two we looked at the actual ScrollBarSkin interface and a sample implementation where we saw how a scroll bar could be layed out and painted.

In this last part of the Skinning a scroll bar series, you’ll see how we actually plug into the Swing UI delegation framework via an extension of BasicScrollBarUI. I realize there is a lot of code listed below, almost 400 lines, so peruse it at your leisure, or take it whole sale with the other two parts of the series and simply use it. Most of it is self explanatory, and much of it is a simplification of what’s in BasicScrollBarUI. All of this code is part of Mac Widgets for Java 0.9.3 and will be updated in 0.9.4.

Finally, a fully skinnable scrollbar.

/**
 * An implementation of {@link javax.swing.plaf.ScrollBarUI} that supports 
 * dynamic skinning. Painting is delegated to a {@link ScrollBarSkin}.
 */
public class SkinnableScrollBarUI extends BasicScrollBarUI {

    private ScrollBarSkin fSkin;
    private ScrollBarOrientation fOrientation;
    private final ScrollBarSkinProvider fScrollBarSkinProvider;

    /**
     * Creates a {@code SkinnableScrollBarUI} that query the given 
     * {@link ScrollBarSkinProvider} in order to get the {@link ScrollBarSkin} 
     * during the installation of this UI delegate.
     * @param scrollBarSkinProvider the provider of the {@code ScrollBarSkin}.
     */
    public SkinnableScrollBarUI(ScrollBarSkinProvider scrollBarSkinProvider) {
        fScrollBarSkinProvider = scrollBarSkinProvider;
    }

    @Override
    public void installUI(JComponent c) {
        JScrollBar scrollBar = (JScrollBar) c;
        // convert the Swing scroll bar orientation to the type-safe ScrollBarOrientation.
        fOrientation = ScrollBarOrientation.getOrientation(scrollBar.getOrientation());
        fSkin = fScrollBarSkinProvider.provideSkin(fOrientation);
        super.installUI(c);
    }

    @Override
    protected void installComponents() {
        // delegate to the ScrollBarSkin.
        fSkin.installComponents(scrollbar);
    }

    @Override
    protected void installListeners() {
        super.installListeners();
        // give the ScrollBarSkin the decrement and increment MouseListeners so that 
        // it may attach them to the appropriate components.
        fSkin.installMouseListenersOnButtons(new CustomArrowButtonListener(-1),
                new CustomArrowButtonListener(1));
        // repaint the scrollbar when the focus state of the parent window changes.
        WindowUtils.installJComponentRepainterOnWindowFocusChanged(scrollbar);
    }

    @Override
    public void layoutContainer(Container scrollbarContainer) {
        if (isDragging) {
            // do nothing.
        } else if (isAllContentVisible(scrollbar)) {
            // if all the content is visible, and thus no scrollbar is necssary, tell the 
            // ScrollBarSkin to layout only the track.
            fSkin.layoutTrackOnly(scrollbar, fOrientation);
            updateThumbBoundsFromScrollBarValue();
        } else {
            // tell the ScrollBarSkin to layout the entire scrollbar. once that's complete, 
            // update the bounds of the visible scroll thumb from the models value.
            fSkin.layoutEverything(scrollbar, fOrientation);
            updateThumbBoundsFromScrollBarValue();
        }
    }

    @Override
    protected Dimension getMinimumThumbSize() {
        // delegate to the ScrollBarSkin.
        return fSkin.getMinimumThumbSize();
    }

    @Override
    public Dimension getPreferredSize(JComponent c) {
        // delegate to the ScrollBarSkin.
        return fSkin.getPreferredSize();
    }

    @Override
    protected Rectangle getThumbBounds() {
        // delegate to the ScrollBarSkin.
        return fSkin.getScrollThumbBounds();
    }

    /**
     * Convienence method that simply breaks apart the given Rectangle into its 
     * primitives and calls {@link #setThumbBounds(int, int, int, int)}.
     */
    private void setThumbBounds(Rectangle thumbBounds) {
        setThumbBounds(thumbBounds.x, thumbBounds.y, thumbBounds.width, 
                thumbBounds.height);
    }

    @Override
    protected void setThumbBounds(int x, int y, int width, int height) {
        // delegate to the ScrollBarSkin.
        fSkin.setScrollThumbBounds(new Rectangle(x, y, width, height));
    }

    @Override
    protected Rectangle getTrackBounds() {
        // delegate to the ScrollBarSkin.
        return fSkin.getTrackBounds();
    }

    @Override
    protected void paintIncreaseHighlight(Graphics g) {
        // do nothing - not supported.
    }

    @Override
    protected void paintDecreaseHighlight(Graphics g) {
        // do nothing - not supported.
    }

    /**
     * Sets the scroll thumb bounds based on the track size, the total size of viewable 
     * area and the amount of content that is currently visible. 
     */
    private void updateThumbBoundsFromScrollBarValue() {
        // most of the below logic was lifted from BasicScrollBarUI. the logic here has been 
        // greatly simplified here through the use of the ScrollBarOrientation.
        
        float min = scrollbar.getMinimum();
        float extent = scrollbar.getVisibleAmount();
        float range = scrollbar.getMaximum() - min;
        float value = scrollbar.getValue();

        int trackSize = fOrientation.getLength(fSkin.getTrackBounds().getSize());
        int thumbLength = (int) (trackSize * (extent / range));

        int minimumThumbLength = fOrientation.getLength(getMinimumThumbSize());
        thumbLength = Math.max(thumbLength, minimumThumbLength);

        float thumbRange = trackSize - thumbLength;
        int thumbPosition = (int) (0.5f + (thumbRange * ((value - min) / (range - extent))));

        // tell the ScrollBarSkin how big the scroll thumb should be.
        fSkin.setScrollThumbBounds(
                fOrientation.createBounds(scrollbar, thumbPosition, thumbLength));
    }

    /**
     * Figures out where the scroll thumb should be based on the given MouseEvent and 
     * moves the  thumb to that new location.
     */
    private void updateThumbBoundsAndScrollBarValueFromMouseEvent(MouseEvent event, int offset) {
        int mouseLocation = adjustMousePosition(event.getPoint(), offset);
        updateThumbBoundsFromMouseLocation(mouseLocation);
        updateScrollBarValueFromMouseLocation(mouseLocation);
    }

    /**
     * Moves the visible scroll thumb to the given location.
     */
    private void updateThumbBoundsFromMouseLocation(int mouseLocation) {
        Dimension thumbSize = getThumbBounds().getSize();
        Dimension trackSize = getTrackBounds().getSize();

        // set the visible thumb bounds. this smoothly tracks where the user has the mouse.
        // when they release the mouse, the actual scroll thumb position will be updated to
        // reflect the exact scroll view window.
        int thumbMaxPossiblePosition =
                fOrientation.getLength(trackSize) - fOrientation.getLength(thumbSize);
        int thumbPosition = Math.min(thumbMaxPossiblePosition, Math.max(0, mouseLocation));
        // update the scroll thumb's position.
        setThumbBounds(fOrientation.updateBoundsPosition(getThumbBounds(), thumbPosition));
    }

    /**
     * Updaates the scrollbar model based on the given mouse location.
     */
    private void updateScrollBarValueFromMouseLocation(int mouseLocation) {
        // most of the below logic was lifted from BasicScrollBarUI. the logic here has been 
        // greatly simplified here through the use of the ScrollBarOrientation.
        
        BoundedRangeModel model = scrollbar.getModel();
        Rectangle thumbBounds = getThumbBounds();
        Rectangle trackBounds = getTrackBounds();

        // calculate what the value of the scrollbar should be.
        int minimumPossibleThumbPosition = fOrientation.getPosition(trackBounds.getLocation());
        int maximumPossibleThumbPosition = getMaximumPossibleThumbPosition(trackBounds, thumbBounds);
        int actualThumbPosition = Math.min(maximumPossibleThumbPosition,
                Math.max(minimumPossibleThumbPosition, mouseLocation));

        // calculate the new value for the scroll bar (the top of the scroll thumb) based
        // on the dragged location.
        float valueMax = model.getMaximum() - model.getExtent();
        float valueRange = valueMax - model.getMinimum();
        float thumbValue = actualThumbPosition - minimumPossibleThumbPosition;
        float thumbRange = maximumPossibleThumbPosition - minimumPossibleThumbPosition;
        int value = (int) Math.ceil((thumbValue / thumbRange) * valueRange);

        scrollbar.setValue(value + model.getMinimum());
    }

    /**
     * Gets the maximum possible thumb position.
     */
    private int getMaximumPossibleThumbPosition(Rectangle trackBounds, Rectangle thumbBounds) {
        int trackStartPosition = fOrientation.getPosition(trackBounds.getLocation());
        int trackLength = fOrientation.getLength(trackBounds.getSize());
        int thumbLength = fOrientation.getLength(thumbBounds.getSize());
        return trackStartPosition + trackLength - thumbLength;
    }

    private int adjustMousePosition(Point mousePoint, int offset) {
        return fOrientation.getPosition(mousePoint) - offset;
    }

    /**
     * True if the given point is before the start of the scroll thumb.
     */
    private boolean isPointBeforeScrollThumb(Point point) {
        int mousePosition = fOrientation.getPosition(point);
        int thumbPosition = fOrientation.getPosition(getThumbBounds().getLocation());
        return mousePosition  thumbPosition;
    }

    private int getDirectionToMoveThumb(Point mousePoint) {
        return isPointBeforeScrollThumb(mousePoint) ? -1 : 1;
    }

    @Override
    protected TrackListener createTrackListener() {
        return new SkinnableTrackListener();
    }

    /**
     * True if the all the content that the scrollbar is scrolling for is currently visible.
     */
    private static boolean isAllContentVisible(JScrollBar scrollBar) {
        float extent = scrollBar.getVisibleAmount();
        float range = scrollBar.getMaximum() - scrollBar.getMinimum();
        return extent == 0.0 || extent / range == 1.0;
    }

    // SkinnableTrackListener implementation. //////////////////////////////////////////

    private class SkinnableTrackListener extends TrackListener {

        private Point iMousePoint = new Point();

        @Override
        public void mousePressed(MouseEvent event) {
            if (shouldHandleMousePressed(event)) {
                doMousePressed(event);
            }
        }

        @Override
        public void mouseDragged(MouseEvent event) {
            if (shouldHandleMouseDragged(event)) {
                doMouseDragged(event);
            }
        }

        private void startScrollTimerIfNecessary() {
            if (isPointBeforeScrollThumb(iMousePoint) || isPointAfterScrollThumb(iMousePoint)) {
                scrollTimer.start();
            }
        }

        private void captureCurrentMousePosition(MouseEvent event) {
            assert event.getSource() == scrollbar
                    : "The listener should be registered with the scrollbar for mouse events.";
            currentMouseX = event.getX();
            currentMouseY = event.getY();
            iMousePoint.x = currentMouseX;
            iMousePoint.y = currentMouseY;
        }

        private boolean isIgnorableMiddleMousePress(MouseEvent event) {
            return SwingUtilities.isMiddleMouseButton(event) && !getSupportsAbsolutePositioning();
        }

        private boolean shouldHandleMousePressed(MouseEvent event) {
            return !isIgnorableMiddleMousePress(event) && !SwingUtilities.isRightMouseButton(event)
                    && scrollbar.isEnabled();
        }

        private boolean shouldHandleMouseDragged(MouseEvent event) {
            return !isIgnorableMiddleMousePress(event) && !SwingUtilities.isRightMouseButton(event)
                    && scrollbar.isEnabled() && !getThumbBounds().isEmpty();
        }

        private void doMousePressed(MouseEvent event) {
            scrollbar.setValueIsAdjusting(true);
            captureCurrentMousePosition(event);

            if (getThumbBounds().contains(iMousePoint)) {
                doMousePressedOnThumb();
            } else
            if (getSupportsAbsolutePositioning() && SwingUtilities.isMiddleMouseButton(event)) {
                doMiddleMouseButtonPressedOnTrack(event);
            } else if (getTrackBounds().contains(iMousePoint)) {
                doMousePressedOnTrack();
            }
        }

        private void doMousePressedOnThumb() {
            offset = fOrientation.getPosition(iMousePoint) 
                    - fOrientation.getPosition(getThumbBounds().getLocation());
            isDragging = true;
        }

        private void doMiddleMouseButtonPressedOnTrack(MouseEvent event) {
            offset = fOrientation.getLength(getThumbBounds().getSize()) / 2;
            isDragging = true;
            updateThumbBoundsAndScrollBarValueFromMouseEvent(event, offset);
        }

        private void doMousePressedOnTrack() {
            isDragging = false;
            int direction = getDirectionToMoveThumb(iMousePoint);
            scrollByBlock(direction);
            scrollTimer.stop();
            scrollListener.setDirection(direction);
            scrollListener.setScrollByBlock(true);
            startScrollTimerIfNecessary();
        }

        private void doMouseDragged(MouseEvent event) {
            if (isDragging) {
                updateThumbBoundsAndScrollBarValueFromMouseEvent(event, offset);
            } else {
                captureCurrentMousePosition(event);
                startScrollTimerIfNecessary();
            }
        }
    }

    // IncrementButtonListener implementation. //////////////////////////////////////////

    protected class CustomArrowButtonListener extends ArrowButtonListener {

        private final int iScrollDirection;

        private CustomArrowButtonListener(int scrollDirection) {
            iScrollDirection = scrollDirection;
        }

        public void mousePressed(MouseEvent e) {
            if (scrollbar.isEnabled() && SwingUtilities.isLeftMouseButton(e)) {
                scrollByUnit(iScrollDirection);
                scrollTimer.stop();
                scrollListener.setDirection(iScrollDirection);
                scrollListener.setScrollByBlock(false);
                scrollTimer.start();
            }
        }

        public void mouseReleased(MouseEvent e) {
            scrollTimer.stop();
            scrollbar.setValueIsAdjusting(false);
        }
    }

    // An interface for providing a ScrollBarSkin. //////////////////////////////////////

    public interface ScrollBarSkinProvider {
        ScrollBarSkin provideSkin(ScrollBarOrientation orientation);
    }

}

7 Responses to “Skinning a scroll bar – Part 3”


  1. […] 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 […]

  2. Kieran Says:

    Hey Ken,

    I’m very interested in using this implementation in a project on which I’m working, but I’ve run into an issue with the rendering/interaction of the thumb. If you’ve got time to take a look, I’d appreciate it.

    Kieran
    khayes@qtsi.com

    • Ken Says:

      Hi Kieran,

      What is the problem you are having?

      -Ken

      • Kieran Says:

        In my app I am drawing a horizontal scrollbar on the bottom of the screen. It appears as though there is an issue with offsetting the thumb based on the size of the top-cap. The image that I have for the thumb (just an orange rectangle) is being drawn in the correct position, but I can see something drawn underneath it (perhaps the thumb container?) that is slightly to the left. It is just a gray rectangle. And the mouse interaction is being based off of that gray rectangle, not the image of my thumb. For instance, if I click on the right half of my orange rectangle the thumb will jump to the right, because it is outside the space of the gray rectangle.

        One thing I noticed was that in the updateThumbBoundsFromScrollBarValue function, the thumbPosition that is used in the fOrientation.createBounds() call has a value of zero when the application first starts. Should this value perhaps be offset by the width of the top-cap?

        Thanks for the quick response.

        -Kieran

      • Kieran Says:

        It may also be helpful to know, I am using the SkinnableScrollBarUI, with minimum modifications to the code provided.

      • Kieran Says:

        … and the ButtonsTogetherScrollBarSkin class.

  3. Andrew Says:

    It may be helpful if you told us how to implement this example. I had a long peak at your code for both MacWidgets and Seaglass and came away feeling like I was looking at structured spaghetti. Far to much methods calling methods calling methods (I call this logical fragmentation), some of which appeared to have little or no recognisable function to make writing the method worthwhile. I appreciate that some of this is down to the structure of Swing, but It makes it very hard to understand how your great and extremely valuable contributions to the world of Java Look and Feel work.


Leave a comment