javafxscrollzoomingpinchzoompanning

Scale at pivot point in an already scaled node


I'm trying to create an application with a zoomable/pannable canvas.

Features:

The zooming at the pivot point works as long as you start the zooming at scale 1. Position the mouse over a grid point and scroll the mouse wheel. The pivot point will remain where you started zooming.

Problem:

When you zoom in, then move the mouse to another point and zoom again, then the pivot point is shifted and zooming isn't happening anymore at the initial mouse position.

Example:

Here's the code:

import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.Scale;
import javafx.stage.Stage;

/**
 * The canvas which holds all of the nodes of the application.
 */
class PannableCanvas extends Pane {

    Scale scaleTransform;

    public PannableCanvas() {

        setPrefSize(600, 600);
        setStyle("-fx-background-color: lightgrey; -fx-border-color: blue;");

        // add scale transform
        scaleTransform = new Scale( 1.0, 1.0);
        getTransforms().add( scaleTransform);

        // logging
        addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { 
            System.out.println( 
                    "canvas event: " + ( ((event.getSceneX() - getBoundsInParent().getMinX()) / getScale()) + ", scale: " + getScale())
                    );
            System.out.println( "canvas bounds: " + getBoundsInParent());   
                });

    }

    /**
     * Add a grid to the canvas, send it to back
     */
    public void addGrid() {

        double w = getBoundsInLocal().getWidth();
        double h = getBoundsInLocal().getHeight();

        // add grid
        Canvas grid = new Canvas(w, h);

        // don't catch mouse events
        grid.setMouseTransparent(true);

        GraphicsContext gc = grid.getGraphicsContext2D();

        gc.setStroke(Color.GRAY);
        gc.setLineWidth(1);

        // draw grid lines
        double offset = 50;
        for( double i=offset; i < w; i+=offset) {
            // vertical
            gc.strokeLine( i, 0, i, h);
            // horizontal
            gc.strokeLine( 0, i, w, i);
        }

        getChildren().add( grid);

        grid.toBack();
    }

    public Scale getScaleTransform() {
        return scaleTransform;
    }

    public double getScale() {
        return scaleTransform.getY();
    }

    /**
     * Set x/y scale
     * @param scale
     */
    public void setScale( double scale) {
        scaleTransform.setX(scale);
        scaleTransform.setY(scale);
    }

    /**
     * Set x/y pivot points
     * @param x
     * @param y
     */
    public void setPivot( double x, double y) {
        scaleTransform.setPivotX(x);
        scaleTransform.setPivotY(y);
    }
}


/**
 * Mouse drag context used for scene and nodes.
 */
class DragContext {

    double mouseAnchorX;
    double mouseAnchorY;

    double translateAnchorX;
    double translateAnchorY;

}

/**
 * Listeners for making the nodes draggable via left mouse button. Considers if parent is zoomed.
 */
class NodeGestures {

    private DragContext nodeDragContext = new DragContext();

    PannableCanvas canvas;

    public NodeGestures( PannableCanvas canvas) {
        this.canvas = canvas;

    }

    public EventHandler<MouseEvent> getOnMousePressedEventHandler() {
        return onMousePressedEventHandler;
    }

    public EventHandler<MouseEvent> getOnMouseDraggedEventHandler() {
        return onMouseDraggedEventHandler;
    }

    private EventHandler<MouseEvent> onMousePressedEventHandler = new EventHandler<MouseEvent>() {

        public void handle(MouseEvent event) {

            // left mouse button => dragging
            if( !event.isPrimaryButtonDown())
                return;

            nodeDragContext.mouseAnchorX = event.getSceneX();
            nodeDragContext.mouseAnchorY = event.getSceneY();

            Node node = (Node) event.getSource();

            nodeDragContext.translateAnchorX = node.getTranslateX();
            nodeDragContext.translateAnchorY = node.getTranslateY();

        }

    };

    private EventHandler<MouseEvent> onMouseDraggedEventHandler = new EventHandler<MouseEvent>() {
        public void handle(MouseEvent event) {

            // left mouse button => dragging
            if( !event.isPrimaryButtonDown())
                return;

            double scale = canvas.getScale();

            Node node = (Node) event.getSource();

            node.setTranslateX(nodeDragContext.translateAnchorX + (( event.getSceneX() - nodeDragContext.mouseAnchorX) / scale));
            node.setTranslateY(nodeDragContext.translateAnchorY + (( event.getSceneY() - nodeDragContext.mouseAnchorY) / scale));

            event.consume();

        }
    };
}

/**
 * Listeners for making the scene's canvas draggable and zoomable
 */
class SceneGestures {

    private static final double MAX_SCALE = 10.0d;
    private static final double MIN_SCALE = .1d;

    private DragContext sceneDragContext = new DragContext();

    PannableCanvas canvas;

    public SceneGestures( PannableCanvas canvas) {
        this.canvas = canvas;
    }

    public EventHandler<MouseEvent> getOnMousePressedEventHandler() {
        return onMousePressedEventHandler;
    }

    public EventHandler<MouseEvent> getOnMouseDraggedEventHandler() {
        return onMouseDraggedEventHandler;
    }

    public EventHandler<ScrollEvent> getOnScrollEventHandler() {
        return onScrollEventHandler;
    }

    private EventHandler<MouseEvent> onMousePressedEventHandler = new EventHandler<MouseEvent>() {

        public void handle(MouseEvent event) {

            // right mouse button => panning
            if( !event.isSecondaryButtonDown())
                return;

            sceneDragContext.mouseAnchorX = event.getSceneX();
            sceneDragContext.mouseAnchorY = event.getSceneY();

            sceneDragContext.translateAnchorX = canvas.getTranslateX();
            sceneDragContext.translateAnchorY = canvas.getTranslateY();

        }

    };

    private EventHandler<MouseEvent> onMouseDraggedEventHandler = new EventHandler<MouseEvent>() {
        public void handle(MouseEvent event) {

            // right mouse button => panning
            if( !event.isSecondaryButtonDown())
                return;

            canvas.setTranslateX(sceneDragContext.translateAnchorX + event.getSceneX() - sceneDragContext.mouseAnchorX);
            canvas.setTranslateY(sceneDragContext.translateAnchorY + event.getSceneY() - sceneDragContext.mouseAnchorY);

            event.consume();
        }
    };

    /**
     * Mouse wheel handler: zoom to pivot point
     */
    private EventHandler<ScrollEvent> onScrollEventHandler = new EventHandler<ScrollEvent>() {

        @Override
        public void handle(ScrollEvent event) {

            double delta = 1;

            double scale = canvas.getScale(); // currently we only use Y, same value is used for X
            double oldScale = scale;

            if (event.getDeltaY() < 0)
                scale -= delta;
            else
                scale += delta;

            if (scale <= MIN_SCALE) {
                scale = MIN_SCALE;
            } else if (scale >= MAX_SCALE) {
                scale = MAX_SCALE;
            }

            // pivot value must be untransformed, i. e. without scaling
            canvas.setPivot( 
                    ((event.getSceneX() - canvas.getBoundsInParent().getMinX()) / oldScale),
                    ((event.getSceneY() - canvas.getBoundsInParent().getMinY()) / oldScale)
                    );

            canvas.setScale( scale);

            System.out.println( "new pivot x: " + canvas.scaleTransform.getPivotX() + "/" + canvas.scaleTransform.getPivotY() + ", new scale: " + scale);
            System.out.println( "bounds: " + canvas.getBoundsInParent());       

            event.consume();

        }

    };


}

/**
 * An application with a zoomable and pannable canvas.
 */
public class ScrollApplication extends Application {
    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) {

        Group group = new Group();

        // create canvas
        PannableCanvas canvas = new PannableCanvas();

        // we don't want the canvas on the top/left in this example => just
        // translate it a bit
        canvas.setTranslateX(100);
        canvas.setTranslateY(100);

        // create sample nodes which can be dragged
        NodeGestures nodeGestures = new NodeGestures( canvas);

        Label label1 = new Label("Draggable node 1");
        label1.setTranslateX(10);
        label1.setTranslateY(10);
        label1.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
        label1.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());

        Label label2 = new Label("Draggable node 2");
        label2.setTranslateX(100);
        label2.setTranslateY(100);
        label2.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
        label2.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());

        Label label3 = new Label("Draggable node 3");
        label3.setTranslateX(200);
        label3.setTranslateY(200);
        label3.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
        label3.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());

        Circle circle1 = new Circle( 300, 300, 50);
        circle1.setStroke(Color.ORANGE);
        circle1.setFill(Color.ORANGE.deriveColor(1, 1, 1, 0.5));
        circle1.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
        circle1.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());

        Rectangle rect1 = new Rectangle(100,100);
        rect1.setTranslateX(450);
        rect1.setTranslateY(450);
        rect1.setStroke(Color.BLUE);
        rect1.setFill(Color.BLUE.deriveColor(1, 1, 1, 0.5));
        rect1.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
        rect1.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());

        canvas.getChildren().addAll(label1, label2, label3, circle1, rect1);

        group.getChildren().add(canvas);

        // create scene which can be dragged and zoomed
        Scene scene = new Scene(group, 1024, 768);

        SceneGestures sceneGestures = new SceneGestures(canvas);
        scene.addEventFilter( MouseEvent.MOUSE_PRESSED, sceneGestures.getOnMousePressedEventHandler());
        scene.addEventFilter( MouseEvent.MOUSE_DRAGGED, sceneGestures.getOnMouseDraggedEventHandler());
        scene.addEventFilter( ScrollEvent.ANY, sceneGestures.getOnScrollEventHandler());

        stage.setScene(scene);
        stage.show();

        canvas.addGrid();

    }
}

It's obviously something wrong with the pivot point calculation, but I can't figure out what it is and how to fix it.

Thank you very much!

enter image description here


Solution

  • First I would recommend to not scale in linear steps but by factors to smooth the scaling:

               double delta = 1.2;
               if (event.getDeltaY() < 0)
                    scale /= delta;
                else
                    scale *= delta;
    

    ... and to be somehow bossy, I recommend curly brackets as a good style ;-) :

                   double delta = 1.2;
                   if (event.getDeltaY() < 0) {
                        scale /= delta;
                   } else {
                        scale *= delta;
                   }
    

    ... and to use the mouse scroll value for even better quality:

                   double delta = 1.2;
                   if (event.getDeltaY() < 0) {
                        scale /= Math.pow(delta, -event.getDeltaY()/20);
                   } else {
                        scale *= Math.pow(delta, event.getDeltaY()/20);
                   }
    

    ... that is finally the same as:

                   scale *= Math.pow(1.01, event.getDeltaY());
    

    Second I recomend to use the canvas translate and scale properties instead of a Transformation:

    public class ZoomApplication extends Application {
        static public class PannableCanvas extends Pane {
    
            DoubleProperty myScale = new SimpleDoubleProperty(1.0);
    
            public PannableCanvas() {
    
                setPrefSize(600, 600);
                setStyle("-fx-background-color: lightgrey; -fx-border-color: blue;");
    
                // add scale transform
                scaleXProperty().bind(myScale);
                scaleYProperty().bind(myScale);
    
                // logging
                addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { 
                    System.out.println( 
                            "canvas event: " + ( ((event.getSceneX() - getBoundsInParent().getMinX()) / getScale()) + ", scale: " + getScale())
                            );
                    System.out.println( "canvas bounds: " + getBoundsInParent());   
                });
    
            }
    
            /**
             * Add a grid to the canvas, send it to back
             */
            public void addGrid() {
    
                double w = getBoundsInLocal().getWidth();
                double h = getBoundsInLocal().getHeight();
    
                // add grid
                Canvas grid = new Canvas(w, h);
    
                // don't catch mouse events
                grid.setMouseTransparent(true);
    
                GraphicsContext gc = grid.getGraphicsContext2D();
    
                gc.setStroke(Color.GRAY);
                gc.setLineWidth(1);
    
                // draw grid lines
                double offset = 50;
                for( double i=offset; i < w; i+=offset) {
                    // vertical
                    gc.strokeLine( i, 0, i, h);
                    // horizontal
                    gc.strokeLine( 0, i, w, i);
                }
    
                getChildren().add( grid);
    
                grid.toBack();
            }
    
            public double getScale() {
                return myScale.get();
            }
    
            /**
             * Set x/y scale
             * @param myScale
             */
            public void setScale( double scale) {
                myScale.set(scale);
            }
    
            /**
             * Set x/y pivot points
             * @param x
             * @param y
             */
            public void setPivot( double x, double y) {
                setTranslateX(getTranslateX()-x);
                setTranslateY(getTranslateY()-y);
            }
        }
    
    
        /**
         * Mouse drag context used for scene and nodes.
         */
        class DragContext {
    
            double mouseAnchorX;
            double mouseAnchorY;
    
            double translateAnchorX;
            double translateAnchorY;
    
        }
    
        /**
         * Listeners for making the nodes draggable via left mouse button. Considers if parent is zoomed.
         */
        class NodeGestures {
    
            private DragContext nodeDragContext = new DragContext();
    
            PannableCanvas canvas;
    
            public NodeGestures( PannableCanvas canvas) {
                this.canvas = canvas;
    
            }
    
            public EventHandler<MouseEvent> getOnMousePressedEventHandler() {
                return onMousePressedEventHandler;
            }
    
            public EventHandler<MouseEvent> getOnMouseDraggedEventHandler() {
                return onMouseDraggedEventHandler;
            }
    
            private EventHandler<MouseEvent> onMousePressedEventHandler = new EventHandler<MouseEvent>() {
    
                public void handle(MouseEvent event) {
    
                    // left mouse button => dragging
                    if( !event.isPrimaryButtonDown())
                        return;
    
                    nodeDragContext.mouseAnchorX = event.getSceneX();
                    nodeDragContext.mouseAnchorY = event.getSceneY();
    
                    Node node = (Node) event.getSource();
    
                    nodeDragContext.translateAnchorX = node.getTranslateX();
                    nodeDragContext.translateAnchorY = node.getTranslateY();
    
                }
    
            };
    
            private EventHandler<MouseEvent> onMouseDraggedEventHandler = new EventHandler<MouseEvent>() {
                public void handle(MouseEvent event) {
    
                    // left mouse button => dragging
                    if( !event.isPrimaryButtonDown())
                        return;
    
                    double scale = canvas.getScale();
    
                    Node node = (Node) event.getSource();
    
                    node.setTranslateX(nodeDragContext.translateAnchorX + (( event.getSceneX() - nodeDragContext.mouseAnchorX) / scale));
                    node.setTranslateY(nodeDragContext.translateAnchorY + (( event.getSceneY() - nodeDragContext.mouseAnchorY) / scale));
    
                    event.consume();
    
                }
            };
        }
    
        /**
         * Listeners for making the scene's canvas draggable and zoomable
         */
        class SceneGestures {
    
            private static final double MAX_SCALE = 10.0d;
            private static final double MIN_SCALE = .1d;
    
            private DragContext sceneDragContext = new DragContext();
    
            PannableCanvas canvas;
    
            public SceneGestures( PannableCanvas canvas) {
                this.canvas = canvas;
            }
    
            public EventHandler<MouseEvent> getOnMousePressedEventHandler() {
                return onMousePressedEventHandler;
            }
    
            public EventHandler<MouseEvent> getOnMouseDraggedEventHandler() {
                return onMouseDraggedEventHandler;
            }
    
            public EventHandler<ScrollEvent> getOnScrollEventHandler() {
                return onScrollEventHandler;
            }
    
            private EventHandler<MouseEvent> onMousePressedEventHandler = new EventHandler<MouseEvent>() {
    
                public void handle(MouseEvent event) {
    
                    // right mouse button => panning
                    if( !event.isSecondaryButtonDown())
                        return;
    
                    sceneDragContext.mouseAnchorX = event.getSceneX();
                    sceneDragContext.mouseAnchorY = event.getSceneY();
    
                    sceneDragContext.translateAnchorX = canvas.getTranslateX();
                    sceneDragContext.translateAnchorY = canvas.getTranslateY();
    
                }
    
            };
    
            private EventHandler<MouseEvent> onMouseDraggedEventHandler = new EventHandler<MouseEvent>() {
                public void handle(MouseEvent event) {
    
                    // right mouse button => panning
                    if( !event.isSecondaryButtonDown())
                        return;
    
                    canvas.setTranslateX(sceneDragContext.translateAnchorX + event.getSceneX() - sceneDragContext.mouseAnchorX);
                    canvas.setTranslateY(sceneDragContext.translateAnchorY + event.getSceneY() - sceneDragContext.mouseAnchorY);
    
                    event.consume();
                }
            };
    
            /**
             * Mouse wheel handler: zoom to pivot point
             */
            private EventHandler<ScrollEvent> onScrollEventHandler = new EventHandler<ScrollEvent>() {
    
                @Override
                public void handle(ScrollEvent event) {
    
    
                    double scale = canvas.getScale(); // currently we only use Y, same value is used for X
                    double oldScale = scale;
    
                    scale *= Math.pow(1.01, event.getDeltaY());
    
                    if (scale <= MIN_SCALE) {
                        scale = MIN_SCALE;
                    } else if (scale >= MAX_SCALE) {
                        scale = MAX_SCALE;
                    }
    
                    double f = (scale / oldScale)-1;
    
                    double dx = (event.getSceneX() - (canvas.getBoundsInParent().getWidth()/2 + canvas.getBoundsInParent().getMinX()));
                    double dy = (event.getSceneY() - (canvas.getBoundsInParent().getHeight()/2 + canvas.getBoundsInParent().getMinY()));
    
    
                    canvas.setScale( scale);
                    canvas.setPivot(f*dx, f*dy);
    
                    event.consume();
    
                }
    
            };
    
    
        }
    
        public static void main(String[] args) {
            launch(args);
        }
    
        @Override
        public void start(Stage stage) {
    
            Group group = new Group();
    
            // create canvas
            PannableCanvas canvas = new PannableCanvas();
    
            // we don't want the canvas on the top/left in this example => just
            // translate it a bit
            canvas.setTranslateX(100);
            canvas.setTranslateY(100);
    
            // create sample nodes which can be dragged
            NodeGestures nodeGestures = new NodeGestures( canvas);
    
            Label label1 = new Label("Draggable node 1");
            label1.setTranslateX(10);
            label1.setTranslateY(10);
            label1.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
            label1.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());
    
            Label label2 = new Label("Draggable node 2");
            label2.setTranslateX(100);
            label2.setTranslateY(100);
            label2.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
            label2.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());
    
            Label label3 = new Label("Draggable node 3");
            label3.setTranslateX(200);
            label3.setTranslateY(200);
            label3.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
            label3.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());
    
            Circle circle1 = new Circle( 300, 300, 50);
            circle1.setStroke(Color.ORANGE);
            circle1.setFill(Color.ORANGE.deriveColor(1, 1, 1, 0.5));
            circle1.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
            circle1.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());
    
            Rectangle rect1 = new Rectangle(100,100);
            rect1.setTranslateX(450);
            rect1.setTranslateY(450);
            rect1.setStroke(Color.BLUE);
            rect1.setFill(Color.BLUE.deriveColor(1, 1, 1, 0.5));
            rect1.addEventFilter( MouseEvent.MOUSE_PRESSED, nodeGestures.getOnMousePressedEventHandler());
            rect1.addEventFilter( MouseEvent.MOUSE_DRAGGED, nodeGestures.getOnMouseDraggedEventHandler());
    
            canvas.getChildren().addAll(label1, label2, label3, circle1, rect1);
    
            group.getChildren().add(canvas);
    
            // create scene which can be dragged and zoomed
            Scene scene = new Scene(group, 1024, 768);
    
            SceneGestures sceneGestures = new SceneGestures(canvas);
            scene.addEventFilter( MouseEvent.MOUSE_PRESSED, sceneGestures.getOnMousePressedEventHandler());
            scene.addEventFilter( MouseEvent.MOUSE_DRAGGED, sceneGestures.getOnMouseDraggedEventHandler());
            scene.addEventFilter( ScrollEvent.ANY, sceneGestures.getOnScrollEventHandler());
    
            stage.setScene(scene);
            stage.show();
    
            canvas.addGrid();
    
        }
    }
    

    After some thoughts about zoom, I came to the conclusion, that it would be a good idea to

    1. Write an independent zoom helper method to ease the zoom function
    2. To also support the pinch-to-zoom gesture with the same method

    So I wrote the following helper method:

    /** Allow to zoom/scale any node with pivot at scene (x,y) coordinates.
     * 
     * @param node
     * @param delta
     * @param x
     * @param y
     */
    public static void zoom(Node node, double factor, double x, double y) {
        double oldScale = node.getScaleX();
        double scale = oldScale * factor;
        if (scale < 0.05) scale = 0.05;
        if (scale > 50)  scale = 50;
        node.setScaleX(scale);
        node.setScaleY(scale);
    
        double  f = (scale / oldScale)-1;
        Bounds bounds = node.localToScene(node.getBoundsInLocal());
        double dx = (x - (bounds.getWidth()/2 + bounds.getMinX()));
        double dy = (y - (bounds.getHeight()/2 + bounds.getMinY()));
    
        node.setTranslateX(node.getTranslateX()-f*dx);
        node.setTranslateY(node.getTranslateY()-f*dy);
    }
    
    public static void zoom(Node node, ScrollEvent event) {
        zoom(node, Math.pow(1.01, event.getDeltaY()), event.getSceneX(), event.getSceneY());
    }
    public static void zoom(Node node, ZoomEvent event) {
        zoom(node, event.getZoomFactor(), event.getSceneX(), event.getSceneY());
    }
    

    allowing me to register zoom function on any node as easy as:

        myView.setOnScroll(event -> GUITools.zoom(myView, event)); // mouse scroll wheel zoom
        myView.setOnZoom(event -> GUITools.zoom(myView, event)); // pinch to zoom
    

    and done...