I'm working on an iOS app that has a drawing surface (specific knowledge of iOS/Core Graphics isn't necessary as the problem just concerns transforms). It supports paths and images. It also supports panning (effectively infinite in all directions), and zooming (10% - 1000%). The origin of the user coordinate space is the upper left corner. This means that whenever you zoom, shapes "pull" or gravitate towards this origin at the top left. This does not result in a great user experience when you're panning around to shapes that are farther away from the origin, you zoom in, and the shapes drift way off screen towards the origin. What I want is for zooming always to focus on the centre of the screen. i.e. No matter how far away you've panned away from the original origin, zooming in/out should always been relative to the current centre of the screen.
I have successfully achieved a movable origin where zooming does happen relative to the centre of the screen. However, it adversely affects the speed of panning when zoomed far in or out. e.g. When zoomed far out, panning is very slow, and when zoomed in, panning is too fast. In other words, the speed of panning does not follow the speed of your finger when panning over the screen. Here is a visual of what's happening:
Sample 1
Notice that the origin stays at the centre of the screen and that zooming in/out is relative to the origin even when panning away. When zoomed out, you can see how slow panning is. Here is another video that shows a consistent panning speed, but the origin is no longer fixed to the centre of the screen, so zooming in/out is no longer relative to the centre:
Sample 2
In short, the second video illustrates correct panning but incorrect zooming, where the first video shows correct zooming but incorrect panning. :/
Here's how I've approached this problem so far:
- As the user pans the screen, I need to move the origin of the graphics context to keep it centred in the screen. This offset value is updated whenever panning is in progress with the absolute value of the panning position. (I'll explain the translation matrix shortly.)
1 2 3 4 5 6 7 8 | var offset: CGPoint = .zero { didSet { // bounds is the bounding rect of the screen. tOriginOffset = CGAffineTransform(translationX: bounds.width/2.0 - offset.x, y: bounds.height/2.0 - offset.y) calculateTranslationMatrix() setNeedsDisplay() } } |
- As the user zooms in/out, update the scaling transform accordingly.
1 2 3 4 5 6 7 8 | var scale: CGFloat = 1.0 { didSet { scale = max(min(scale, QLCanvasView.MAX_SCALING), QLCanvasView.MIN_SCALING) tScaling = CGAffineTransform(scaleX: scale, y: scale) calculateTranslationMatrix() setNeedsDisplay() } } |
- Since I'm applying an offset to the graphics context in order to move the origin to the centre of the screen, I need to compensate for this when drawing actual paths and images so they appear in the right place on the canvas. Hence a separate translation matrix is applied to paths and images to move them "back" to where they should be.
1 2 3 4 5 6 7 | func calculateTranslationMatrix() { // NOTE: Factor in scale in translation offset so that panning stays at a consistent speed. But it doesn't work as I expected. let scaledOffset = offset * (1.0/scale) let tx = (scaledOffset.x - tOriginOffset.tx) let ty = (scaledOffset.y - tOriginOffset.ty) tTranslation = CGAffineTransform(translationX: tx, y: ty) } |
- Finally during drawing, I concatenate the origin offset and scaling matrices together and apply them to the graphics context, which affects all elements drawn to it. All paths will then have the translation matrix applied to it to compensate for the translation of the graphics context.
1 2 3 4 | let ctm = tScaling.concatenating(tOriginOffset) context.concatenate(ctm) // effectively ctm = tScaling * tOriginOffset //... path.move(to: stroke.origin, transform: tTranslation) |
To me it seems like it's a problem with the translation matrix that I'm applying to individual paths/images. I've attempted to scale it according to the zoom scale as you can see in the function above, but it obviously isn't right. What am I missing here? Or am I approaching this totally in the wrong way?