Antonin
Antonin

Reputation: 952

JavaFX ScrollPane update of viewportBoundsProperty

I am building a tilemap just like openstreetmap or google map using JavaFX with a ScrollPane and a TilePane.

In the tilemap, we need to only load the currently visible portion of the screen and not all the tiles.

It seems like JavaFX ScrollPane updates its viewportBounds MinX, MaxX and MinY and MaxY (let's call it offset for the rest of the question) :

This is a problem because it means that I can compute the currently visible portion of my content while clicking and draging but not when using the scrollbars or moving using the touchpad.

I need to get this offset constantly because my ScrollPane's content is loaded when it is currently visible at the ScrollPane "position" :

Here is a minimal reproductible example illustrating the situation :

public class DemoScrollPane extends javafx.application.Application {
    private Logger logger = LoggerFactory.getLogger(DemoScrollPane.class);


    public static void run(String[] args) {
        javafx.application.Application.launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        // create content
        Pane pane = new Pane();
        Rectangle rectangle = new Rectangle(1000,1000, Color.AQUA);
        Rectangle rectangleInitialVP = new Rectangle(640,480, Color.GRAY);
        pane.getChildren().add(rectangle);
        pane.getChildren().add(rectangleInitialVP);

        // create scrollPane
        ScrollPane scrollPane = new ScrollPane();
        scrollPane.setContent(pane);
        // add click and drag interaction
        scrollPane.setPannable(true);

        // register a watcher on scrollPane viewportBoundsProperty
        InvalidationListener listener =
                    o -> {
                        logger.debug("viewport change {}",scrollPane.getViewportBounds());
                    };
        // This is triggered when moving using click and drag (pan)
        // and does correctly update the scrollPane viewportbounds.        
        // BUT this is not trigered when moving by clocking on hbar or vbar or by using a touchpad (on mac) without clicking.
        scrollPane.viewportBoundsProperty().addListener(listener);

        // display the app
        Scene scene = new Scene(scrollPane, 640, 480);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    @Override
    public void stop() throws Exception {
        logger.debug("stop");
    }
}

Can someone provide me with pointers on how to compute (or get) this offset dynamically on any scroll action (click and drag panning or panning using a touchpad or using horizontal or vertical bars) ?

Thanks in advance.

Upvotes: 0

Views: 315

Answers (2)

Sai Dandem
Sai Dandem

Reputation: 9959

The simple answer of your question about the difference is :: that's how it is implemented !!

Firstly, the viewPort bounds are updated everytime the scrollPane tries to layout its children. The sneak peak of layoutChildren method in ScrollPaneSkin class is as below:

protected void layoutChildren(double x, double y, double w, double h) {
  ScrollPane control = (ScrollPane)this.getSkinnable();
  ... // All the stuff for layouting
  ...
  control.setViewportBounds(new BoundingBox(this.snapPosition(this.viewContent.getLayoutX()), this.snapPosition(this.viewContent.getLayoutY()), this.snapSize(this.contentWidth), this.snapSize(this.contentHeight)));
} 

Now if we check for the related implementation for "pannable" property of ScrollPane, the code is as below (in ScrollPaneSkin class):

this.viewRect.setOnDragDetected((e) -> {
    if (IS_TOUCH_SUPPORTED) {
        this.startSBReleasedAnimation();
    }

    if (((ScrollPane)this.getSkinnable()).isPannable()) {
        this.dragDetected = true;
        if (this.saveCursor == null) {
            this.saveCursor = ((ScrollPane)this.getSkinnable()).getCursor();
            if (this.saveCursor == null) {
                this.saveCursor = Cursor.DEFAULT;
            }

            ((ScrollPane)this.getSkinnable()).setCursor(Cursor.MOVE);
            ((ScrollPane)this.getSkinnable()).requestLayout(); // !! THIS LINE !!
        }
    }

});
this.viewRect.addEventFilter(MouseEvent.MOUSE_RELEASED, (e) -> {
    this.mouseDown = false;
    if (this.dragDetected) {
        if (this.saveCursor != null) {
            ((ScrollPane)this.getSkinnable()).setCursor(this.saveCursor);
            this.saveCursor = null;
            ((ScrollPane)this.getSkinnable()).requestLayout();// !! THIS LINE !!
        }

        this.dragDetected = false;
    }

    if ((this.posY > ((ScrollPane)this.getSkinnable()).getVmax() || this.posY < ((ScrollPane)this.getSkinnable()).getVmin() || this.posX > ((ScrollPane)this.getSkinnable()).getHmax() || this.posX < ((ScrollPane)this.getSkinnable()).getHmin()) && !this.touchDetected) {
        this.startContentsToViewport();
    }

});

From the above code you can notice that, when the "pannable" property is true, if a drag is detected, it will request a layout. And in the next scene pulse it will call the layoutChildren and will update the viewportBounds. And the same thing happens when you release the mouse (if a drag is detected). That is the reason you can see the logs only at the start and end of the panning and not while dragging.

Now coming to the code of hbar/vbar dragging, the code when the scrollBar value is updated is as below: (in ScrollPaneSkin class)

InvalidationListener vsbListener = (valueModel) -> {
    if (!IS_TOUCH_SUPPORTED) {
        this.posY = Utils.clamp(((ScrollPane)this.getSkinnable()).getVmin(), this.vsb.getValue(), ((ScrollPane)this.getSkinnable()).getVmax());
    } else {
        this.posY = this.vsb.getValue();
    }

    this.updatePosY();
};
this.vsb.valueProperty().addListener(vsbListener);
InvalidationListener hsbListener = (valueModel) -> {
    if (!IS_TOUCH_SUPPORTED) {
        this.posX = Utils.clamp(((ScrollPane)this.getSkinnable()).getHmin(), this.hsb.getValue(), ((ScrollPane)this.getSkinnable()).getHmax());
    } else {
        this.posX = this.hsb.getValue();
    }

    this.updatePosX();
};
this.hsb.valueProperty().addListener(hsbListener);  

The value listeners just update the layoutX/Y property of the viewContent but not a layout request. The updatePosX method is as below:

private double updatePosX() {
    ScrollPane sp = (ScrollPane)this.getSkinnable();
    double x = this.isReverseNodeOrientation() ? this.hsb.getMax() - (this.posX - this.hsb.getMin()) : this.posX;
    double minX = Math.min(-x / (this.hsb.getMax() - this.hsb.getMin()) * (this.nodeWidth - this.contentWidth), 0.0D);
    this.viewContent.setLayoutX(this.snapPosition(minX));
    if (!sp.hvalueProperty().isBound()) {
        sp.setHvalue(Utils.clamp(sp.getHmin(), this.posX, sp.getHmax()));
    }

    return this.posX;
}

Another Solution:

+1 for @James_D answer. If you want to keep this more dynamic or if you dont know ahead the max bounds of your pane, you can try the below approach.

From the above code, we can notice that whether we pan or drag the scroll bar, the layoutX/Y properties of the viewContent(scrollPane's content parent node) are updated. So you can add listener to its layoutX/Y properties which will give you the exact position without the need of setting the min/max values to scroll bars.

ScrollPane scrollPane = new ScrollPane();
scrollPane.setContent(pane);
scrollPane.getContent().parentProperty().addListener((obs, old, viewContent) -> {
    if (viewContent != null) {
        InvalidationListener posListener = e -> {
            double x = viewContent.getLayoutX() * -1;
            double y = viewContent.getLayoutY() * -1;
            double width = scrollPane.getViewportBounds().getWidth();
            double height = scrollPane.getViewportBounds().getHeight();
            Bounds movingViewPort = new BoundingBox(x, y, width, height);
            System.out.println("viewport: " + movingViewPort);
        };
        viewContent.layoutXProperty().addListener(posListener);
        viewContent.layoutYProperty().addListener(posListener);
    }
});
scrollPane.setPannable(true);

Upvotes: 1

Antonin
Antonin

Reputation: 952

Thanks to @James_D's comment here is a solution that goes around the problem (see below).

If someone has a better understanding of JavaFX's ScrollPane do not hesitate to explain difference between

  • the click and scroll that updates the viewPort of the ScrollPane
  • vs scroll with H and V bars that does not update the viewPort of the ScrollPane.

Here is the solution taken from from James_D's comment :

public class DemoScrollPane extends javafx.application.Application {
    private Logger logger = LoggerFactory.getLogger(DemoScrollPane.class);


    public static void run(String[] args) {
        javafx.application.Application.launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        // create content
        Pane pane = new Pane();
        pane.setMaxSize(1000,1000);
        Rectangle rectangle = new Rectangle(1000,1000, Color.AQUA);
        Rectangle rectangleInitialVP = new Rectangle(640,480, Color.GRAY);
        pane.getChildren().add(rectangle);
        pane.getChildren().add(rectangleInitialVP);


        // create scrollPane
        ScrollPane scrollPane = new ScrollPane();
        scrollPane.setContent(pane);
        scrollPane.setVmin(0);
        scrollPane.setVmax(pane.getMaxHeight());
        scrollPane.setHmin(0);
        scrollPane.setHmax(pane.getMaxWidth());
        
        // add click and drag interaction
        scrollPane.setPannable(true);

        // register a watcher on scrollPane viewportBoundsProperty
        InvalidationListener listener =
                    o -> {
            Bounds movingViewPort = new BoundingBox(scrollPane.getHvalue(),
                    scrollPane.getVvalue(),
                    0.,
                    scrollPane.getViewportBounds().getWidth(),
                    scrollPane.getViewportBounds().getHeight(),
                    0.
                    );
                        logger.debug("viewport {}",movingViewPort);
                    };
        // This is triggered when moving using click and drag (pan)
        // and does correctly update the scrollPane viewportbounds.

        // BUT this is not trigered when moving by clocking on hbar or vbar or by using a touchpad (on mac)
        // without clicking.
        // (even tough the viewport obviously change)
        scrollPane.vvalueProperty().addListener(listener);
        scrollPane.hvalueProperty().addListener(listener);

        // display the app
        Scene scene = new Scene(scrollPane, 640, 480);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    @Override
    public void stop() throws Exception {
        logger.debug("stop");
    }
}

Upvotes: 1

Related Questions