Basic Image Transformation

(OSX 10.11, Xcode 8)

Create a new app: drop an NSImageView and two buttons into the storyboard, set up an outlet for the image view and two actions – zoomIn and zoomOut for the buttons.

0) Not recommended: scaleUnitSquare

@IBAction func zoomOut(_ sender: AnyObject) {
imageView.scaleUnitSquare(to: NSSize(width: 0.4, height: 0.4))
}

This works perfectly fine. Unfortunately, the counterpart – where you increase the size – only goes up to the original size of your imageView.

To my surprise, all of the code in this article works without imageView.needsDisplay = true – I will update this post if I find out whether one should use it (to be a good citizen) or avoid it (to simplify matters).

1) Using CGAffineTransform

Layer transformations

a) On iOS, you get a .transform property on UIView for free. On macOS, this property is missing – macOS views are not, by default, layer-backed, so you need to add that layer first.

override func viewDidLoad() {
super.viewDidLoad()
imageView.wantsLayer = true
}

(you can also use a specific NSImageView subclass that overrides this property).

b) Now you can access CALayer, and immediately, you run into the first oddity: the layer’s ‘transform’ property is of type CATransform3D.

let zoomIn3D = CATransform3DMakeScale(2, 2, 1)
imageView.layer?.transform = zoomIn3D

Playing in 3D is overkill, but I’m leaving this here for the sake of completion.

c) CALayer has a setAffineTransform method, which takes an instance of CGAffineTransform as an argument, so

let zoom = CGAffineTransform(scaleX: 2, y: 2)
imageView.layer?.setAffineTransform(zoom)

lets you zoom in.

Unfortunately, AffineTransforms are like Highlander: there can only be one.

If you run

let zoom = CGAffineTransform(scaleX: 2, y: 2)
imageView.layer?.setAffineTransform(zoom)
let translate = CGAffineTransform(translationX: -256, y: -256)
imageView.layer?.setAffineTransform(translate)

in this order, you get the translation, if you swap them around, you get the zoom.

2) Stack ’em:
a) In ObjectiveC, you would use CGAffineTransformConcat to combine two CGAffineTransform instances. This has been replaced by an instance method, so

let zoom = CGAffineTransform(scaleX: 2, y: 2)
let translate = CGAffineTransform(translationX: -256, y: -256)
let concat = zoom.concatenating(translate)
imageView.layer?.setAffineTransform(concat)

b) It becomes more awkward when you want more than two transformations, because you can only concatenate two at a time.

let rotate = CGAffineTransform(rotationAngle: CGFloat.pi / 4)
let concat2 = concat.concatenating(rotate)
imageView.layer?.setAffineTransform(concat2)

This works, but it’s needlessly complex.

3) Stack ’em high:

http://stackoverflow.com/questions/30929986/how-to-apply-multiple-transforms-in-swift has the answer to this: you can stack CGAffineTransforms (or chain them directly), so

var t = CGAffineTransform.identity
t = t.translatedBy(x: -100, y: -300)
t = t.rotated(by: CGFloat.pi / 4)
t = t.scaledBy(x: 2, y: 2)

imageView.layer?.setAffineTransform(t)

or

let transform = CGAffineTransform(scaleX: 2, y: 2.0).rotated(by: CGFloat.pi / 4).translatedBy(x: -100, y: -200)

imageView.layer?.setAffineTransform(transform)

will give you a chain of transformations.

4) Put it back:

imageView.layer?.setAffineTransform(CGAffineTransform.identity)

will reset everything to its initial state.

5) Play with Autolayout
(this is me being pessimistic).

ScreenShot2017-01-04at14.01.56-2016-12-5-21-42.pngScreenShot2017-01-04at14.02.10-2016-12-5-21-42.png

As you can see, while the ‘zoom’ part functions nicely, the image view expands to take up all available space in the window. Since I have stacked
let zoom = CGAffineTransform(scaleX: 2, y: 2)
let translate = CGAffineTransform(translationX: -256, y: -256)
this should be no great surprise.

a) Comment out the translation (this was to achieve the effect of moving into a specific point in the picture), and set the height and width for your image view. Build and run. It will behave as before and expand to take up all available space.
b) Set the content hugging priority to 1000. This should, in theory, stop the imageView from expanding. Spoiler: it won’t.
c) Delete the height and width constraints, and pin the view to the edges of its superview, leaving ample space (50-100pts). Whenever I try this in Xcode 8/10.11, my window acquires epic proportions – about twice as wide as my screen – and the view grows beyond all expectations, and that’s _with_ the imageView’s scaling mode set to ‘propotionally down’. Right now, I’m cautiously filing this as ‘autolayout happens’.
ScreenShot2017-01-04at14.26.11-2016-12-5-21-42.png
Testing this with a 100x100px image, it appears that setting the outer constraints for the image view make it display the image at its full size. Since I wasn’t scaling down the picture above for display, this is the result. This isn’t expected (or wanted) behaviour, but right now, there seems to be no way to stop it other than not using autolayout. (The ‘scale’ settings – including ‘none’ make no difference: when autolayout calculates the size of an image view, it seems to always use the original image size and force the window to obey it. I do not remember this from previous apps, but I may never have used these partiular seetings – autolayout constraints to top, bottom, leading, trailing in combination with an oversized image. Will test on 10.12 and the latest Xcode before filing a bug.)

Remove the constraints. Even just setting height and width on the image view and setting an additional constraint, priority 1000, to the top of the view does not have the desired effect – the imageView still spills over.

6) Embed the imageView in a customView Editor->Embed in ->CustomView) and create an outlet for it in the viewController

@IBOutlet weak var outerView: NSView!

Set the size of the outerView to be the same as the imageView, and create constraints pinning it into place for the leading, trailing, top and bottom space to the superview.

Build and run: Rejoice.
ScreenShot2017-01-04at15.41.56-2016-12-5-21-42.png
Now the view stays where it is supposed to be, while the contents get transformed.

Alternatively, you can use NSScrollView.

Using ScrollViews

7) For a different behaviour, embed your image view in a scoll view (Editor->Embed in -> Scroll view)

a) Build and run.
The size of the view now remains consistent, but the moment you actually try to scroll, the zoom factor jumps back to 1.

When you set ‘allowMagnification’ in IB, the behaviour persists; if you zoom in manually (gestures/scroll view), the scroll view behaves as expected and lets you scroll around the zoomed-in image.

b) create an outlet for your scroll view, comment out the transformation code, and replace the zoom function with

let zoomPoint: NSPoint = NSPoint(x: -100, y: -300)
scrollView.setMagnification(2.0, centeredAt: zoomPoint)

Build and run. Gloat.
ScreenShot2017-01-04at15.19.40-2016-12-5-21-42.png

For some reason, the scrollView’s magnification persists between application runs, so you might want to do some housekeeping in viewDidLoad to reset the magnification to

scrollView.magnify(toFit: scrollView.bounds)

c) try to break it: set the magification factor in the zoomOut function to 0. It will zoom out to the minimum factor you’ve set, whatever that is. (Default 0.4, but you can play with the values.)

And more…

Usage Example

I’ve worked this out because I wanted to create the illusion that the user is moving into an image (as a low-key solution rather than using animation).
Magnifying a scrollView, on the other hand, it a basic function of drawing applications.

Alternatives

Whether you use CGAffineTransform or manually magnify a scrollView depends on what you want to achieve.
Depending on what you are trying to achieve, you might wish to look into SpriteKit as an alternative framework for manipulating individual interface elements.

Extensions

For scrollViews, you might want to provide different content once the user has zoomed to a certain point (think maps: they are not magnified indefinitely, but get replaced by a more general or more detailed map.
The CGAffineTransforms discussed here are only the tip of the iceberg that is CGLayer, with all the potential for animation and content filters that layers offer.