Reputation:
I'm trying to implement a ScrollPane with similar functionality as CorelDraw/InDesign: A piece of paper within a ScrollPane that can be moved around (Changing ViewPort) and that can be enlarged/ zoomed in out.
My problem is that I don't know how to get the coordinates of the Page within the ScrollPane. I read many articles about how to implement a ScrollPane with zoomable content and learned that a Group and an additional Node was necessary to make the entire Page visible within a ScrollPane.
What I want:
If the Page is zoomed in so that the upper part and lower part is outside the viewport but the background (in my case a VBox) is visible left and right, I want to know how many pixels of the VBox are visible in the ScrollPanes viewport left of the page content (And same right).
With the current code I know how far the upper left corner of the page is "away" from the left edge of the ScrollPanes viewport but if the entire page is visible it always says 0,0.
Any ideas how to achieve that with my arrangements of components?
My code so far:
package demo;
import javafx.application.Application;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
public class MVP extends Application {
private Stage stage = null;
private Group zoomGroup = null;
private VBox centeredVBox = null;
private ScrollPane scrollPane = null;
private StackPane page = null;
@Override
public void start(Stage primaryStage) throws Exception {
this.stage = primaryStage;
this.page = new StackPane();
this.page.setPrefWidth(42840);
this.page.setPrefHeight(60624);
this.page.setBackground(new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, Insets.EMPTY)));
this.zoomGroup = new Group(page);
this.centeredVBox = new VBox(zoomGroup);
this.centeredVBox.setAlignment(Pos.CENTER);
this.centeredVBox
.setBackground(new Background(new BackgroundFill(Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY)));
this.scrollPane = new ScrollPane(centeredVBox);
this.scrollPane.setPannable(true);
this.scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS);
this.scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS);
this.scrollPane.setFitToHeight(true); // center
this.scrollPane.setFitToWidth(true); // center
this.scrollPane.setPrefSize(800, 600);
this.centeredVBox.setOnScroll(evt -> {
if (evt.isControlDown()) {
this.onScroll(evt.getTextDeltaY(), new Point2D(evt.getX(), evt.getY()));
}
this.scrollPane.layout();
});
this.scrollPane.setOnMouseReleased(evt -> {
// HOW TO GET THE POSITION OF THE PAGE WITHIN VIEWPORT OF SCROLLPANE IN PIXEL COORDINATES?
System.out.println(
"Upper left: " + (this.scrollPane.getViewportBounds().getMinX() * (1 / this.page.getScaleX())) + "/"
+ (this.scrollPane.getViewportBounds().getMinY() * (1 / this.page.getScaleY())));
System.out.println(
"Lower Right: " + (this.scrollPane.getViewportBounds().getMaxX() * (1 / this.page.getScaleX()))
+ "/" + (this.scrollPane.getViewportBounds().getMaxY() * (1 / this.page.getScaleY())));
});
this.page.setOnMouseMoved(evt -> {
this.stage.setTitle(String.valueOf(evt.getX() + "/" + evt.getY()));
});
Scene scene = new Scene(this.scrollPane);
primaryStage.setTitle("Demo");
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
private void onScroll(double wheelDelta, Point2D mousePoint) {
// this.pageRepresentation.setLastClickedPoint(mousePoint);
double zoomFactor = Math.exp(wheelDelta * 0.02);
Bounds innerBounds = this.zoomGroup.getLayoutBounds();
Bounds viewportBounds = this.scrollPane.getViewportBounds();
// calculate pixel offsets from [0, 1] range
double valX = this.scrollPane.getHvalue() * (innerBounds.getWidth() - viewportBounds.getWidth());
double valY = this.scrollPane.getVvalue() * (innerBounds.getHeight() - viewportBounds.getHeight());
// convert target coordinates to zoomTarget coordinates
Point2D posInZoomTarget = this.page.parentToLocal(this.zoomGroup.parentToLocal(mousePoint));
// this.pageRepresentation.setLastClickedPoint(posInZoomTarget);
// calculate adjustment of scroll position (pixels)
Point2D adjustment = this.page.getLocalToParentTransform()
.deltaTransform(posInZoomTarget.multiply(zoomFactor - 1));
// this.pageRepresentation.setLastClickedPoint(adjustment);
// convert back to [0, 1] range
// (too large/small values are automatically corrected by ScrollPane)
Bounds updatedInnerBounds = zoomGroup.getBoundsInLocal();
this.scrollPane
.setHvalue((valX + adjustment.getX()) / (updatedInnerBounds.getWidth() - viewportBounds.getWidth()));
this.scrollPane
.setVvalue((valY + adjustment.getY()) / (updatedInnerBounds.getHeight() - viewportBounds.getHeight()));
double zoomValue = this.page.getScaleX() * zoomFactor;
this.page.setScaleX(zoomValue);
this.page.setScaleY(zoomValue);
this.page.layout();
this.scrollPane.layout();
}
}
Upvotes: 0
Views: 86
Reputation: 159341
Solution
Convert coordinates to a common coordinate system, e.g. scene coordinates, then you can directly compare their positions relative to each other.
Sample Application Discussion
I lifted the code from here:
And modified it to report coordinates. Perhaps it reports the values you are looking for.
I didn't really understand your app code, so I did not use that.
There is a lot of code in this answer, but most of it is just copied from the linked answer.
The only new bit is the stuff that reports coordinates for the scene root, ScrollPane viewport, and individual nodes in the viewport.
private void reportCoords() {
Node viewport = scene.lookup(".viewport");
Bounds sceneBounds = scene.getRoot().getLayoutBounds();
Bounds viewportBoundsInScene = viewport.localToScene(viewport.getBoundsInLocal());
Bounds starBoundsInScene = star.localToScene(star.getBoundsInLocal());
Bounds curveBoundsInScene = star.localToScene(curve.getBoundsInLocal());
String alertText = """
Root: %s
Viewport: %s
Star: %s
Curve: %s
""".formatted(
formatBounds(sceneBounds),
formatBounds(viewportBoundsInScene),
formatBounds(starBoundsInScene),
formatBounds(curveBoundsInScene)
);
System.out.println(alertText);
Alert displayCoordsAlert = new Alert(
Alert.AlertType.INFORMATION,
alertText
);
displayCoordsAlert.initOwner(stage);
displayCoordsAlert.setHeaderText("Coordinate bounds in scene coordinates");
displayCoordsAlert.getDialogPane()
.lookup(".content")
.setStyle("-fx-font-family: monospace");
displayCoordsAlert.getDialogPane().setPrefSize(550, 200);
displayCoordsAlert.showAndWait();
}
private String formatBounds(Bounds bounds) {
return "minX: %5d, minY: %5d, maxX: %5d, maxY: %5d".formatted(
(int) bounds.getMinX(),
(int) bounds.getMinY(),
(int) bounds.getMaxX(),
(int) bounds.getMaxY()
);
}
To use it, run the app and either use the menu or press the r
key to generate an alert and sysout log reporting the current coordinates of interesting things (in scene coordinates).
Scene coordinates are used so that all coordinates are in the same coordinate system. You can use them to compare absolute positions, which, if I understood your question, is something you are trying to do.
The ScrollPane in the example is pannable and zoomable and that is taken into account when calculating the coordinates of items in the viewpoint. So scroll and pan to move the content around, then press the r
key to report the current scene coordinates of everything.
Transformation on node bounds to the scene coordinate system is done using this code:
star.localToScene(star.getBoundsInLocal())
If the node you want coordinates of is also transformed (then you may need to use getBoundsInParent()
rather than getBoundsInLocal()
). But in this case, it wasn't necessary. The root node in the scroll pane was transformed and not, directly, the nodes inside it. So each child node of the root has boundsInLocal and boundsInParent that are equivalent to each other.
Sample Application
import javafx.application.Application;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Bounds;
import javafx.geometry.Point2D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.SVGPath;
import javafx.scene.shape.StrokeLineJoin;
import javafx.stage.Stage;
public class GraphicsScalingApp extends Application {
public static void main(String[] args) {
launch(args);
}
private final Node star = createStar();
private final Node curve = createCurve();
private Stage stage;
private Scene scene;
@Override
public void start(final Stage stage) {
this.stage = stage;
final Group group = new Group(star, curve);
Parent zoomPane = createZoomPane(group);
VBox layout = new VBox();
layout.getChildren().setAll(createMenuBar(stage, group), zoomPane);
VBox.setVgrow(zoomPane, Priority.ALWAYS);
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((observable, oldValue, newValue) ->
zoomPane.setMinSize(newValue.getWidth(), newValue.getHeight())
);
scroller.setPrefViewportWidth(256);
scroller.setPrefViewportHeight(256);
zoomPane.setOnScroll(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(event ->
lastMouseCoordinates.set(new Point2D(event.getX(), event.getY()))
);
scrollContent.setOnMouseDragged(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(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(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(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(event -> {
group.setScaleX(group.getScaleX() * 1 / 1.5);
group.setScaleY(group.getScaleY() * 1 / 1.5);
});
zoomMenu.getItems().setAll(zoomResetMenuItem, zoomInMenuItem,
zoomOutMenuItem);
Menu coordsMenu = new Menu("_Coords");
MenuItem reportCoordsMenuItem = new MenuItem("_Report");
reportCoordsMenuItem.setAccelerator(new KeyCodeCombination(KeyCode.R));
reportCoordsMenuItem.setOnAction(event -> reportCoords());
coordsMenu.getItems().setAll(reportCoordsMenuItem);
MenuBar menuBar = new MenuBar();
menuBar.getMenus().setAll(fileMenu, zoomMenu, coordsMenu);
return menuBar;
}
private void reportCoords() {
Node viewport = scene.lookup(".viewport");
Bounds sceneBounds = scene.getRoot().getLayoutBounds();
Bounds viewportBoundsInScene = viewport.localToScene(viewport.getBoundsInLocal());
Bounds starBoundsInScene = star.localToScene(star.getBoundsInLocal());
Bounds curveBoundsInScene = star.localToScene(curve.getBoundsInLocal());
String alertText = """
Root: %s
Viewport: %s
Star: %s
Curve: %s
""".formatted(
formatBounds(sceneBounds),
formatBounds(viewportBoundsInScene),
formatBounds(starBoundsInScene),
formatBounds(curveBoundsInScene)
);
System.out.println(alertText);
Alert displayCoordsAlert = new Alert(
Alert.AlertType.INFORMATION,
alertText
);
displayCoordsAlert.initOwner(stage);
displayCoordsAlert.setHeaderText("Coordinate bounds in scene coordinates");
displayCoordsAlert.getDialogPane()
.lookup(".content")
.setStyle("-fx-font-family: monospace");
displayCoordsAlert.getDialogPane().setPrefSize(550, 200);
displayCoordsAlert.showAndWait();
}
private String formatBounds(Bounds bounds) {
return "minX: %5d, minY: %5d, maxX: %5d, maxY: %5d".formatted(
(int) bounds.getMinX(),
(int) bounds.getMinY(),
(int) bounds.getMaxX(),
(int) bounds.getMaxY()
);
}
// 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";
}
Upvotes: 4