TIL by Ed

Writing up things I’ve learnt.

Zooming With UIScrollView

I had the need to debug some code recently that delt with zooming on a UIScrollView. Never implemented something like this before, but turns out the basics to get started are very simple.

Some basic setup

Lets setup a simple view called ZoomView with a scroll view with a single subview that we will style as a red rectangle.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class ZoomView: UIView {

    let scrollView = UIScrollView()
    let rectangle = UIView()

    override func awakeFromNib() {
        super.awakeFromNib()

        backgroundColor = UIColor(white: 0.99, alpha: 1.0)

        rectangle.backgroundColor = UIColor.redColor()

        scrollView.setTranslatesAutoresizingMaskIntoConstraints(false)
        rectangle.setTranslatesAutoresizingMaskIntoConstraints(false)

        addSubview(scrollView)
        scrollView.addSubview(rectangle)

        let views = ["scrollView": scrollView, "rectangle": rectangle]
        var constraints = [NSLayoutConstraint]()
        constraints += NSLayoutConstraint.constraintsWithVisualFormat("V:|[scrollView]|", options: NSLayoutFormatOptions(0), metrics: nil, views: views) as! [NSLayoutConstraint]
        constraints += NSLayoutConstraint.constraintsWithVisualFormat("H:|[scrollView]|", options: NSLayoutFormatOptions(0), metrics: nil, views: views) as! [NSLayoutConstraint]
        constraints += NSLayoutConstraint.constraintsWithVisualFormat("V:|[rectangle]|", options: NSLayoutFormatOptions(0), metrics: nil, views: views) as! [NSLayoutConstraint]
        constraints += NSLayoutConstraint.constraintsWithVisualFormat("H:|[rectangle]|", options: NSLayoutFormatOptions(0), metrics: nil, views: views) as! [NSLayoutConstraint]
        constraints.append(NSLayoutConstraint(item: rectangle, attribute: .Height, relatedBy: .Equal, toItem: nil, attribute: .NotAnAttribute, multiplier: 0, constant: 40))
        constraints.append(NSLayoutConstraint(item: rectangle, attribute: .Width, relatedBy: .Equal, toItem: nil, attribute: .NotAnAttribute, multiplier: 0, constant: 60))
        NSLayoutConstraint.activateConstraints(constraints)
    }
}

I’ve sized the rectangle view as (60,40) so it will look something like this.

Making the red rectangle zoomable

To do this, we let the scroll view know which view is zoomable. We do this through implementing viewForZoomingInScrollView(_:) in the UIScrollViewDelegate protocol.

1
2
3
4
5
// MARK: UIScrollViewDelegate

func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
   return rectangle
}

Doing this alone won’t make the view zoomable. We also have to tell the scroll view how much zooming can be done. This is done via a couple properties on UIScrollView

  • minimumZoomScale: the minimum scale factor that can be applied to the zommable view
  • maximumZoomScale: the maximum scale factor that can be applied to the zommable view
  • zoomScale: the current scale factor applied to the zommable view

The zoom behaviour is achieved by the scroll view by applying a transform to the zoomable view, where the magnitude of the transform is zoomScale.

Let set these properties as

1
2
3
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 10.0
scrollView.zoomScale = scrollView.minimumZoomScale

This says that currently the scroll view is not zoomed as the zoomScale is set as the minimumZoomScale. And that we can zoom this view by 10x as the maximumZoomScale is 10 times that of the minimumZoomScale.

All up

So you should end up with something like this for ZoomView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class ZoomView: UIView, UIScrollViewDelegate {

    let scrollView = UIScrollView()
    let rectangle = UIView()

    override func awakeFromNib() {
        super.awakeFromNib()

        backgroundColor = UIColor(white: 0.99, alpha: 1.0)

        scrollView.minimumZoomScale = 1.0
        scrollView.maximumZoomScale = 10.0
        scrollView.zoomScale = scrollView.minimumZoomScale
        scrollView.delegate = self

        rectangle.backgroundColor = UIColor.redColor()

        scrollView.setTranslatesAutoresizingMaskIntoConstraints(false)
        rectangle.setTranslatesAutoresizingMaskIntoConstraints(false)

        addSubview(scrollView)
        scrollView.addSubview(rectangle)

        let views = ["scrollView": scrollView, "rectangle": rectangle]
        var constraints = [NSLayoutConstraint]()
        constraints += NSLayoutConstraint.constraintsWithVisualFormat("V:|[scrollView]|", options: NSLayoutFormatOptions(0), metrics: nil, views: views) as! [NSLayoutConstraint]
        constraints += NSLayoutConstraint.constraintsWithVisualFormat("H:|[scrollView]|", options: NSLayoutFormatOptions(0), metrics: nil, views: views) as! [NSLayoutConstraint]
        constraints += NSLayoutConstraint.constraintsWithVisualFormat("V:|[rectangle]|", options: NSLayoutFormatOptions(0), metrics: nil, views: views) as! [NSLayoutConstraint]
        constraints += NSLayoutConstraint.constraintsWithVisualFormat("H:|[rectangle]|", options: NSLayoutFormatOptions(0), metrics: nil, views: views) as! [NSLayoutConstraint]
        constraints.append(NSLayoutConstraint(item: rectangle, attribute: .Height, relatedBy: .Equal, toItem: nil, attribute: .NotAnAttribute, multiplier: 0, constant: 40))
        constraints.append(NSLayoutConstraint(item: rectangle, attribute: .Width, relatedBy: .Equal, toItem: nil, attribute: .NotAnAttribute, multiplier: 0, constant: 60))
        NSLayoutConstraint.activateConstraints(constraints)
    }

    // MARK: UIScrollViewDelegate

    func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
        return rectangle
    }
}

When we run this we can see we can scale the red rectangle by zooming anywhere in the scroll view. We also notice a couple more things

  • the red rectangle does not zoom beyond a certain size, this is because we set the maximumZoomScale. Since the maximumZoomScale is 10, the size of the red rectangle is (600, 400) (i.e. which is 10x (60, 40)) when we have achieved maximum zoom.
  • the red rectangle cannot be zoomed out smaller than the starting size, this is because we set the zommScale to match the minimumZoomScale

Another interesting thing to note is as the scroll view scales the zoomable view, it also scales the contentSize of the scroll view by the same factor. We can see this below courtesy of Reveal

That’s the basics. Enjoy and play around.

Swift Initializer Confusion

So this all came around when subclassing UIView. Something like below

1
2
3
4
5
6
7
class CustomView: UIView {

    override init () {
        super.init();
        self.backgroundColor = UIColor.redColor()
    }
}

and creating an instance like below

1
let customView = CustomView()

But xcode hits me with the error

‘required’: initializer ‘init(coder:)’ must be provided by subclass of ‘UIView’

After a bit of reading, it seems that effectively what I have done is override a designated initializer in UIView. You see, unlike Objective-C, Swift subclasses do not inherit their superclass initializers by default. Actually, if we read the documentation around Automatic Initializer Inheritance, we can understand why that compiler error is coming up. I’ve just quoted the two rules that apply here straight from the documentation

Rule 1

If your subclass doesn’t define any designated initializers, it automatically inherits all of its superclass designated initializers.

Rule 2

If your subclass provides an implementation of all of its superclass designated initializers—either by inheriting them as per rule 1, or by providing a custom implementation as part of its definition—then it automatically inherits all of the superclass convenience initializers.

So here it seems I broke Rule 1. I haven’t inherited the init(coder:) initializer because I have defined my own designated initializer by overriding init. And since UIView conforms to NSCoding, the init(coder:) initializer is required.

Ok, so this kinda sucks. I’ll implement init(coder:) and just call super to satisfy the complier

1
2
3
4
5
6
7
8
9
10
11
class CustomView: UIView {

    override init () {
        super.init();
        self.backgroundColor = UIColor.redColor()
    }

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
}

Oh crap. When I run this, I now get

fatal error: use of unimplemented initializer ‘init(frame:)’

Hmmm. So it looks like the implementation of UIView’s initializer init() calls out to the init(frame:) (which I did not inherit from UIView since I broke Rule 1). To fix this, I must implement init(frame:).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CustomView: UIView {

    override init () {
        super.init();
        self.backgroundColor = UIColor.redColor()
    }

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }
}

Since I always want the CustomView always to be red, then I should move that code to the other designated initializers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CustomView: UIView {

    override init () {
        super.init();
    }

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.backgroundColor = UIColor.redColor()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor.redColor()
    }
}

Now at this point you would think I could go ahead and delete my implementation of init(), but in fact init() is a designated initializer inherited from NSObject. Despite the fact that UIView’s implementation of init() calls init(frame:), it is not a considered a convenience initializer. You can try this by deleting init(), and you should get a compile error when trying to do let customView = CustomView(). If it was a convenience initializer, it would have been inherited after we implemented both init(frame:) and init(coder:). Since UIView is implemented in Objective-C before Swift and these rules came along, it can break these rules of a designated initializer calling another designated initializer.

Finally

So basically when creating a simple subclass of UIView, it seems that if I wanted to do some setup at initialization I have to implement/override the following

  • init()
  • init(frame:)
  • init(coder:)

just so that I can do

1
let customView = CustomView()

These rules are to ensure safety around initialization, but it sure can be confusing at first why you have to jump through those hoops just to get something simple done.

The Mystery of the +requiresConstraintBasedLayout

I use AutoLayout. I read about AutoLayout. I’ve also watched a couple of talks on AutoLayout. In exploring AutoLayout, I’ve come across this odd class method requiresConstraintBasedLayout on UIView. After using AutoLayout extensively on 

  • 3+ projects,
  • 100+ UIViews,
  • 100,000+ constraints,

I’ve never had to use this method. According to the documentation for requiresConstraintBasedLayout

Returns whether the receiver depends on the constraint-based layout system.

Custom views should override this to return YES if they can not layout correctly using autoresizing.

Wait!! I was using constraints all this time, but I never had to override this class method. This bugged me. Why was all my view laying out fine even though I didn’t override this method on my custom UIView subclasses? There was an objc.io article that advised that you declare your view’s dependency on AutoLayout explicitly by overriding this when creating UIView subclasses that uses constraints. My views were dependent on AutoLayout and ONLY used constraints, but it worked fine still without needing to do this step. I also bought up the question at my local cocoaheads meetup during a AutoLayout talk, but came up empty unfortunately. All explanations of this class method made sense. But the fact that AutoLayout still worked for me regardless of me needing to override this method, suggested that some part of the puzzle was missing. There was something else that was missing from the documentation that would fill the gap in my understanding. A clearer answer finally came after my @somekindaofmica decided to look at the UIView header (not the documentation). The header file had this to say

constraint-based layout engages lazily when someone tries to use it (e.g., adds a constraint to a view).  If you do all of your constraint set up in -updateConstraints, you might never even receive updateConstraints if no one makes a constraint.  To fix this chicken and egg problem, override this method to return YES if your view needs the window to use constraint-based layout.

Wow! That should have really been included in the documentation. The purpose of this class method is totally making sense now. I don’t usually set up all constraints inside -updateConstraints. Even if I did have a UIView subclass that sets up ALL of its constraints inside -updateConstraint, I would usually have another UIView subclass somewhere else in the view hierarchy that would “engage” AutoLayout by adding constraints outside of -updateConstraints. This explains why my UIView’s have laid out correctly using constraints when I haven’’t been overriding this class method.

TL;DR

If you set up all your constraints in -updateConstraints in your UIView subclass, make sure you override +requiresConstraintBasedLayout. I’ve attached a sample project to demonstrate this. You can see the behaviour by (un)commenting the overrided +requiresConstraintBasedLayout class method. https://github.com/ehuynh/RequiresConstraintBasedLayoutExample

UIScrollView contentSize and AutoLayout Gotcha

Adopting AutoLayout at work these days and I came across this interesting gotcha recently when trying to use AutoLayout with UIScrollView. The gotcha is related to setting the contentSize of the UIScrollView

Previously (when you’re not using AutoLayout) when using UIScrollView, you would set the contentSize to indicate the size of the content you are placing within the scroll view. With AutoLayout though, it is a bit different. If you do try and set the contentSize while using constraints, you’ll end up just banging your head against the keyboard.

I wasted half an hour of my time, and the solution was not immediately obvious. There is nothing in the UIScrollView documentation that mentions that you shouldn’t set the contentSize when using AutoLayout. The answer came after being lead to https://developer.apple.com/library/ios/technotes/tn2154/_index.html.

The document mentions two approaches

  • mixed
  • pure AutoLayout

Of course we go for the pure AutoLayout approach to avoid the use of Frames (yuk). To do that we need to do the following (straight from the document)

  • Set translatesAutoresizingMaskIntoConstraints to NO on all views involved.
  • Position and size your scroll view with constraints external to the scroll view.
  • Use constraints to lay out the subviews within the scroll view, being sure that the constraints tie to all four edges of the scroll view and do not rely on the scroll view to get their size.

To rephrase the above, make sure the constraints defined for the content view is able to determine its size independent of the scroll view size. An example of this is to set the size of the content view explicitly. Also make sure the constraints defined for the content view is such that the content view will sit flush against all four edges of the scroll view. Hence constraints like the ones below will satisfy those requirements:

1
2
3
4
5
6
7
8
9
[scrollView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[contentView(600)]|"
                                                                   options:kNilOptions
                                                                   metrics:nil
                                                                     views:views]];

[scrollView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[contentView(300)]|"
                                                                   options:kNilOptions
                                                                   metrics:nil
                                                                     views:views]];

By doing the the above, AutoLayout will calculate the contentSize required, without you needing to set it. SO DON’T SET IT when using AutoLayout. I’ve put up a example project on Github https://github.com/ehuynh/ScrollViewAutoLayoutExample to demonstrate this.

Becoming a Better Dev

Over the last year or so I’ve come to enjoy development more and more. And so, I’ve been trying to continuously improve my skills, and as a developer in general. I’ve noticed though podcasts I listen to, developers I follow on twitter and also blogs that I read, that starting a blog is something all developers should do as part of their career. So with this blog, I hope to share will the dev community my thoughts and learnings as I continue on the path to become a better developer.