Search code examples
javaswingscrollgraphics2dzooming

Java2D zoom and scroll relative to mouse position


I am trying to create a simple drawing application using swing and java2D. The aim is to achieve a smooth zoom, always relative to the mouse cursor point. The application consists of two classes: CanvasPane and Canvas.

  • CanvasPane class is a simple container with BorderLayout and JScrollPane in the center.
  • Canvas class is a drawing component added to a JScrollPane in CanvasPane. Canvas draws a simple rectangle[800x600], and dispatches it's mouse events (wheel and drag).

When rectangle is smaller then visibleRect, canvas size is equal to visibleRect and I call AffineTransform.translate to follow mouse (thanks to this question)

When rectangle grows bigger then canvas, canvas size grows too and became scrollable. Then I call scrollRectToVisible on it to follow mouse.

The question is: How to use translate and scrollRectToVisible together, to smooth scale without graphics jumps. May be there is some known decision?

What I want is perfectly realized in YED Graph Editor, but it's code is closed. I have tried with many examples but there were only zoom or scroll without complex usage of them.

Full code follows.

Class CanvasPane:

import javax.swing.*;
import java.awt.*;


public class CanvasPane extends JPanel {

    private Canvas canvas;

    public CanvasPane(boolean isDoubleBuffered) {
        super(isDoubleBuffered);
        setLayout(new BorderLayout());
        canvas = new Canvas(1.0);
        JScrollPane pane = new JScrollPane(canvas);
        pane.getViewport().setBackground(Color.DARK_GRAY);
        add(pane, BorderLayout.CENTER);
    }

    public static void main(String[] args) {
        JFrame frame = new JFrame("Test Graphics");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setLayout(new BorderLayout());
        frame.add(new CanvasPane(true), BorderLayout.CENTER);
        frame.setSize(new Dimension(1000, 800));
        frame.setVisible(true);
    }
}

Class Canvas:

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;


public class Canvas extends JComponent implements MouseWheelListener, MouseMotionListener, MouseListener {
    private double zoom = 1.0;
    public static final double SCALE_STEP = 0.1d;
    private Dimension initialSize;
    private Point origin;
    private double previousZoom = zoom;
    AffineTransform tx = new AffineTransform();
    private double scrollX = 0d;
    private double scrollY = 0d;
    private Rectangle2D rect = new Rectangle2D.Double(0,0, 800, 600);

    public Canvas(double zoom) {
        this.zoom = zoom;
        addMouseWheelListener(this);
        addMouseMotionListener(this);
        addMouseListener(this);
        setAutoscrolls(true);
    }

    public Dimension getInitialSize() {
        return initialSize;
    }

    @Override
    public void paintComponent(Graphics g) {
        super.paintComponent(g);
        Graphics2D g2 = (Graphics2D) g;
        Graphics2D g2d = (Graphics2D) g.create();
        g2d.clearRect(0, 0, getWidth(), getHeight());
        g2d.transform(tx);
        g2d.setColor(Color.DARK_GRAY);
        g2d.fill(rect);
        g2d.setColor(Color.GRAY);
        g2d.setStroke(new BasicStroke(5.0f));
        g2d.draw(rect);
        g2d.dispose();
    }

    @Override
    public void setSize(Dimension size) {
        super.setSize(size);
        if (initialSize == null) {
            this.initialSize = size;
        }
    }

    @Override
    public void setPreferredSize(Dimension preferredSize) {
        super.setPreferredSize(preferredSize);
        if (initialSize == null) {
            this.initialSize = preferredSize;
        }
    }

    public void mouseWheelMoved(MouseWheelEvent e) {
        double zoomFactor = - SCALE_STEP*e.getPreciseWheelRotation()*zoom;
        zoom = Math.abs(zoom + zoomFactor);
        //Here we calculate new size of canvas relative to zoom.
        Rectangle realView = getVisibleRect();
        Dimension d = new Dimension(
                (int)(initialSize.width*zoom),
                (int)(initialSize.height*zoom));
//        if (d.getWidth() >= realView.getWidth() && d.getHeight() >= realView.getHeight()) {
            setPreferredSize(d);
            setSize(d);
            validate();
            followMouseOrCenter(e);
//        }

        //Here we calculate transform for the canvas graphics to scale relative to mouse
            translate(e);
            repaint();
        previousZoom = zoom;
    }

    private void translate(MouseWheelEvent e) {
        Rectangle realView = getVisibleRect();
        Point2D p1 = e.getPoint();
        Point2D p2 = null;
        try {
            p2 = tx.inverseTransform(p1, null);
        } catch (NoninvertibleTransformException ex) {
            ex.printStackTrace();
            return;
        }
        Dimension d = getSize();
        if (d.getWidth() <= realView.getWidth() && d.getHeight() <= realView.getHeight()) {
            //Zooming and translating relative to the mouse position
            tx.setToIdentity();
            tx.translate(p1.getX(), p1.getY());
            tx.scale(zoom, zoom);
            tx.translate(-p2.getX(), -p2.getY());
        } else {
            //Only zooming, translate is not needed because scrollRectToVisible works;
            tx.setToIdentity();
            tx.scale(zoom, zoom);
        }
//        What to do next?
//        The only translation works when rect is smaller then canvas size.
//        Rect bigger then canvas must be scrollable, but relative to mouse position as before.
        // But when the rect gets bigger than canvas, there is a terrible jump of a graphics.
        //So there must be some combination of translation ans scroll to achieve a smooth scale.
        //... brain explosion(((
    }


    public void followMouseOrCenter(MouseWheelEvent e) {
        Point2D point = e.getPoint();
        Rectangle visibleRect = getVisibleRect();

        scrollX = point.getX()/previousZoom*zoom - (point.getX()-visibleRect.getX());
        scrollY = point.getY()/previousZoom*zoom - (point.getY()-visibleRect.getY());

        visibleRect.setRect(scrollX, scrollY, visibleRect.getWidth(), visibleRect.getHeight());
        scrollRectToVisible(visibleRect);
    }

    public void mouseDragged(MouseEvent e) {
        if (origin != null) {
            int deltaX = origin.x - e.getX();
            int deltaY = origin.y - e.getY();
            Rectangle view = getVisibleRect();
            Dimension size = getSize();
            view.x += deltaX;
            view.y += deltaY;
            scrollRectToVisible(view);
        }
    }

    public void mouseMoved(MouseEvent e) {
    }

    public void mouseClicked(MouseEvent e) {
    }

    public void mousePressed(MouseEvent e) {
        origin = new Point(e.getPoint());
    }

    public void mouseReleased(MouseEvent e) {

    }

    public void mouseEntered(MouseEvent e) {

    }

    public void mouseExited(MouseEvent e) {

    }

}

Solution

  • I have finally achieved the enlightenment=)

    We can simply make the canvas size much bigger than size of a drawing object, and so forget about calculating of any unintelligible transforms.

    I initially make the canvas 100x bigger than drawing rectangle. Then I zoom Graphics2D and translate zoomed graphics to the center of the canvas while painting. Next, I calculate a new visibleRect to follow mouse point and scroll to it.

    When canvas became unscrollable, it's unreasonable to follow mouse because the drawing object is too small (100x smaller then its initial size), so I only center it to be always visible. It works exactly as I needed.

    So we have a working example with zoom following mouse and drag by mouse. Code follows.

    Class CanvasPane:

    import javax.swing.*;
    import java.awt.*;
    
    
    public class CanvasPane extends JPanel {
    
        private static Canvas canvas;
    
        public CanvasPane(boolean isDoubleBuffered) {
            super(isDoubleBuffered);
            setLayout(new BorderLayout());
            canvas = new Canvas(1.0);
            JScrollPane pane = new JScrollPane(canvas);
            pane.getViewport().setBackground(Color.DARK_GRAY);
            add(pane, BorderLayout.CENTER);
        }
    
        public static void main(String[] args) {
            JFrame frame = new JFrame("Test Graphics");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setLayout(new BorderLayout());
            frame.add(new CanvasPane(true), BorderLayout.CENTER);
            frame.setSize(new Dimension(1000, 800));
            frame.pack();
            frame.setVisible(true);
    
            //Initial scrolling of the canvas to its center
            Rectangle rect = canvas.getBounds();
            Rectangle visibleRect = canvas.getVisibleRect();
            double tx = (rect.getWidth() - visibleRect.getWidth())/2;
            double ty = (rect.getHeight() - visibleRect.getHeight())/2;
            visibleRect.setBounds((int)tx, (int)ty, visibleRect.width, visibleRect.height);
            canvas.scrollRectToVisible(visibleRect);
        }
    }
    

    Class Canvas:

    import javax.swing.*;
    import java.awt.*;
    import java.awt.event.*;
    import java.awt.geom.Point2D;
    import java.awt.geom.Rectangle2D;
    
    
    public class Canvas extends JComponent implements MouseWheelListener, MouseMotionListener, MouseListener {
        private double zoom = 1.0;
        public static final double SCALE_STEP = 0.1d;
        private Dimension initialSize;
        private Point origin;
        private double previousZoom = zoom;
        private double scrollX = 0d;
        private double scrollY = 0d;
        private Rectangle2D rect = new Rectangle2D.Double(0,0, 800, 600);
        private float hexSize = 3f;
    
        public Canvas(double zoom) {
            this.zoom = zoom;
            addMouseWheelListener(this);
            addMouseMotionListener(this);
            addMouseListener(this);
            setAutoscrolls(true);
    
            //Set preferred size to be 100x bigger then drawing object
            //So the canvas will be scrollable until our drawing object gets 100x smaller then its natural size.
            //When the drawing object became so small, it is unnecessary to follow mouse on it,
            //and we only center it on the canvas
    
            setPreferredSize(new Dimension((int)(rect.getWidth()*100), (int)(rect.getHeight()*100)));
        }
    
        @Override
        public void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2 = (Graphics2D) g;
    
            //Obtain a copy of graphics object without any transforms
            Graphics2D g2d = (Graphics2D) g.create();
            g2d.clearRect(0, 0, getWidth(), getHeight());
    
            //Zoom graphics
            g2d.scale(zoom, zoom);
    
            //translate graphics to be always in center of the canvas
            Rectangle size = getBounds();
            double tx = ((size.getWidth() - rect.getWidth() * zoom) / 2) / zoom;
            double ty = ((size.getHeight() - rect.getHeight() * zoom) / 2) / zoom;
            g2d.translate(tx, ty);
    
            //Draw
            g2d.setColor(Color.LIGHT_GRAY);
            g2d.fill(rect);
            g2d.setColor(Color.DARK_GRAY);
            g2d.setStroke(new BasicStroke(5.0f));
            g2d.draw(rect);
    
            //Forget all transforms
            g2d.dispose();
        }
    
        @Override
        public void setSize(Dimension size) {
            super.setSize(size);
            if (initialSize == null) {
                this.initialSize = size;
            }
        }
    
        @Override
        public void setPreferredSize(Dimension preferredSize) {
            super.setPreferredSize(preferredSize);
            if (initialSize == null) {
                this.initialSize = preferredSize;
            }
        }
    
        public void mouseWheelMoved(MouseWheelEvent e) {
            double zoomFactor = - SCALE_STEP*e.getPreciseWheelRotation()*zoom;
            zoom = Math.abs(zoom + zoomFactor);
            //Here we calculate new size of canvas relative to zoom.
            Dimension d = new Dimension(
                    (int)(initialSize.width*zoom),
                    (int)(initialSize.height*zoom));
                setPreferredSize(d);
                setSize(d);
                validate();
            followMouseOrCenter(e.getPoint());
            previousZoom = zoom;
        }
    
        public void followMouseOrCenter(Point2D point) {
            Rectangle size = getBounds();
            Rectangle visibleRect = getVisibleRect();
            scrollX = size.getCenterX();
            scrollY = size.getCenterY();
            if (point != null) {
                scrollX = point.getX()/previousZoom*zoom - (point.getX()-visibleRect.getX());
                scrollY = point.getY()/previousZoom*zoom - (point.getY()-visibleRect.getY());
            }
    
            visibleRect.setRect(scrollX, scrollY, visibleRect.getWidth(), visibleRect.getHeight());
            scrollRectToVisible(visibleRect);
        }
    
        public void mouseDragged(MouseEvent e) {
            if (origin != null) {
                int deltaX = origin.x - e.getX();
                int deltaY = origin.y - e.getY();
                Rectangle view = getVisibleRect();
                view.x += deltaX;
                view.y += deltaY;
                scrollRectToVisible(view);
            }
        }
    
        public void mouseMoved(MouseEvent e) {
        }
    
        public void mouseClicked(MouseEvent e) {
        }
    
        public void mousePressed(MouseEvent e) {
            origin = new Point(e.getPoint());
        }
    
        public void mouseReleased(MouseEvent e) {
        }
    
        public void mouseEntered(MouseEvent e) {
        }
    
        public void mouseExited(MouseEvent e) {
        }
    
    }