创建一个简单的自定义滚动视图

Creating a simple custom scroll view

这是一个关于滚动视图的一般性问题,我想学习滚动视图的基础知识以及如何自己实现它,因为它是大多数动态 GUI 的重要组成部分。您可能会问,为什么不简单地使用平台提供的那个?我的回答是,除了学习新东西很有趣之外,很高兴看到按照您想要的方式定制的东西成为。简而言之,我只想创建一个简单的自定义滚动视图,并尝试了解它在幕后是如何工作的。

继续,我目前要在这里展示的只是我想出的 UI 的最简单的例子。基本上,它是一个 Pane 用作整个内容的视口,并在其右边缘包含一个垂直滚动条,就像普通的滚动视图一样,但我只是添加了一个小的过渡动画滚动条在鼠标上的宽度悬停。

滚动容器 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);
        }
    }
}

主要 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);
    }
}

我想看一个展示滚动基本艺术的工作示例;比如处理拇指动画移动的正确方法、滚动条拇指长度的计算,最后是移动内容所需的总单位或数量。我认为这三个部分是滚动视图核心的关键。

P.S
我还想看看 onScroll 事件在 JavaFX 中的使用,现在我只知道常用的鼠标事件。提前谢谢你。


更新

我在下面@fabian 先生的回答中添加了一个BlockIncrement 函数。它基本上只是将拇指移动到指针的当前位置,同时保持 [0, 1] 范围值。所有功劳和感谢都归功于他。

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);
            }
        }
    }
}

有几个方程式可让您计算布局(均假设 contentHeight > viewportHeight):

vValue表示拇指在[0, 1]中垂直滚动条的位置(0=最上面的位置,1=拇指的底部在轨道底部)。

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

另请注意,向子项提供访问权限并在 ScrollContainer 之外添加内容是一种不好的做法,因为它需要此 class 的用户进行修改,而这些修改应保留给 class 本身。这样做很容易导致以下行破坏 ScrollContainer(内容可能会隐藏拇指):

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

最好直接扩展 Region 并使用一种方法来(重新)放置内容。

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);
            }
        }
    }
}