Skip to content

Instantly share code, notes, and snippets.

@james-d
Last active September 6, 2023 11:42
Show Gist options
  • Star 22 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save james-d/ce5ec1fd44ce6c64e81a to your computer and use it in GitHub Desktop.
Save james-d/ce5ec1fd44ce6c64e81a to your computer and use it in GitHub Desktop.
Zoomable and Pannable JavaFX ImageView Example
import javafx.application.Application;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class PlutoExplorer extends Application {
private static final String IMAGE_CREDIT_URL = "http://www.nasa.gov/image-feature/global-mosaic-of-pluto-in-true-color";
private static final String IMAGE_URL = "https://www.nasa.gov/sites/default/files/thumbnails/image/global-mosaic-of-pluto-in-true-color.jpg";
private static final int MIN_PIXELS = 10;
@Override
public void start(Stage primaryStage) {
Image image = new Image(IMAGE_URL);
double width = image.getWidth();
double height = image.getHeight();
ImageView imageView = new ImageView(image);
imageView.setPreserveRatio(true);
reset(imageView, width / 2, height / 2);
ObjectProperty<Point2D> mouseDown = new SimpleObjectProperty<>();
imageView.setOnMousePressed(e -> {
Point2D mousePress = imageViewToImage(imageView, new Point2D(e.getX(), e.getY()));
mouseDown.set(mousePress);
});
imageView.setOnMouseDragged(e -> {
Point2D dragPoint = imageViewToImage(imageView, new Point2D(e.getX(), e.getY()));
shift(imageView, dragPoint.subtract(mouseDown.get()));
mouseDown.set(imageViewToImage(imageView, new Point2D(e.getX(), e.getY())));
});
imageView.setOnScroll(e -> {
double delta = e.getDeltaY();
Rectangle2D viewport = imageView.getViewport();
double scale = clamp(Math.pow(1.01, delta),
// don't scale so we're zoomed in to fewer than MIN_PIXELS in any direction:
Math.min(MIN_PIXELS / viewport.getWidth(), MIN_PIXELS / viewport.getHeight()),
// don't scale so that we're bigger than image dimensions:
Math.max(width / viewport.getWidth(), height / viewport.getHeight())
);
Point2D mouse = imageViewToImage(imageView, new Point2D(e.getX(), e.getY()));
double newWidth = viewport.getWidth() * scale;
double newHeight = viewport.getHeight() * scale;
// To keep the visual point under the mouse from moving, we need
// (x - newViewportMinX) / (x - currentViewportMinX) = scale
// where x is the mouse X coordinate in the image
// solving this for newViewportMinX gives
// newViewportMinX = x - (x - currentViewportMinX) * scale
// we then clamp this value so the image never scrolls out
// of the imageview:
double newMinX = clamp(mouse.getX() - (mouse.getX() - viewport.getMinX()) * scale,
0, width - newWidth);
double newMinY = clamp(mouse.getY() - (mouse.getY() - viewport.getMinY()) * scale,
0, height - newHeight);
imageView.setViewport(new Rectangle2D(newMinX, newMinY, newWidth, newHeight));
});
imageView.setOnMouseClicked(e -> {
if (e.getClickCount() == 2) {
reset(imageView, width, height);
}
});
Hyperlink link = new Hyperlink("Image Credit: NASA/JHUAPL/SwRI");
link.setOnAction(e -> getHostServices().showDocument(IMAGE_CREDIT_URL));
link.setPadding(new Insets(10));
link.setTooltip(new Tooltip(IMAGE_CREDIT_URL));
HBox buttons = createButtons(width, height, imageView);
Tooltip tooltip = new Tooltip("Scroll to zoom, drag to pan");
Tooltip.install(buttons, tooltip);
Pane container = new Pane(imageView);
container.setPrefSize(800, 600);
imageView.fitWidthProperty().bind(container.widthProperty());
imageView.fitHeightProperty().bind(container.heightProperty());
VBox root = new VBox(link, container, buttons);
root.setFillWidth(true);
VBox.setVgrow(container, Priority.ALWAYS);
primaryStage.setScene(new Scene(root));
primaryStage.setTitle("Pluto explorer");
primaryStage.show();
}
private HBox createButtons(double width, double height, ImageView imageView) {
Button reset = new Button("Reset");
reset.setOnAction(e -> reset(imageView, width / 2, height / 2));
Button full = new Button("Full view");
full.setOnAction(e -> reset(imageView, width, height));
HBox buttons = new HBox(10, reset, full);
buttons.setAlignment(Pos.CENTER);
buttons.setPadding(new Insets(10));
return buttons;
}
// reset to the top left:
private void reset(ImageView imageView, double width, double height) {
imageView.setViewport(new Rectangle2D(0, 0, width, height));
}
// shift the viewport of the imageView by the specified delta, clamping so
// the viewport does not move off the actual image:
private void shift(ImageView imageView, Point2D delta) {
Rectangle2D viewport = imageView.getViewport();
double width = imageView.getImage().getWidth() ;
double height = imageView.getImage().getHeight() ;
double maxX = width - viewport.getWidth();
double maxY = height - viewport.getHeight();
double minX = clamp(viewport.getMinX() - delta.getX(), 0, maxX);
double minY = clamp(viewport.getMinY() - delta.getY(), 0, maxY);
imageView.setViewport(new Rectangle2D(minX, minY, viewport.getWidth(), viewport.getHeight()));
}
private double clamp(double value, double min, double max) {
if (value < min)
return min;
if (value > max)
return max;
return value;
}
// convert mouse coordinates in the imageView to coordinates in the actual image:
private Point2D imageViewToImage(ImageView imageView, Point2D imageViewCoordinates) {
double xProportion = imageViewCoordinates.getX() / imageView.getBoundsInLocal().getWidth();
double yProportion = imageViewCoordinates.getY() / imageView.getBoundsInLocal().getHeight();
Rectangle2D viewport = imageView.getViewport();
return new Point2D(
viewport.getMinX() + xProportion * viewport.getWidth(),
viewport.getMinY() + yProportion * viewport.getHeight());
}
public static void main(String[] args) {
launch(args);
}
}
@DaTeL237
Copy link

DaTeL237 commented May 2, 2016

Nice example!

It would be a nice improvement if the ImageView grew to fit its container when zoomed in.
For example:

  1. Run your code
  2. Make the window much wider
  3. Make sure the view is zoomed in

It would be nice if the imageview would now span the entire width of the container, rather than remaining the original aspect ratio

@clarkbean710
Copy link

If you change the event handler like this it will adjust the viewport proportions to match the ImageView size:

    imageView.setOnScroll(e -> {
        double delta = e.getDeltaY();
        Rectangle2D viewport = imageView.getViewport();

        double scale = clamp(Math.pow(1.005, delta),  // altered the value from 1.01to zoom slower
                // don't scale so we're zoomed in to fewer than MIN_PIXELS in any direction:
                Math.min(MIN_PIXELS / viewport.getWidth(), MIN_PIXELS / viewport.getHeight()),
                // don't scale so that we're bigger than image dimensions:
                Math.max(width / viewport.getWidth(), height / viewport.getHeight())
        );
        if (scale != 1.0) {
            Point2D mouse = imageViewToImage(imageView, new Point2D(e.getX(), e.getY()));

            double newWidth = viewport.getWidth();
            double newHeight = viewport.getHeight();
            double imageViewRatio = (imageView.getFitWidth() / imageView.getFitHeight());
            double viewportRatio = (newWidth / newHeight);
            if (viewportRatio < imageViewRatio) {
                // adjust width to be proportional with height
                newHeight = newHeight * scale;
                newWidth = newHeight * imageViewRatio;
                if (newWidth > image.getWidth()) {
                    newWidth = image.getWidth();
                }
            } else {
                // adjust height to be proportional with width
                newWidth = newWidth * scale;
                newHeight = newWidth / imageViewRatio;
                if (newHeight > image.getHeight()) {
                    newHeight = image.getHeight();
                }
            }

            // To keep the visual point under the mouse from moving, we need
            // (x - newViewportMinX) / (x - currentViewportMinX) = scale
            // where x is the mouse X coordinate in the image
            // solving this for newViewportMinX gives
            // newViewportMinX = x - (x - currentViewportMinX) * scale
            // we then clamp this value so the image never scrolls out
            // of the imageview:
            double newMinX = 0;
            if (newWidth < image.getWidth()) {
                newMinX = clamp(mouse.getX() - (mouse.getX() - viewport.getMinX()) * scale,
                        0, width - newWidth);
            }
            double newMinY = 0;
            if (newHeight < image.getHeight()) {
                newMinY = clamp(mouse.getY() - (mouse.getY() - viewport.getMinY()) * scale,
                        0, height - newHeight);
            }

            imageView.setViewport(new Rectangle2D(newMinX, newMinY, newWidth, newHeight));
        }
    });

@ajeje93
Copy link

ajeje93 commented Jul 3, 2017

@clarkbean710 Using your solution, the imageview keeps jumping from side to side when dragging the image. Do you know how can I solve this problem?

Edit: I solved by adjusting MIN_PIXELS to the width of my window

Edit 2: I have a small correction for the shift method that avoid problems when dragging the imageview with the solution from @clarkbean710

private void shift(ImageView imageView, Point2D delta) {
		Rectangle2D viewport = imageView.getViewport();
		double width = imageView.getImage().getWidth();
		double height = imageView.getImage().getHeight();
		double maxX = width - viewport.getWidth();
		double maxY = height - viewport.getHeight();
		double minX = clamp(viewport.getMinX() - delta.getX(), 0, maxX);
		double minY = clamp(viewport.getMinY() - delta.getY(), 0, maxY);
		if (minX < 0.0) {
			minX = 0.0;
		}
		if (minY < 0.0) {
			minY = 0.0;
		}
		imageView.setViewport(new Rectangle2D(minX, minY, viewport.getWidth(), viewport.getHeight()));
	}

@bcdrake
Copy link

bcdrake commented Nov 8, 2017

Doesn't work for images that end up being larger than the window can accommodate. Seems silly to only be able to pan around images that are less than your screen's resolution.

@james-d
Copy link
Author

james-d commented May 1, 2020 via email

@piratack007
Copy link

It's due to people like you, that i'am learning a lot, thanks a lot sir

@swardana
Copy link

This example is work's like magic, thank you for sharing this.
However, I would like to use MVP architecture. Do anyone know how to move the scrollEvent, mousePressed, and mouseDragged to the presenter?
or zooming and panning the image is should on the view?

@DanskerDave
Copy link

Hi there James-D,
I've reworked PlutoExplorer, added some more structure, loads of comments & Javadoc and corrected a small bug.
There are a number of detail improvements.
The bug was a superfluous line of code in the OnMouseDragged Event-Handler:
mouseDown.set(imageViewToImage(imageView, new Point2D(e.getX(), e.getY())));
It works fine without it. If its unclear why, contact me for a detailed explanation.
I've tried to separate the GUI from the Controller Logic a bit, so maybe this addresses the comment from 23.Sep.2020 of swardana.
Its still a way from being a fully fledged MVC structure, but it is only an example after all.
PlutoExplorerExtended.java can be found here.
All the best,
DanskerDave

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment