javauser-interfacejavafxzoomingpanning

How to re-center an image once it has been dragged and zoomed in or out


I am trying to create a class which handles zooming and panning components. The class contains references to an Anchorpane, which I am using since it does not do any aligning by default, as well as an ImageView which I want to be able to drag and zoom onto. The zooming and panning both work individually, but I am having trouble re-centering the image once it has been zoomed in or out.

The idea is that I want to be able to recenter the image when the app resizes so that dragging always appears to be relative to the center of the Anchorpane. By default any displacement in an Anchorpane is relative to the top-left corner, but that is not intuitive to the user. It would be much more logical for the content to appear to be moving relative to the center. To achieve this, the idea is to recenter the content whenever the window changes size, and then apply a translation corresponding to the amount of dragging done by the user

If you run the code I posted and zoom in, then resize the window, you'll notice that the red rectangle representing the image goes all over the place. If you shrink the window, then the square will no longer be at the same place as before. This only happens when scaling has been applied and appear to be a problem with the re-centering method.

If the centering method worked correctly, the square should return to the same position is was at before the window was expanded, which happens when the square is at 1 to 1 scale and no zooming has occured

Here is the class I use to handle the zoom and dragging

package com.example.test;

import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.transform.Scale;
import javafx.scene.transform.Translate;

public class ZoomController{

    // the plane on which the node is being dragged
    private final AnchorPane PLANE;
    // the node I want to zoom and drag
    private final Node CONTENT;

    private Point2D lastContentPosition = new Point2D(0, 0);
    private Point2D lastMousePosition   = new Point2D(0, 0);
    // the total amount of dragging 
    // applied to the CONTENT node
    private Point2D dragOffset          = new Point2D(0, 0);

    // the total amount of scaling
    // applied to the CONTENT node
    private int scale = 1;

    public ZoomController(
            AnchorPane plane,
            Node content
    ) {
        this.PLANE = plane;
        this.CONTENT = content;

        addListeners();
        
        // artificially reproduces the problem
        applyZoom(2, new Point2D(0, 350));
        applyDrag(new Point2D(-100, 0));
    }

    private void addListeners() {
        // tries to center the CONTENT whenever the window is resized
        PLANE.heightProperty().addListener(centerContent());
        PLANE.widthProperty().addListener(centerContent());

        // saves the mouse's position whenever the mouse moves
        PLANE.setOnMousePressed(getMousePosition());
        PLANE.setOnMouseDragged(drag());

        PLANE.setOnScroll(handleScroll());
    }

    private ChangeListener<Number> centerContent() {
        return (observableValue, number, t1) -> {
            centerView();
        };
    }

    private EventHandler<MouseEvent> drag() {
        return mouseEvent -> {

            // calculates the path taken by the mouse...
            Point2D newMousePosition = new Point2D(mouseEvent.getX(), mouseEvent.getY());
            Point2D mouseTranslation = lastMousePosition.subtract(newMousePosition);
            // ...and saves its new position
            updateMousePosition(mouseEvent);

            // applies the drag
            applyDrag(mouseTranslation);

        };
    }

    private EventHandler<MouseEvent> getMousePosition() {
        return this::updateMousePosition;
    }

    private EventHandler<ScrollEvent> handleScroll() {
        return scrollEvent -> {

            // filters out the mouse stopping to scroll
            if (scrollEvent.getDeltaX() == 0 && scrollEvent.getDeltaY() == 0) return;

            // starts zooming
            if (scrollEvent.isControlDown()) {
                zoom(scrollEvent);
            }

        };
    }

    private void zoom(ScrollEvent scrollEvent) {

        // adds or subtracts to the image's scale based on
        // whether user is scrolling backwards or forwards
        final double dScale = scrollEvent.getDeltaY() > 0 ? 0.1 : -0.1;
        scale += dScale;

        // gets the coordinates IN THE IMAGE's FRAME OF REFERENCE
        // of the point at which to zoom the image so it is centered on the mouse
        Point2D target = CONTENT.parentToLocal(new Point2D(scrollEvent.getX(), scrollEvent.getY()));

        // applies the zoom to the image
        applyZoom(1 + dScale, target);

        // saves the image's position once it has been zoomed
        updateContentPosition();
    }

    private void applyZoom(final double zoomAmount, Point2D target) {
        // applies the necessary scaling to the image...
        Scale zoom = new Scale(zoomAmount, zoomAmount);
        // ...and centers the scaling to the point where the mouse is located at
        zoom.setPivotY(target.getY());
        zoom.setPivotX(target.getX());
        CONTENT.getTransforms().add(zoom);

        updateContentPosition();
    }

    private void applyDrag(Point2D dragAmount) {
        // drag amount always corresponds to the mouse's displacement
        // for the moment this is a 1 to 1 mapping
        // since I have not figured out how to take the scale into consideration

        // updates the total displacement caused by drag (used when we re-center the image)
        dragOffset = dragOffset.subtract(dragAmount);

        // applies the necessary translation to the image...
        Translate drag = new Translate();
        // ...based on the mouse's movement
        drag.setX(-dragAmount.getX());
        drag.setY(-dragAmount.getY());
        CONTENT.getTransforms().add(drag);

        // saves the image's position after it has been dragged
        updateContentPosition();
    }

    private void centerView() {
        // gets the coordinates we need to place the image at for it to be centered
        Point2D centerPosition = getCenterEdge();
        // calculates the path to take from the image's current position
        // to the position it has to be at to be centered
        // ie: the displacement vector
        Point2D translation    = centerPosition.subtract(lastContentPosition);

        // applies the necessary translation to the image...
        Translate translateToCenter = new Translate();
        // ...while account for drag so image is not fully re-centered
        translateToCenter.setX(translation.getX() + dragOffset.getX());
        translateToCenter.setY(translation.getY() + dragOffset.getY());
        CONTENT.getTransforms().add(translateToCenter);

        // saves the image's position after it has been centered
        updateContentPosition();
    }

    private void updateMousePosition(MouseEvent mouseEvent) {
        lastMousePosition = new Point2D(mouseEvent.getX(), mouseEvent.getY());
    }

    private void updateContentPosition() {
        // updates the image's position
        lastContentPosition = getContentPosition();
    }

    private Point2D getContentPosition() {
        // gets the minimal coordinates of the bounds around the image
        // ie: the image's coordinates
        Bounds contentBounds = CONTENT.getBoundsInParent();
        return new Point2D(contentBounds.getMinX(), contentBounds.getMinY());
    }

    private Point2D getCenterEdge() {
        // gets the size of the image and the anchor pane it is in...
        Point2D contentSize    = getContentSize();
        Point2D availableSpace = getAvailableSpace();
        // ...to determine the coordinates at which to place the image for it to be centerd
        return new Point2D(
                (availableSpace.getX() - contentSize.getX()) / 2,
                (availableSpace.getY() - contentSize.getY()) / 2
        );
    }

    private Point2D getContentSize() {
        // gets the bounds around the image
        Bounds contentBounds = CONTENT.getBoundsInParent();
        return new Point2D(contentBounds.getWidth(), contentBounds.getHeight());
    }

    private Point2D getAvailableSpace() {
        // gets the size of the Anchorpane the image is inn
        return new Point2D(PLANE.getWidth(), PLANE.getHeight());
    }


}

And here is the main class I am using to test the zoom

package com.example.test;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.AnchorPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;

public class ZoomMain extends Application {

    @Override
    public void start(Stage stage) throws Exception {

        // the node we want to drag & zoom
        Rectangle rectangle = new Rectangle(200, 100);
        rectangle.setFill(Color.RED);

        // the plane on which the node is being dragged
        AnchorPane plane    = new AnchorPane();

        // adds the node
        Scene mainScene = new Scene(plane);
        plane.getChildren().add(rectangle);

        // handles the zoom
        ZoomController zoomController = new ZoomController(
            plane,
            rectangle
        );

        stage.setTitle("Zooming test");
        stage.setScene(mainScene);
        stage.setMinHeight(500);
        stage.setMinWidth(500);

        stage.show();
    }

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

}

I have already tried to group the image to drag & zoom into a group, which would be useful since it would let me include multiple nodes to be dragged at once, however when I do that the contents of the group remain invisible.


Solution

  • Instead of re-centering the content after scale, use the center point of the content as the scale pivot. In other words, scale around the center of the content.
    Introduce a simple method to calculate the center:

    private Point2D getContentCenter() {
        Bounds contentBounds = CONTENT.getBoundsInLocal();
        return new Point2D(contentBounds.getCenterX(), contentBounds.getCenterY());
    }
    

    And scale around it:

    private void zoom(ScrollEvent scrollEvent) {
        // adds or subtracts to the image's scale based on
        // whether user is scrolling backwards or forwards
        final double dScale = scrollEvent.getDeltaY() > 0 ? 0.1 : -0.1;
        scale += dScale;
        Point2D target  = getContentCenter();
        // applies the zoom to the image
        applyZoom(1 + dScale, target);
    }
    

    EDIT: The following ZoomController implements scaling around mouth positions and recentering of the content when the pane is resized :

    class ZoomController{
    
        // the plane on which the node is being dragged
        private final AnchorPane PLANE;
        // the node I want to zoom and drag
        private final Node CONTENT;
    
        // the total amount of scaling  applied to the CONTENT node
        private int scale = 1;
    
        private Point2D lastMousePosition   = new Point2D(0, 0);
    
        public ZoomController( AnchorPane plane, Node content ) {
            PLANE = plane;
            CONTENT = content;
    
            PLANE.heightProperty().addListener((obs, number, t1) -> centerView());
            PLANE.widthProperty().addListener((obs, number, t1) -> centerView());
    
            // saves the mouse's position whenever the mouse moves
            PLANE.setOnMousePressed(event -> updateMousePosition(event));
            PLANE.setOnMouseDragged(drag());
    
            PLANE.setOnScroll(handleScroll());
        }
    
        private void updateMousePosition(MouseEvent mouseEvent) {
            lastMousePosition = new Point2D(mouseEvent.getX(), mouseEvent.getY());
        }
    
        private EventHandler<ScrollEvent> handleScroll() {
            return scrollEvent -> {
                // filters out the mouse stopping to scroll
                if (scrollEvent.getDeltaX() == 0 && scrollEvent.getDeltaY() == 0) return;
                // starts zooming
                if (scrollEvent.isControlDown()) {
                    zoom(scrollEvent);
                }
            };
        }
    
        private void zoom(ScrollEvent scrollEvent) {
    
            // adds or subtracts to the image's scale based on
            // whether user is scrolling backwards or forwards
            final double dScale = scrollEvent.getDeltaY() > 0 ? 0.1 : -0.1;
            scale += dScale;
    
            // scale around mouse position
            Point2D pivot = CONTENT.parentToLocal(new Point2D(scrollEvent.getX(), scrollEvent.getY()));
    
            //applies the zoom to the image
            applyZoom(1 + dScale, pivot);
        }
    
        private void applyZoom(final double zoomAmount, Point2D target) {
            // applies the necessary scaling to the image...
            Scale zoom = new Scale(zoomAmount, zoomAmount);
            // ...and centers the scaling to the point where the mouse is located at
            zoom.setPivotY(target.getY());
            zoom.setPivotX(target.getX());
            CONTENT.getTransforms().add(zoom);
        }
    
        private EventHandler<MouseEvent> drag() {
            return mouseEvent -> {
    
                // calculates the path taken by the mouse...
                Point2D newMousePosition = new Point2D(mouseEvent.getX(), mouseEvent.getY());
                Point2D mouseTranslation = lastMousePosition.subtract(newMousePosition);
                // ...and saves its new position
                updateMousePosition(mouseEvent);
    
                // applies the drag
                applyDrag(mouseTranslation);
            };
        }
    
        private void applyDrag(Point2D dragAmount) {
            // applies the necessary translation to the image...
            Translate drag = new Translate();
            // ...based on the mouse's movement
            drag.setX(-dragAmount.getX());
            drag.setY(-dragAmount.getY());
            CONTENT.getTransforms().add(drag);
        }
    
        private void centerView() {
    
            // gets the coordinates we need to place the image at for it to be centered
            Point2D centerPosition = CONTENT.parentToLocal(getCenterEdge());
            Point2D position = getContentPosition();
    
            Point2D translation    = centerPosition.subtract(position);
            // applies the necessary translation to the image...
            Translate translateToCenter = new Translate();
            translateToCenter.setX(translation.getX());
            translateToCenter.setY(translation.getY());
            CONTENT.getTransforms().add(translateToCenter);
        }
    
        private Point2D getContentPosition() {
            // gets the minimal coordinates of the bounds around the image
            // ie: the image's coordinates
            Bounds contentBounds = CONTENT.getBoundsInLocal();
            return new Point2D(contentBounds.getMinX(), contentBounds.getMinY());
        }
    
        private Point2D getCenterEdge() {
            // gets the size of the image and the anchor pane it is in...
            Point2D contentSize  = getContentSize();
            Point2D parentSize = getParentSize();
            // ...to determine the coordinates at which to place the image for it to be centered
            return new Point2D(
                    (parentSize.getX() - contentSize.getX()) / 2,
                    (parentSize.getY() - contentSize.getY()) / 2
            );
        }
    
        private Point2D getContentSize() {
            //BoundsInParent: rectangular bounds of this Node which include its transforms
            Bounds contentBounds = CONTENT.getBoundsInParent();
            return new Point2D(contentBounds.getWidth(), contentBounds.getHeight());
        }
    
        private Point2D getParentSize() {//renamed from getAvailableSpace()
            // gets the size of the Anchorpane the image is inn
            return new Point2D(PLANE.getWidth(), PLANE.getHeight());
        }
    }