javafx

After adding a new node in JavaFX, only the old coordinates can be obtained


Here is a example code that shows my problem:

public class TestApplication extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception {
        VBox box = new VBox();
        Button button = new Button("Add a Label at top.");
        button.setOnAction(event -> {
            box.getChildren().addFirst(new Label("Label"));
            // Here I want to compute something about coordinate
            Platform.runLater(() -> System.out.println(button.getBoundsInParent().getMinY()));
        });
        box.getChildren().add(button);
        box.setPrefSize(500, 500);
        Scene scene = new Scene(box);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

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

After clicking the button, the layout of the window looks like:

enter image description here

Then I want to get button.getBoundsInParent().getMinY().

I think that minY equals the height of the label (This is also what i want to get).

However, result is -1.399999976158142(I don't know why it's not 0, but it has nothing to do with my question this time).

What I want to ask is:

Why can I only get the coordinate before the label is added, and how can I get the correct coordinates at the location of System.out.println.

Thanks in advance for your help! 🙏

Update:

I have read the comments. The function I want to implement is to scroll to make a node visible.

In my program, there are many layers of containers between the button and the ScrollPane. So I tried to write a static method to handle this task.

It looks like below:

public class TestApplication extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception {
        VBox box = new VBox();
        Button button = new Button("Add a Label at top.");
        button.setOnAction(event -> {
            box.getChildren().addFirst(new Label("Label"));
            Platform.runLater(() -> scrollVerticalToVisible(button));
        });
        box.getChildren().add(button);
        box.setPrefSize(500, 500);
        Scene scene = new Scene(new ScrollPane(box));
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void scrollVerticalToVisible(Node node) {
        Bounds bounds = node.getBoundsInParent();
        Node container = node;
        while (container != null && !(container instanceof ScrollPane)) {
            bounds = container.localToParent(bounds);
            container = container.getParent();
        }
        if (container == null) {
            return;
        }
        ScrollPane scrollPane = (ScrollPane) container;
        double height = scrollPane.getContent().getBoundsInLocal().getHeight();
        // Same Issue, I cannot get correct y
        double y = bounds.getMaxY() - scrollPane.getViewportBounds().getMinY();
        double viewHeight = scrollPane.getViewportBounds().getHeight();
        System.out.println(y + " " + viewHeight + " " + height);

        scrollPane.setVvalue((y - viewHeight) / (height - viewHeight));
    }

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

Solution

  • Before you attempt to fix this, I would make sure you don't have a better API-supported solution. As @SedJ601 points out in a comment, a ListView might be a better control to use here.

    However, there are two issues here:

    1. You are trying to do these computations before a layout pass has been made, and therefore before the new coordinates have been computed.
    2. Your calculations of the amount to scroll are incorrect.

    There is no nice way to fix the first problem. The "correct" way to do this is to add functionality to the existing scroll pane class to allow it to scroll a node into view. This would entail at least subclassing the existing scroll pane skin and overriding its layout methods to do the appropriate computations. (By doing this, you can ensure the calculations are done at the appropriate time during the layout pass.) Unfortunately, JavaFX skins are not easy to subclass, and taking this approach will require a lot of work and a lot of replication of existing library code.

    There is a bit of a hack you can use to do this, which is to force a layout pass by calling

    scrollPane.applyCSS();
    scrollPane.layout();
    

    Note this forces an additional layout pass, which is not great from a performance standpoint but probably doesn't cause any problems in practice.

    For the calculations, suppose the height of the content is height and the height of the viewport is viewHeight. Then the number of "scrollable pixels" is the difference between them, height-viewHeight.

    enter image description here

    If the current vertical scroll value is v (and assuming this is on the scale of 0 to 1), then the number of pixels of content scrolled off the top of the viewport is v * (height - viewHeight).

    Consequently, the portion of the content that is currently visible extends from v * (height - viewHeight) to v * (height - viewHeight) + viewHeight.

    So the scrolling algorithm should be something like:

    Finally note that there is no a-priori guarantee that the range of vvalue for the scroll pane is from 0 to 1; it actually ranges from scrollPane.getVmin() to scrollPane.getVMax(). We can make the above calculations work by computing v as

    v = (scrollPane.getVvalue() - scrollPane.getVmin()) / (scrollPane.getVmax() - scrollPane.getVmin());
    

    and then once we have computed newV from any necessary adjustment, do

    scrollPane.setVvalue(scrollPane.getVmin() + newV * (scrollPane.getVMax() - scrollPane.getVmin()));
    

    One final side note:

    In my program, there are many layers of containers between the button and the ScrollPane.

    A useful technique to get the bounds of one node in the coordinate system of another is to translate the bounds from the original node to the scene, and then translate from the scene to the desired coordinate system. (This works as long as both nodes are in the same scene.)

    So you can do

    Bounds bounds = scrollPane.getContent().sceneToLocal(node.localToScene(node.getBoundsInLocal()));
    

    I.e. get the local bounds of the node you want, translate to the scene coordinate system using node.localToScene(...) and then translate to the coordinate system of the scroll pane's content node using scrollPane.getContent().sceneToLocal(...).

    Here is a working version, with a slightly more complex test case. I added a second button to add labels below the buttons, and made the layout a little more complex (both buttons inside a VBox inside the main VBox). I also added a button outside the scroll pane which will scroll both buttons into view.

    
    import javafx.application.Application;
    import javafx.geometry.Bounds;
    import javafx.scene.Node;
    import javafx.scene.Scene;
    import javafx.scene.control.Button;
    import javafx.scene.control.Label;
    import javafx.scene.control.ScrollPane;
    import javafx.scene.layout.BorderPane;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    
    public class TestApplication extends Application {
        @Override
        public void start(Stage primaryStage) throws Exception {
            VBox buttons = new VBox();
            VBox box = new VBox(buttons);
            Button button = new Button("Add a Label at top.");
            button.setOnAction(event -> {
                box.getChildren().addFirst(new Label("Label"));
                scrollVerticalToVisible(buttons);
            });
            buttons.getChildren().add(button);
            Button belowButton = new Button("Add a Label at the bottom");
            belowButton.setOnAction(_ -> {
                box.getChildren().add(new Label("Label"));
                scrollVerticalToVisible(buttons);
            });
            buttons.getChildren().add(belowButton);
            box.setPrefSize(500, 500);
    
            Button scrollButton = new Button("Find buttons!");
            scrollButton.setOnAction(_ -> scrollVerticalToVisible(buttons));
    
            BorderPane root = new BorderPane(new ScrollPane(box));
            root.setBottom(scrollButton);
            Scene scene = new Scene(root);
            primaryStage.setScene(scene);
            primaryStage.show();
        }
    
        public static void scrollVerticalToVisible(Node node) {
            Node container;
            for (
                    container = node;
                    container!=null && ! (container instanceof ScrollPane);
                    container = container.getParent()
            );
            if (container == null) {
                return;
            }
            ScrollPane scrollPane = (ScrollPane) container;
    
            scrollPane.applyCss();
            scrollPane.layout();
    
            Bounds bounds = scrollPane.getContent().sceneToLocal(node.localToScene(node.getBoundsInLocal()));
    
            double height = scrollPane.getContent().getBoundsInLocal().getHeight();
            double viewHeight = scrollPane.getViewportBounds().getHeight();
    
            if (viewHeight >= height) return;
    
            double ymin = bounds.getMinY();
            double ymax = bounds.getMaxY();
            double v = (scrollPane.getVvalue() - scrollPane.getVmin()) / (scrollPane.getVmax() - scrollPane.getVmin());
    
            double viewportMinInContent = v * (height-viewHeight);
            double viewportMaxInContent = viewportMinInContent + viewHeight;
    
            if (ymin < viewportMinInContent) {
                double newV = ymin / (height - viewHeight);
                scrollPane.setVvalue(scrollPane.getVmin() + newV * (scrollPane.getVmax() - scrollPane.getVmin()));
            } else if (ymax > viewportMaxInContent) {
                double newV = (ymax - viewHeight) / (height - viewHeight);
                scrollPane.setVvalue(scrollPane.getVmin() + newV * (scrollPane.getVmax() - scrollPane.getVmin()));
            }
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    }
    

    (Note we also do the check if (viewHeight > height) return;, i.e. don't do anything if the whole content is visible.)