JavaFX correct scaling

I created a sample app to demonstrate one approach to performing scaling of a node in a viewport on a scroll event (e.g. scroll in and out by rolling the mouse wheel).

The key logic to the sample for scaling a group placed within a StackPane:

final double SCALE_DELTA = 1.1;
final StackPane zoomPane = new StackPane();

zoomPane.getChildren().add(group);
zoomPane.setOnScroll(new EventHandler<ScrollEvent>() {
  @Override public void handle(ScrollEvent event) {
    event.consume();

    if (event.getDeltaY() == 0) {
      return;
    }

    double scaleFactor =
      (event.getDeltaY() > 0)
        ? SCALE_DELTA
        : 1/SCALE_DELTA;

    group.setScaleX(group.getScaleX() * scaleFactor);
    group.setScaleY(group.getScaleY() * scaleFactor);
  }
});

The scroll event handler is set on the enclosing StackPane which is a resizable pane so it expands to fill any empty space, keeping the zoomed content centered in the pane. If you move the mouse wheel anywhere inside the StackPane it will zoom in or out the enclosed group of nodes.

zoomy zoomyin

import javafx.application.Application;
import javafx.beans.value.*;
import javafx.event.*;
import javafx.geometry.Bounds;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.image.*;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.stage.Stage;

public class GraphicsScalingApp extends Application {
  public static void main(String[] args) { launch(args); }

  @Override public void start(final Stage stage) {
    final Group group = new Group(
        createStar(),
        createCurve()
    );

    Parent zoomPane = createZoomPane(group);

    VBox layout = new VBox();
    layout.getChildren().setAll(
        createMenuBar(stage, group),
        zoomPane
    );

    VBox.setVgrow(zoomPane, Priority.ALWAYS);
    Scene scene = new Scene(
        layout
    );

    stage.setTitle("Zoomy");
    stage.getIcons().setAll(new Image(APP_ICON));
    stage.setScene(scene);
    stage.show();
  }

  private Parent createZoomPane(final Group group) {
    final double SCALE_DELTA = 1.1;
    final StackPane zoomPane = new StackPane();

    zoomPane.getChildren().add(group);
    zoomPane.setOnScroll(new EventHandler<ScrollEvent>() {
      @Override public void handle(ScrollEvent event) {
        event.consume();

        if (event.getDeltaY() == 0) {
          return;
        }

        double scaleFactor =
          (event.getDeltaY() > 0)
            ? SCALE_DELTA
            : 1/SCALE_DELTA;

        group.setScaleX(group.getScaleX() * scaleFactor);
        group.setScaleY(group.getScaleY() * scaleFactor);
      }
    });

    zoomPane.layoutBoundsProperty().addListener(new ChangeListener<Bounds>() {
      @Override public void changed(ObservableValue<? extends Bounds> observable, Bounds oldBounds, Bounds bounds) {
      zoomPane.setClip(new Rectangle(bounds.getMinX(), bounds.getMinY(), bounds.getWidth(), bounds.getHeight()));
      }
    });

    return zoomPane;
  }

  private SVGPath createCurve() {
    SVGPath ellipticalArc = new SVGPath();
    ellipticalArc.setContent(
        "M10,150 A15 15 180 0 1 70 140 A15 25 180 0 0 130 130 A15 55 180 0 1 190 120"
    );
    ellipticalArc.setStroke(Color.LIGHTGREEN);
    ellipticalArc.setStrokeWidth(4);
    ellipticalArc.setFill(null);
    return ellipticalArc;
  }

  private SVGPath createStar() {
    SVGPath star = new SVGPath();
    star.setContent(
        "M100,10 L100,10 40,180 190,60 10,60 160,180 z"
    );
    star.setStrokeLineJoin(StrokeLineJoin.ROUND);
    star.setStroke(Color.BLUE);
    star.setFill(Color.DARKBLUE);
    star.setStrokeWidth(4);
    return star;
  }

  private MenuBar createMenuBar(final Stage stage, final Group group) {
    Menu fileMenu = new Menu("_File");
    MenuItem exitMenuItem = new MenuItem("E_xit");
    exitMenuItem.setGraphic(new ImageView(new Image(CLOSE_ICON)));
    exitMenuItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override public void handle(ActionEvent event) {
        stage.close();
      }
    });
    fileMenu.getItems().setAll(
        exitMenuItem
    );
    Menu zoomMenu = new Menu("_Zoom");
    MenuItem zoomResetMenuItem = new MenuItem("Zoom _Reset");
    zoomResetMenuItem.setAccelerator(new KeyCodeCombination(KeyCode.ESCAPE));
    zoomResetMenuItem.setGraphic(new ImageView(new Image(ZOOM_RESET_ICON)));
    zoomResetMenuItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override public void handle(ActionEvent event) {
        group.setScaleX(1);
        group.setScaleY(1);
      }
    });
    MenuItem zoomInMenuItem = new MenuItem("Zoom _In");
    zoomInMenuItem.setAccelerator(new KeyCodeCombination(KeyCode.I));
    zoomInMenuItem.setGraphic(new ImageView(new Image(ZOOM_IN_ICON)));
    zoomInMenuItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override public void handle(ActionEvent event) {
        group.setScaleX(group.getScaleX() * 1.5);
        group.setScaleY(group.getScaleY() * 1.5);
      }
    });
    MenuItem zoomOutMenuItem = new MenuItem("Zoom _Out");
    zoomOutMenuItem.setAccelerator(new KeyCodeCombination(KeyCode.O));
    zoomOutMenuItem.setGraphic(new ImageView(new Image(ZOOM_OUT_ICON)));
    zoomOutMenuItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override public void handle(ActionEvent event) {
        group.setScaleX(group.getScaleX() * 1/1.5);
        group.setScaleY(group.getScaleY() * 1/1.5);
      }
    });
    zoomMenu.getItems().setAll(
        zoomResetMenuItem,
        zoomInMenuItem,
        zoomOutMenuItem
    );
    MenuBar menuBar = new MenuBar();
    menuBar.getMenus().setAll(
        fileMenu,
        zoomMenu
    );
    return menuBar;
  }

  // icons source from: http://www.iconarchive.com/show/soft-scraps-icons-by-deleket.html
  // icon license: CC Attribution-Noncommercial-No Derivate 3.0 =? http://creativecommons.org/licenses/by-nc-nd/3.0/
  // icon Commercial usage: Allowed (Author Approval required -> Visit artist website for details).

  public static final String APP_ICON        = "http://icons.iconarchive.com/icons/deleket/soft-scraps/128/Zoom-icon.png";
  public static final String ZOOM_RESET_ICON = "http://icons.iconarchive.com/icons/deleket/soft-scraps/24/Zoom-icon.png";
  public static final String ZOOM_OUT_ICON   = "http://icons.iconarchive.com/icons/deleket/soft-scraps/24/Zoom-Out-icon.png";
  public static final String ZOOM_IN_ICON    = "http://icons.iconarchive.com/icons/deleket/soft-scraps/24/Zoom-In-icon.png";
  public static final String CLOSE_ICON      = "http://icons.iconarchive.com/icons/deleket/soft-scraps/24/Button-Close-icon.png";
}

Update for a zoomed node in a ScrollPane

The above implementation works well as far as it goes, but it is useful to be able to place the zoomed node inside a scroll pane, so that when you zoom in making the zoomed node larger than your available viewport, you can still pan around the zoomed node within the scroll pane to view parts of the node.

I found achieving the behavior of zooming in a scroll pane difficult, so I asked for help on an Oracle JavaFX Forum thread.

Oracle JavaFX forum user James_D came up with the following solution which solves the zooming within a ScrollPane problem quite well.

His comments and code were as below:

A couple of minor changes first: I wrapped the StackPane in a Group so that the ScrollPane would be aware of the changes to the transforms, as per the ScrollPane Javadocs. And then I bound the minimum size of the StackPane to the viewport size (keeping the content centered when smaller than the viewport).

Initially I thought I should use a Scale transform to zoom around the displayed center (i.e. the point on the content that is at the center of the viewport). But I found I still needed to fix the scroll position afterwards to keep the same displayed center, so I abandoned that and reverted to using setScaleX() and setScaleY().

The trick is to fix the scroll position after scaling. I computed the scroll offset in local coordinates of the scroll content, and then computed the new scroll values needed after the scale. This was a little tricky. The basic observation is that
(hValue-hMin)/(hMax-hMin) = x / (contentWidth – viewportWidth), where x is the horizontal offset of the left edge of the viewport from the left edge of the content.
Then you have centerX = x + viewportWidth/2.

After scaling, the x coordinate of the old centerX is now centerX*scaleFactor. So we just have to set the new hValue to make that the new center. There’s a bit of algebra to figure that out.

After that, panning by dragging was pretty easy :).

A corresponding feature request to add high level APIs to support zooming and scaling functionality in a ScrollPane is Add scaleContent functionality to ScrollPane. Vote for or comment on the feature request if you would like to see it implemented.

import javafx.application.Application;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.*;
import javafx.event.*;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.image.*;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.stage.Stage;

public class GraphicsScalingApp extends Application {
  public static void main(String[] args) {
    launch(args);
  }

  @Override
  public void start(final Stage stage) {
    final Group group = new Group(createStar(), createCurve());

    Parent zoomPane = createZoomPane(group);

    VBox layout = new VBox();
    layout.getChildren().setAll(createMenuBar(stage, group), zoomPane);

    VBox.setVgrow(zoomPane, Priority.ALWAYS);

    Scene scene = new Scene(layout);

    stage.setTitle("Zoomy");
    stage.getIcons().setAll(new Image(APP_ICON));
    stage.setScene(scene);
    stage.show();
  }

  private Parent createZoomPane(final Group group) {
    final double SCALE_DELTA = 1.1;
    final StackPane zoomPane = new StackPane();

    zoomPane.getChildren().add(group);

    final ScrollPane scroller = new ScrollPane();
    final Group scrollContent = new Group(zoomPane);
    scroller.setContent(scrollContent);

    scroller.viewportBoundsProperty().addListener(new ChangeListener<Bounds>() {
      @Override
      public void changed(ObservableValue<? extends Bounds> observable,
          Bounds oldValue, Bounds newValue) {
        zoomPane.setMinSize(newValue.getWidth(), newValue.getHeight());
      }
    });

    scroller.setPrefViewportWidth(256);
    scroller.setPrefViewportHeight(256);

    zoomPane.setOnScroll(new EventHandler<ScrollEvent>() {
      @Override
      public void handle(ScrollEvent event) {
        event.consume();

        if (event.getDeltaY() == 0) {
          return;
        }

        double scaleFactor = (event.getDeltaY() > 0) ? SCALE_DELTA
            : 1 / SCALE_DELTA;

        // amount of scrolling in each direction in scrollContent coordinate
        // units
        Point2D scrollOffset = figureScrollOffset(scrollContent, scroller);

        group.setScaleX(group.getScaleX() * scaleFactor);
        group.setScaleY(group.getScaleY() * scaleFactor);

        // move viewport so that old center remains in the center after the
        // scaling
        repositionScroller(scrollContent, scroller, scaleFactor, scrollOffset);

      }
    });

    // Panning via drag....
    final ObjectProperty<Point2D> lastMouseCoordinates = new SimpleObjectProperty<Point2D>();
    scrollContent.setOnMousePressed(new EventHandler<MouseEvent>() {
      @Override
      public void handle(MouseEvent event) {
        lastMouseCoordinates.set(new Point2D(event.getX(), event.getY()));
      }
    });

    scrollContent.setOnMouseDragged(new EventHandler<MouseEvent>() {
      @Override
      public void handle(MouseEvent event) {
        double deltaX = event.getX() - lastMouseCoordinates.get().getX();
        double extraWidth = scrollContent.getLayoutBounds().getWidth() - scroller.getViewportBounds().getWidth();
        double deltaH = deltaX * (scroller.getHmax() - scroller.getHmin()) / extraWidth;
        double desiredH = scroller.getHvalue() - deltaH;
        scroller.setHvalue(Math.max(0, Math.min(scroller.getHmax(), desiredH)));

        double deltaY = event.getY() - lastMouseCoordinates.get().getY();
        double extraHeight = scrollContent.getLayoutBounds().getHeight() - scroller.getViewportBounds().getHeight();
        double deltaV = deltaY * (scroller.getHmax() - scroller.getHmin()) / extraHeight;
        double desiredV = scroller.getVvalue() - deltaV;
        scroller.setVvalue(Math.max(0, Math.min(scroller.getVmax(), desiredV)));
      }
    });

    return scroller;
  }

  private Point2D figureScrollOffset(Node scrollContent, ScrollPane scroller) {
    double extraWidth = scrollContent.getLayoutBounds().getWidth() - scroller.getViewportBounds().getWidth();
    double hScrollProportion = (scroller.getHvalue() - scroller.getHmin()) / (scroller.getHmax() - scroller.getHmin());
    double scrollXOffset = hScrollProportion * Math.max(0, extraWidth);
    double extraHeight = scrollContent.getLayoutBounds().getHeight() - scroller.getViewportBounds().getHeight();
    double vScrollProportion = (scroller.getVvalue() - scroller.getVmin()) / (scroller.getVmax() - scroller.getVmin());
    double scrollYOffset = vScrollProportion * Math.max(0, extraHeight);
    return new Point2D(scrollXOffset, scrollYOffset);
  }

  private void repositionScroller(Node scrollContent, ScrollPane scroller, double scaleFactor, Point2D scrollOffset) {
    double scrollXOffset = scrollOffset.getX();
    double scrollYOffset = scrollOffset.getY();
    double extraWidth = scrollContent.getLayoutBounds().getWidth() - scroller.getViewportBounds().getWidth();
    if (extraWidth > 0) {
      double halfWidth = scroller.getViewportBounds().getWidth() / 2 ;
      double newScrollXOffset = (scaleFactor - 1) *  halfWidth + scaleFactor * scrollXOffset;
      scroller.setHvalue(scroller.getHmin() + newScrollXOffset * (scroller.getHmax() - scroller.getHmin()) / extraWidth);
    } else {
      scroller.setHvalue(scroller.getHmin());
    }
    double extraHeight = scrollContent.getLayoutBounds().getHeight() - scroller.getViewportBounds().getHeight();
    if (extraHeight > 0) {
      double halfHeight = scroller.getViewportBounds().getHeight() / 2 ;
      double newScrollYOffset = (scaleFactor - 1) * halfHeight + scaleFactor * scrollYOffset;
      scroller.setVvalue(scroller.getVmin() + newScrollYOffset * (scroller.getVmax() - scroller.getVmin()) / extraHeight);
    } else {
      scroller.setHvalue(scroller.getHmin());
    }
  }

  private SVGPath createCurve() {
    SVGPath ellipticalArc = new SVGPath();
    ellipticalArc.setContent("M10,150 A15 15 180 0 1 70 140 A15 25 180 0 0 130 130 A15 55 180 0 1 190 120");
    ellipticalArc.setStroke(Color.LIGHTGREEN);
    ellipticalArc.setStrokeWidth(4);
    ellipticalArc.setFill(null);
    return ellipticalArc;
  }

  private SVGPath createStar() {
    SVGPath star = new SVGPath();
    star.setContent("M100,10 L100,10 40,180 190,60 10,60 160,180 z");
    star.setStrokeLineJoin(StrokeLineJoin.ROUND);
    star.setStroke(Color.BLUE);
    star.setFill(Color.DARKBLUE);
    star.setStrokeWidth(4);
    return star;
  }

  private MenuBar createMenuBar(final Stage stage, final Group group) {
    Menu fileMenu = new Menu("_File");
    MenuItem exitMenuItem = new MenuItem("E_xit");
    exitMenuItem.setGraphic(new ImageView(new Image(CLOSE_ICON)));
    exitMenuItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override
      public void handle(ActionEvent event) {
        stage.close();
      }
    });
    fileMenu.getItems().setAll(exitMenuItem);
    Menu zoomMenu = new Menu("_Zoom");
    MenuItem zoomResetMenuItem = new MenuItem("Zoom _Reset");
    zoomResetMenuItem.setAccelerator(new KeyCodeCombination(KeyCode.ESCAPE));
    zoomResetMenuItem.setGraphic(new ImageView(new Image(ZOOM_RESET_ICON)));
    zoomResetMenuItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override
      public void handle(ActionEvent event) {
        group.setScaleX(1);
        group.setScaleY(1);
      }
    });
    MenuItem zoomInMenuItem = new MenuItem("Zoom _In");
    zoomInMenuItem.setAccelerator(new KeyCodeCombination(KeyCode.I));
    zoomInMenuItem.setGraphic(new ImageView(new Image(ZOOM_IN_ICON)));
    zoomInMenuItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override
      public void handle(ActionEvent event) {
        group.setScaleX(group.getScaleX() * 1.5);
        group.setScaleY(group.getScaleY() * 1.5);
      }
    });
    MenuItem zoomOutMenuItem = new MenuItem("Zoom _Out");
    zoomOutMenuItem.setAccelerator(new KeyCodeCombination(KeyCode.O));
    zoomOutMenuItem.setGraphic(new ImageView(new Image(ZOOM_OUT_ICON)));
    zoomOutMenuItem.setOnAction(new EventHandler<ActionEvent>() {
      @Override
      public void handle(ActionEvent event) {
        group.setScaleX(group.getScaleX() * 1 / 1.5);
        group.setScaleY(group.getScaleY() * 1 / 1.5);
      }
    });
    zoomMenu.getItems().setAll(zoomResetMenuItem, zoomInMenuItem,
        zoomOutMenuItem);
    MenuBar menuBar = new MenuBar();
    menuBar.getMenus().setAll(fileMenu, zoomMenu);
    return menuBar;
  }

  // icons source from:
  // http://www.iconarchive.com/show/soft-scraps-icons-by-deleket.html
  // icon license: CC Attribution-Noncommercial-No Derivate 3.0 =?
  // http://creativecommons.org/licenses/by-nc-nd/3.0/
  // icon Commercial usage: Allowed (Author Approval required -> Visit artist
  // website for details).

  public static final String APP_ICON = "http://icons.iconarchive.com/icons/deleket/soft-scraps/128/Zoom-icon.png";
  public static final String ZOOM_RESET_ICON = "http://icons.iconarchive.com/icons/deleket/soft-scraps/24/Zoom-icon.png";
  public static final String ZOOM_OUT_ICON = "http://icons.iconarchive.com/icons/deleket/soft-scraps/24/Zoom-Out-icon.png";
  public static final String ZOOM_IN_ICON = "http://icons.iconarchive.com/icons/deleket/soft-scraps/24/Zoom-In-icon.png";
  public static final String CLOSE_ICON = "http://icons.iconarchive.com/icons/deleket/soft-scraps/24/Button-Close-icon.png";
}

Leave a Comment