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