javafxdrag-and-dropzoomingscrollpane

JavaFX Pan and Zoom with Draggable Nodes Inside


I have a simple JavaFX pan and zoom application as show below. The pan and zoom functionality work great, but I would also like to be able to drag and drop the circle node too. The problem I have is that the scrollpane gets all of the mouse events first, so I'm unable to assign a drag and drop to just the circle. Is it possible to have a draggable/zoomable scrollpane and also be able to drag a node inside the pane?

Screenshot

Here us the code that I'm using:

package sample;

import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.stage.Stage;
import javafx.util.Duration;

public class Main extends Application {

    private ScrollPane scrollPane = new ScrollPane();
    private final DoubleProperty zoomProperty = new SimpleDoubleProperty(1.0d);
    private final DoubleProperty deltaY = new SimpleDoubleProperty(0.0d);
    private final Group group = new Group();
    ImageView bigImageView = null;
    PanAndZoomPane panAndZoomPane = null;
    Pane featuresPane = new Pane();

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

        bigImageView = new ImageView();

        StackPane bigStackpane = new StackPane();

        bigStackpane.getChildren().add(bigImageView);
        bigStackpane.getChildren().add(featuresPane);
        featuresPane.toFront();

        featuresPane.setOpacity(.8);
        Circle circle = new Circle();
        circle.setCenterX(200);
        circle.setCenterY(200);
        circle.setRadius(100);
        circle.setFill(Color.RED);
        circle.setOnMouseClicked(e -> {
            System.out.println("circle clicked");
        });
        featuresPane.getChildren().add(circle);

        scrollPane.setPannable(true);
        scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
        scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);

        group.getChildren().add(bigImageView);
        group.getChildren().add(bigStackpane);

        panAndZoomPane = new PanAndZoomPane();
        zoomProperty.bind(panAndZoomPane.myScale);
        deltaY.bind(panAndZoomPane.deltaY);
        panAndZoomPane.getChildren().add(group);

        SceneGestures sceneGestures = new SceneGestures(panAndZoomPane);

        scrollPane.setContent(panAndZoomPane);
        panAndZoomPane.toBack();

        scrollPane.addEventFilter( MouseEvent.MOUSE_CLICKED, sceneGestures.getOnMouseClickedEventHandler());
        scrollPane.addEventFilter( MouseEvent.MOUSE_PRESSED, sceneGestures.getOnMousePressedEventHandler());
        scrollPane.addEventFilter( MouseEvent.MOUSE_DRAGGED, sceneGestures.getOnMouseDraggedEventHandler());
        scrollPane.addEventFilter( ScrollEvent.ANY, sceneGestures.getOnScrollEventHandler());

        AnchorPane bigImageAnchorPane = new AnchorPane();

        bigImageAnchorPane.getChildren().add(scrollPane);

        Image image = new Image("https://i.imgur.com/8p1XBag.jpg");
        bigImageView.setImage(image);

        bigImageAnchorPane.setTopAnchor(scrollPane, 1.0d);
        bigImageAnchorPane.setRightAnchor(scrollPane, 1.0d);
        bigImageAnchorPane.setBottomAnchor(scrollPane, 1.0d);
        bigImageAnchorPane.setLeftAnchor(scrollPane, 1.0d);

        BorderPane root = new BorderPane(bigImageAnchorPane);
        Label label = new Label("Pan and Zoom Test");
        root.setTop(label);

        Scene scene = new Scene(root, 1000, 1000);
        primaryStage.setScene(scene);
        primaryStage.show();
    }


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

    class PanAndZoomPane extends Pane {

        public static final double DEFAULT_DELTA = 1.5d; //1.3d
        DoubleProperty myScale = new SimpleDoubleProperty(1.0);
        public DoubleProperty deltaY = new SimpleDoubleProperty(0.0);
        private Timeline timeline;


        public PanAndZoomPane() {

            this.timeline = new Timeline(30);//60

            // add scale transform
            scaleXProperty().bind(myScale);
            scaleYProperty().bind(myScale);
        }


        public double getScale() {
            return myScale.get();
        }

        public void setScale( double scale) {
            myScale.set(scale);
        }

        public void setPivot( double x, double y, double scale) {
            // note: pivot value must be untransformed, i. e. without scaling
            // timeline that scales and moves the node
            timeline.getKeyFrames().clear();
            timeline.getKeyFrames().addAll(
                    new KeyFrame(Duration.millis(100), new KeyValue(translateXProperty(), getTranslateX() - x)), //200
                    new KeyFrame(Duration.millis(100), new KeyValue(translateYProperty(), getTranslateY() - y)), //200
                    new KeyFrame(Duration.millis(100), new KeyValue(myScale, scale)) //200
            );
            timeline.play();

        }

        public double getDeltaY() {
            return deltaY.get();
        }
        public void setDeltaY( double dY) {
            deltaY.set(dY);
        }
    }


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

        double mouseAnchorX;
        double mouseAnchorY;

        double translateAnchorX;
        double translateAnchorY;

    }

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

        private DragContext sceneDragContext = new DragContext();

        PanAndZoomPane panAndZoomPane;

        public SceneGestures( PanAndZoomPane canvas) {
            this.panAndZoomPane = canvas;
        }

        public EventHandler<MouseEvent> getOnMouseClickedEventHandler() {
            return onMouseClickedEventHandler;
        }

        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) {

                sceneDragContext.mouseAnchorX = event.getX();
                sceneDragContext.mouseAnchorY = event.getY();

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

            }

        };

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

                panAndZoomPane.setTranslateX(sceneDragContext.translateAnchorX + event.getX() - sceneDragContext.mouseAnchorX);
                panAndZoomPane.setTranslateY(sceneDragContext.translateAnchorY + event.getY() - 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 = PanAndZoomPane.DEFAULT_DELTA;

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

                panAndZoomPane.setDeltaY(event.getDeltaY());
                if (panAndZoomPane.deltaY.get() < 0) {
                    scale /= delta;
                } else {
                    scale *= delta;
                }

                double f = (scale / oldScale)-1;

                double dx = (event.getX() - (panAndZoomPane.getBoundsInParent().getWidth()/2 + panAndZoomPane.getBoundsInParent().getMinX()));
                double dy = (event.getY() - (panAndZoomPane.getBoundsInParent().getHeight()/2 + panAndZoomPane.getBoundsInParent().getMinY()));

                panAndZoomPane.setPivot(f*dx, f*dy, scale);

                event.consume();

            }
        };

        /**
         * Mouse click handler
         */
        private EventHandler<MouseEvent> onMouseClickedEventHandler = new EventHandler<MouseEvent>() {

            @Override
            public void handle(MouseEvent event) {
                if (event.getButton().equals(MouseButton.PRIMARY)) {
                    if (event.getClickCount() == 2) {
                        System.out.println("Image Layer Double Clicked...");
                    }else{
                        System.out.println("Image Layer Clicked...");
                    }
                }
            }
        };
    }
}


Solution

  • Your code is adding behavior via event filters. These filters are invoked during the event capturing phase which means they are invoked before the events reach your circle. You should strive to implement your behavior via event handlers, which are invoked during the event bubbling phase. Then you can consume events to prevent them from reaching ancestors, allowing you to drag your circle without scrolling/panning the scroll-pane content. For more information about event handling and propagation, check out this tutorial.

    Here's a proof-of-concept which adds the zoom-handling to the scroll-pane's content and still let's you drag around a circle:

    import javafx.application.Application;
    import javafx.geometry.Point2D;
    import javafx.scene.Group;
    import javafx.scene.Scene;
    import javafx.scene.control.ScrollPane;
    import javafx.scene.control.ScrollPane.ScrollBarPolicy;
    import javafx.scene.image.ImageView;
    import javafx.scene.layout.Region;
    import javafx.scene.layout.StackPane;
    import javafx.scene.paint.Color;
    import javafx.scene.shape.Circle;
    import javafx.stage.Stage;
    
    public class Main extends Application {
    
      @Override
      public void start(Stage primaryStage) {
        // using your example image
        ImageView imageView = new ImageView("https://i.imgur.com/8p1XBag.jpg");
    
        Circle circle = new Circle(100, 100, 25, Color.FIREBRICK);
        circle.setOnMousePressed(
            e -> {
              // prevent pannable ScrollPane from changing cursor on drag-detected (implementation
              // detail)
              e.setDragDetect(false);
              Point2D offset =
                  new Point2D(e.getX() - circle.getCenterX(), e.getY() - circle.getCenterY());
              circle.setUserData(offset);
              e.consume(); // prevents MouseEvent from reaching ScrollPane
            });
        circle.setOnMouseDragged(
            e -> {
              // prevent pannable ScrollPane from changing cursor on drag-detected (implementation
              // detail)
              e.setDragDetect(false);
              Point2D offset = (Point2D) circle.getUserData();
              circle.setCenterX(e.getX() - offset.getX());
              circle.setCenterY(e.getY() - offset.getY());
              e.consume(); // prevents MouseEvent from reaching ScrollPane
            });
    
        // the zoom-able content of the ScrollPane
        Group group = new Group(imageView, circle);
    
        // wrap Group in another Group since it's the former that's scaled and
        // Groups only take transformations of their **children** into account (not themselves)
        StackPane content = new StackPane(new Group(group));
        content.setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
        // due to later configuration, the StackPane will always cover the entire viewport
        content.setOnScroll(
            e -> {
              if (e.isShortcutDown() && e.getDeltaY() != 0) {
                if (e.getDeltaY() < 0) {
                  group.setScaleX(Math.max(group.getScaleX() - 0.1, 0.5));
                } else {
                  group.setScaleX(Math.min(group.getScaleX() + 0.1, 5.0));
                }
                group.setScaleY(group.getScaleX());
                e.consume(); // prevents ScrollEvent from reaching ScrollPane
              }
            });
    
        // use StackPane (or some other resizable node) as content since Group is not 
        // resizable. Note StackPane will center content if smaller than viewport.
        ScrollPane scrollPane = new ScrollPane(content);
        scrollPane.setVbarPolicy(ScrollBarPolicy.NEVER);
        scrollPane.setHbarPolicy(ScrollBarPolicy.NEVER);
        scrollPane.setPannable(true);
        // ensure StackPane content always has at least the same dimensions as the viewport
        scrollPane.setFitToWidth(true);
        scrollPane.setFitToHeight(true);
    
        primaryStage.setScene(new Scene(scrollPane, 1000, 650));
        primaryStage.show();
      }
    }
    

    Note this does not exactly replicate the behavior of your example. It does not use animations nor does it zoom on a pivot point. But hopefully it can help you move forward in your application.