Search code examples
javajavafxscrolljavafx-8scrollview

Creating a simple custom scroll view


This is somehow a general question about scroll views, I want to learn the basics of a scroll view and how to implement one on my own because it is essential as part of most dynamic GUI. You may ask, Why not simply use the one provided by the platform? My answer would be, aside from it's fun to learn new stuff, it's nice to see things customized the way you want it to be. Simply put, I want to create just a simple custom scroll view and try to understand how it is working behind the scene.

Moving on, what I currently have to present here is just the simplest example of the UI I came up with. Basically, it is a Pane that serves as the viewport for the entire content and contains a single vertical scrollbar on its right edge, just like normal scroll views, but I just added a little transition which animates the scrollbar's width on mouse hover.

ScrollContainer class

import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;

/**
 * ScrollContainer
 *
 * A container for scrolling large content.
 */
public class ScrollContainer extends Pane {

    private VerticalScrollBar scrollBar; // The scrollbar used for scrolling over the content from viewport
    private Rectangle rectangle; // Object for clipping the viewport to restrict overflowing content

    /**
     * Construct a new ScrollContainer
     */
    public ScrollContainer() {
        super();

        scrollBar = new VerticalScrollBar();
        getChildren().add(scrollBar);

        rectangle = new Rectangle();
        rectangle.widthProperty().bind(widthProperty());
        rectangle.heightProperty().bind(heightProperty());
        setClip(rectangle);
    }

    @Override
    protected void layoutChildren() {
        super.layoutChildren();

        // Layout scrollbar to the edge of container, and fit the viewport's height as well
        scrollBar.resize(scrollBar.getWidth(), getHeight());
        scrollBar.setLayoutX(getWidth() - scrollBar.getWidth());
    }

    /**
     * VerticalScrollBar
     */
    private class VerticalScrollBar extends Region {
        // Temporary scrubber's height.
        // TODO: Figure out the computation for scrubber's height.
        private static final double SCRUBBER_LENGTH = 100;

        private double initialY; // Initial mouse position when dragging the scrubber
        private Timeline widthTransition; // Transforms width of scrollbar on hover
        private Region scrubber; // Indicator about the content's visible area

        /**
         * Construct a new VerticalScrollBar
         */
        private VerticalScrollBar() {
            super();

            // Scrollbar's initial width
            setPrefWidth(7);

            widthTransition = new Timeline(
                    new KeyFrame(Duration.ZERO, new KeyValue(prefWidthProperty(), 7)),
                    new KeyFrame(Duration.millis(500), new KeyValue(prefWidthProperty(), 14))
            );

            scrubber = new Region();
            scrubber.setStyle("-fx-background-color: rgba(0,0,0, 0.25)");
            scrubber.setOnMousePressed(event -> initialY = event.getY());
            scrubber.setOnMouseDragged(event -> {
                // Moves the scrubber vertically within the scrollbar.
                // TODO: Figure out the proper way of handling scrubber movement, an onScroll mouse wheel function, ect.
                double initialScrollY = event.getSceneY() - initialY;
                double maxScrollY = getHeight() - SCRUBBER_LENGTH;
                double minScrollY = 0;
                if (initialScrollY >= minScrollY && initialScrollY <= maxScrollY) {
                    scrubber.setTranslateY(initialScrollY);
                }
            });
            getChildren().add(scrubber);

            // Animate scrollbar's width on mouse enter and exit
            setOnMouseEntered(event -> {
                widthTransition.setRate(1);
                widthTransition.play();
            });
            setOnMouseExited(event -> {
                widthTransition.setRate(-1);
                widthTransition.play();
            });
        }

        @Override
        protected void layoutChildren() {
            super.layoutChildren();

            // Layout scrubber to fit the scrollbar's width
            scrubber.resize(getWidth(), SCRUBBER_LENGTH);
        }
    }
}

Main class

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class Main extends Application {

    @Override
    public void start(Stage primaryStage) {
        Label lorem = new Label();
        lorem.setStyle("-fx-padding: 20px;");
        lorem.setText("Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
                "Integer ut ornare enim, a rutrum nisl. " +
                "Proin eros felis, rutrum at pharetra viverra, elementum quis lacus. " +
                "Nam sit amet sollicitudin nibh, ac mattis lectus. " +
                "Sed mattis ullamcorper sapien, a pulvinar turpis hendrerit vel. " +
                "Fusce nec diam metus. In vel dui lacus. " +
                "Sed imperdiet ipsum euismod aliquam rhoncus. " +
                "Morbi sagittis mauris ac massa pretium, vel placerat purus porta. " +
                "Suspendisse orci leo, sagittis eu orci vitae, porttitor sagittis odio. " +
                "Proin iaculis enim sed ipsum sodales, at congue ante blandit. " +
                "Etiam mattis erat nec dolor vestibulum, quis interdum sem pellentesque. " +
                "Nullam accumsan ex non lacus sollicitudin interdum.");
        lorem.setWrapText(true);

        StackPane content = new StackPane();
        content.setPrefSize(300, 300);
        content.setMinSize(300, 300);
        content.setMaxSize(300, 300);
        content.setStyle("-fx-background-color: white;");
        content.getChildren().add(lorem);

        ScrollContainer viewport = new ScrollContainer();
        viewport.setStyle("-fx-background-color: whitesmoke");
        viewport.getChildren().add(0, content);

        primaryStage.setScene(new Scene(viewport, 300, 150));
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

I wanted to see a working example showing just the basic art of scrolling; like the proper way of handling the thumb's animation movement, computation of scrollbar's thumb length, and lastly, the required total unit or amount to move the content. I think these three parts are the keys to the core of a scroll view.

P.S
I also want to see the use of the onScroll event in JavaFX, right now all I know is the common used mouse events. Thank you in advance.


UPDATE

I've added a BlockIncrement function to the answer of sir @fabian below. It will basically just move the thumb to the current position of the pointer while keeping the [0, 1] range value. All credits and thanks goes to him.

This is for others who were looking for something like this idea of custom scroll view, hope you might find this reference useful in the future.

public class ScrollContainer extends Region {

    private VerticalScrollBar scrollBar; // The scrollbar used for scrolling over the content from viewport
    private Rectangle rectangle; // Object for clipping the viewport to restrict overflowing content

    /**
     * Construct a new ScrollContainer
     */
    public ScrollContainer() {
        setOnScroll(evt -> {
            double viewportHeight = getHeight();
            double contentHeight = getContentHeight();
            if (contentHeight > viewportHeight) {
                double delta = evt.getDeltaY() / (viewportHeight - contentHeight);
                if (Double.isFinite(delta)) {
                    scrollBar.setValue(scrollBar.getValue() + delta);
                }
            }
        });

        scrollBar = new VerticalScrollBar();
        getChildren().add(scrollBar);

        rectangle = new Rectangle();
        setClip(rectangle);
    }

    private Node content;

    public void setContent(Node content) {
        if (this.content != null) {
            // remove old content
            getChildren().remove(this.content);
        }
        if (content != null) {
            // add new content
            getChildren().add(0, content);
        }
        this.content = content;
    }

    private double getContentHeight() {
        return content == null ? 0 : content.getLayoutBounds().getHeight();
    }

    @Override
    protected void layoutChildren() {
        super.layoutChildren();

        double w = getWidth();
        double h = getHeight();

        double sw = scrollBar.getWidth();

        double viewportWidth = w - sw;
        double viewportHeight = h;

        if (content != null) {
            double contentHeight = getContentHeight();
            double vValue = scrollBar.getValue();

            // position content according to scrollbar value
            content.setLayoutY(Math.min(0, viewportHeight - contentHeight) * vValue);
        }

        // Layout scrollbar to the edge of container, and fit the viewport's height as well
        scrollBar.resize(sw, h);
        scrollBar.setLayoutX(viewportWidth);

        // resize clip
        rectangle.setWidth(w);
        rectangle.setHeight(h);
    }

    /**
     * VerticalScrollBar
     */
    private class VerticalScrollBar extends Region {

        private boolean thumbPressed; // Indicates that the scrubber was pressed

        private double initialValue;
        private double initialY; // Initial mouse position when dragging the scrubber
        private Timeline widthTransition; // Transforms width of scrollbar on hover
        private Region scrubber; // Indicator about the content's visible area

        private double value;

        private void setValue(double v) {
            value = v;
        }

        private double getValue() {
            return value;
        }

        private double calculateScrubberHeight() {
            double h = getHeight();
            return h * h / getContentHeight();
        }

        /**
         * Construct a new VerticalScrollBar
         */
        private VerticalScrollBar() {
            // Scrollbar's initial width
            setPrefWidth(7);

            widthTransition = new Timeline(
                    new KeyFrame(Duration.ZERO, new KeyValue(prefWidthProperty(), 7)),
                    new KeyFrame(Duration.millis(500), new KeyValue(prefWidthProperty(), 14))
            );

            scrubber = new Region();
            scrubber.setStyle("-fx-background-color: rgba(0,0,0, 0.25)");
            scrubber.setOnMousePressed(event -> {
                initialY = scrubber.localToParent(event.getX(), event.getY()).getY();
                initialValue = value;
                thumbPressed = true;
            });
            scrubber.setOnMouseDragged(event -> {
                if (thumbPressed) {
                    double currentY = scrubber.localToParent(event.getX(), event.getY()).getY();
                    double sH = calculateScrubberHeight();
                    double h = getHeight();

                    // calculate value change and prevent errors
                    double delta = (currentY - initialY) / (h - sH);
                    if (!Double.isFinite(delta)) {
                        delta = 0;
                    }

                    // keep value in range [0, 1]
                    double newValue = Math.max(0, Math.min(1, initialValue + delta));
                    value = newValue;

                    // layout thumb
                    requestLayout();
                }
            });
            scrubber.setOnMouseReleased(event -> thumbPressed = false);
            getChildren().add(scrubber);

            // Added BlockIncrement.
            // Pressing the `track` or the scrollbar itself will move and position the
            // scrubber to the pointer location, as well as the content prior to the
            // value changes.
            setOnMousePressed(event -> {
                if (!thumbPressed) {
                    double sH = calculateScrubberHeight();
                    double h = getHeight();
                    double pointerY = event.getY();
                    double delta = pointerY / (h - sH);
                    double newValue = Math.max(0, Math.min(1, delta));

                    // keep value in range [0, 1]
                    if (delta > 1) {
                        newValue = 1;
                    }
                    value = newValue;

                    requestLayout();
                }
            });

            // Animate scrollbar's width on mouse enter and exit
            setOnMouseEntered(event -> {
                widthTransition.setRate(1);
                widthTransition.play();
            });
            setOnMouseExited(event -> {
                widthTransition.setRate(-1);
                widthTransition.play();
            });
        }

        @Override
        protected void layoutChildren() {
            super.layoutChildren();

            double h = getHeight();
            double cH = getContentHeight();

            if (cH <= h) {
                // full size, if content does not excede viewport size
                scrubber.resize(getWidth(), h);
            } else {
                double sH = calculateScrubberHeight();

                // move thumb to position
                scrubber.setTranslateY(value * (h - sH));

                // Layout scrubber to fit the scrollbar's width
                scrubber.resize(getWidth(), sH);
            }
        }
    }
}

Solution

  • There are a few equations that allow you to compute the layout (all assuming contentHeight > viewportHeight):

    vValue denotes the position of the thumb in the vertical scroll bar in [0, 1] (0 = topmost position, 1 = bottom of the thumb is at bottom of the track).

    topY = vValue * (contentHeight - viewportHeight)
    thumbHeight / trackHeight = viewportHeight / contentHeight
    thumbY = vValue * (trackHeight - thumbHeight)
    

    Also note that providing access to the children and adding the content outside of the ScrollContainer is bad practice since it requires the user of this class to do modifications that should be reserved for the class itself. Doing this could easily lead to the following line which breaks the ScrollContainer (the content could hide the thumb):

     // viewport.getChildren().add(0, content);
     viewport.getChildren().add(content);
    

    It's better extend Region directly and using a method to (re)place the content.

    public class ScrollContainer extends Region {
    
        private VerticalScrollBar scrollBar; // The scrollbar used for scrolling over the content from viewport
        private Rectangle rectangle; // Object for clipping the viewport to restrict overflowing content
    
        /**
         * Construct a new ScrollContainer
         */
        public ScrollContainer() {
            setOnScroll(evt -> {
                double viewportHeight = getHeight();
                double contentHeight = getContentHeight();
                if (contentHeight > viewportHeight) {
                    double delta = evt.getDeltaY() / (viewportHeight - contentHeight);
                    if (Double.isFinite(delta)) {
                        scrollBar.setValue(scrollBar.getValue() + delta);
                    }
                }
            });
    
            scrollBar = new VerticalScrollBar();
            getChildren().add(scrollBar);
    
            rectangle = new Rectangle();
            setClip(rectangle);
        }
    
        private Node content;
    
        public void setContent(Node content) {
            if (this.content != null) {
                // remove old content
                getChildren().remove(this.content);
            }
            if (content != null) {
                // add new content
                getChildren().add(0, content);
            }
            this.content = content;
        }
    
        private double getContentHeight() {
            return content == null ? 0 : content.getLayoutBounds().getHeight();
        }
    
        @Override
        protected void layoutChildren() {
            super.layoutChildren();
    
            double w = getWidth();
            double h = getHeight();
    
            double sw = scrollBar.getWidth();
    
            double viewportWidth = w - sw;
            double viewportHeight = h;
    
            if (content != null) {
                double contentHeight = getContentHeight();
                double vValue = scrollBar.getValue();
    
                // position content according to scrollbar value
                content.setLayoutY(Math.min(0, viewportHeight - contentHeight) * vValue);
            }
    
            // Layout scrollbar to the edge of container, and fit the viewport's height as well
            scrollBar.resize(sw, h);
            scrollBar.setLayoutX(viewportWidth);
    
            // resize clip
            rectangle.setWidth(w);
            rectangle.setHeight(h);
        }
    
        /**
         * VerticalScrollBar
         */
        private class VerticalScrollBar extends Region {
    
            private double initialValue;
            private double initialY; // Initial mouse position when dragging the scrubber
            private Timeline widthTransition; // Transforms width of scrollbar on hover
            private Region scrubber; // Indicator about the content's visible area
    
            private double value;
    
            public double getValue() {
                return value;
            }
    
            private double calculateScrubberHeight() {
                double h = getHeight();
                return h * h / getContentHeight();
            }
    
            /**
             * Construct a new VerticalScrollBar
             */
            private VerticalScrollBar() {
                // Scrollbar's initial width
                setPrefWidth(7);
    
                widthTransition = new Timeline(
                        new KeyFrame(Duration.ZERO, new KeyValue(prefWidthProperty(), 7)),
                        new KeyFrame(Duration.millis(500), new KeyValue(prefWidthProperty(), 14))
                );
    
                scrubber = new Region();
                scrubber.setStyle("-fx-background-color: rgba(0,0,0, 0.25)");
                scrubber.setOnMousePressed(event -> {
                    initialY = scrubber.localToParent(event.getX(), event.getY()).getY();
                    initialValue = value;
                });
                scrubber.setOnMouseDragged(event -> {
                    double currentY = scrubber.localToParent(event.getX(), event.getY()).getY();
                    double sH = calculateScrubberHeight();
                    double h = getHeight();
    
                    // calculate value change and prevent errors
                    double delta = (currentY - initialY) / (h - sH);
                    if (!Double.isFinite(delta)) {
                        delta = 0;
                    }
    
                    // keep value in range [0, 1]
                    double newValue = Math.max(0, Math.min(1, initialValue + delta));
                    value = newValue;
    
                    // layout thumb
                    requestLayout();
                });
                getChildren().add(scrubber);
    
                // Animate scrollbar's width on mouse enter and exit
                setOnMouseEntered(event -> {
                    widthTransition.setRate(1);
                    widthTransition.play();
                });
                setOnMouseExited(event -> {
                    widthTransition.setRate(-1);
                    widthTransition.play();
                });
            }
    
            @Override
            protected void layoutChildren() {
                super.layoutChildren();
    
                double h = getHeight();
                double cH = getContentHeight();
    
                if (cH <= h) {
                    // full size, if content does not excede viewport size
                    scrubber.resize(getWidth(), h);
                } else {
                    double sH = calculateScrubberHeight();
    
                    // move thumb to position
                    scrubber.setTranslateY(value * (h - sH));
    
                    // Layout scrubber to fit the scrollbar's width
                    scrubber.resize(getWidth(), sH);
                }
            }
        }
    }