I'm trying to code a zoom-able image in a JScrollPane.
When the image is fully zoomed out it should be centered horizontally and vertically. When both scroll bars have appeared the zooming should always happen relative to the mouse coordinate, i.e. the same point of the image should be under the mouse before and after the zoom event.
I have almost achieves my goal. Unfortunately the "scrollPane.getViewport().setViewPosition()" method sometimes fails to update the view position correctly. Calling the method twice (hack!) overcomes the issue in most cases, but the view still flickers.
I have no explanation as to why this is happening. However I'm confident that it's not a math problem.
Below is a MWE. To see what my problem is in particular you can do the following:
- Zoom in until you have some scroll bars (200% zoom or so)
- Scroll into the bottom right corner by clicking the scroll bars
- Place the mouse in the corner and zoom in twice. The second time you'll see how the scroll position jumps towards the center.
I would really appreciate if someone could tell me where the problem lies. Thank you!
package com.vitco;
import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseWheelEvent;
import java.awt.image.BufferedImage;
import java.util.Random;
/**
* Zoom-able scroll panel test case
*/
public class ZoomScrollPanel {
// the size of our image
private final static int IMAGE_SIZE = 600;
// create an image to display
private BufferedImage getImage() {
BufferedImage image = new BufferedImage(IMAGE_SIZE, IMAGE_SIZE, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
// draw the small pixel first
Random rand = new Random();
for (int x = 0; x < IMAGE_SIZE; x += 10) {
for (int y = 0; y < IMAGE_SIZE; y += 10) {
g.setColor(new Color(rand.nextInt(255),rand.nextInt(255),rand.nextInt(255)));
g.fillRect(x, y, 10, 10);
}
}
// draw the larger transparent pixel second
for (int x = 0; x < IMAGE_SIZE; x += 100) {
for (int y = 0; y < IMAGE_SIZE; y += 100) {
g.setColor(new Color(rand.nextInt(255),rand.nextInt(255),rand.nextInt(255), 180));
g.fillRect(x, y, 100, 100);
}
}
return image;
}
// the image panel that resizes according to zoom level
private class ImagePanel extends JPanel {
private final BufferedImage image = getImage();
@Override
public void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D)g.create();
g2.scale(scale, scale);
g2.drawImage(image, 0, 0, null);
g2.dispose();
}
@Override
public Dimension getPreferredSize() {
return new Dimension((int)Math.round(IMAGE_SIZE * scale), (int)Math.round(IMAGE_SIZE * scale));
}
}
// the current zoom level (100 means the image is shown in original size)
private double zoom = 100;
// the current scale (scale = zoom/100)
private double scale = 1;
// the last seen scale
private double lastScale = 1;
public void alignViewPort(Point mousePosition) {
// if the scale didn't change there is nothing we should do
if (scale != lastScale) {
// compute the factor by that the image zoom has changed
double scaleChange = scale / lastScale;
// compute the scaled mouse position
Point scaledMousePosition = new Point(
(int)Math.round(mousePosition.x * scaleChange),
(int)Math.round(mousePosition.y * scaleChange)
);
// retrieve the current viewport position
Point viewportPosition = scrollPane.getViewport().getViewPosition();
// compute the new viewport position
Point newViewportPosition = new Point(
viewportPosition.x + scaledMousePosition.x - mousePosition.x,
viewportPosition.y + scaledMousePosition.y - mousePosition.y
);
// update the viewport position
// IMPORTANT: This call doesn't always update the viewport position. If the call is made twice
// it works correctly. However the screen still "flickers".
scrollPane.getViewport().setViewPosition(newViewportPosition);
// debug
if (!newViewportPosition.equals(scrollPane.getViewport().getViewPosition())) {
System.out.println("Error: " + newViewportPosition + " != " + scrollPane.getViewport().getViewPosition());
}
// remember the last scale
lastScale = scale;
}
}
// reference to the scroll pane container
private final JScrollPane scrollPane;
// constructor
public ZoomScrollPanel() {
// initialize the frame
JFrame frame = new JFrame();
frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
frame.setSize(600, 600);
// initialize the components
final ImagePanel imagePanel = new ImagePanel();
final JPanel centerPanel = new JPanel();
centerPanel.setLayout(new GridBagLayout());
centerPanel.add(imagePanel);
scrollPane = new JScrollPane(centerPanel);
scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS);
frame.add(scrollPane);
// add mouse wheel listener
imagePanel.addMouseWheelListener(new MouseAdapter() {
@Override
public void mouseWheelMoved(MouseWheelEvent e) {
super.mouseWheelMoved(e);
// check the rotation of the mousewheel
int rotation = e.getWheelRotation();
boolean zoomed = false;
if (rotation > 0) {
// only zoom out until no scrollbars are visible
if (scrollPane.getHeight() < imagePanel.getPreferredSize().getHeight() ||
scrollPane.getWidth() < imagePanel.getPreferredSize().getWidth()) {
zoom = zoom / 1.3;
zoomed = true;
}
} else {
// zoom in until maximum zoom size is reached
double newCurrentZoom = zoom * 1.3;
if (newCurrentZoom < 1000) { // 1000 ~ 10 times zoom
zoom = newCurrentZoom;
zoomed = true;
}
}
// check if a zoom happened
if (zoomed) {
// compute the scale
scale = (float) (zoom / 100f);
// align our viewport
alignViewPort(e.getPoint());
// invalidate and repaint to update components
imagePanel.revalidate();
scrollPane.repaint();
}
}
});
// display our frame
frame.setVisible(true);
}
// the main method
public static void main(String[] args) {
new ZoomScrollPanel();
}
}
Note: I have also looked at the question here JScrollPane setViewPosition After "Zoom" but unfortunately the problem and solution are slightly different and do not apply.
Edit
I have solved the issue by using a hack, however I'm still no closer to understanding as to what the underlying problem is. What is happening is that when the setViewPosition is called some internal state changes trigger additional calls to setViewPosition. These additional calls only happen occasionally. When I'm blocking them everything works perfectly.
To fix the problem I simply introduced a new boolean variable "blocked = false;" and replaced the lines
scrollPane = new JScrollPane(centerPanel);
and
scrollPane.getViewport().setViewPosition(newViewportPosition);
with
scrollPane = new JScrollPane();
scrollPane.setViewport(new JViewport() {
private boolean inCall = false;
@Override
public void setViewPosition(Point pos) {
if (!inCall || !blocked) {
inCall = true;
super.setViewPosition(pos);
inCall = false;
}
}
});
scrollPane.getViewport().add(centerPanel);
and
blocked = true;
scrollPane.getViewport().setViewPosition(newViewportPosition);
blocked = false;
I would still really appreciate if someone could make sense of this!
Why does this hack work? Is there a cleaner way to achieve the same functionality?
MouseWheelEvent
occurs at x positions 105, 136, 177, 230 etc, which is the 1.3 zoom factor (y scales the same). You call this behavior intended. (2) I put my mouse on a bottom-right corner and zoom in. The x coordinates I get are 537, 698, 907 etc. - 1.3 factor. However, between 537 and 698 you print the line x=151 != x=34. You have one coordinate system which is scaling and one which isn't, and you're trying to adjust between them. – Inamorata