Chapter 7. Scroll Views

A scroll view (UIScrollView) is a view whose content is larger than its bounds. To reveal a desired area, the user can scroll the content by dragging, or you can reposition the content in code.

A scroll view isn’t magic; it takes advantage of ordinary UIView features (Chapter 1). The content is simply the scroll view’s subviews. When the scroll view scrolls, what’s really changing is the scroll view’s own bounds origin; the subviews are positioned with respect to the bounds origin, so they move with it. The scroll view’s clipsToBounds is usually true, so any content positioned within the scroll view is visible and any content positioned outside it is not.

In addition, a scroll view brings to the table some nontrivial abilities:

  • It knows how to shift its bounds origin in response to the user’s gestures.
  • It provides scroll indicators whose size and position give the user a clue as to the content’s size and position.
  • It can enforce paging, whereby the user can scroll only by a fixed amount.
  • It supports zooming, so that the user can resize the content with a pinch gesture.
  • It provides a plethora of delegate methods, so that your code knows exactly how the user is scrolling and zooming.

As I’ve just said, a scroll view’s subviews, like those of any view, are positioned with respect to its bounds origin; to scroll is to change the bounds origin. The scroll view thus already knows how far it should be allowed to slide its subviews downward and rightward — the limit is reached when the scroll view’s bounds origin is (0.0,0.0). What the scroll view needs to know is how far it should be allowed to slide its subviews upward and leftward. That is the scroll view’s content size — its contentSize property. The scroll view uses its contentSize, in combination with its own bounds size, to set the limits on how large its bounds origin can become. It may also be helpful to think of the scroll view’s scrollable content as the rectangle defined by CGRect(origin:CGPointZero, size:contentSize); this is the rectangle that the user can inspect by scrolling.

If a dimension of the contentSize isn’t larger than the same dimension of the scroll view’s own bounds, the content won’t be scrollable in that dimension: there is nothing to scroll, as the entire scrollable content is already showing. The default is that the contentSize is (0.0,0.0) — meaning that the scroll view isn’t scrollable.

To get a working scroll view, therefore, it will be crucial to set its contentSize correctly. You can do this directly, in code; or, if you’re using autolayout (Chapter 1), the contentSize is calculated for you based on the constraints of the scroll view’s subviews. I’ll demonstrate both approaches.

Creating a Scroll View in Code

I’ll start by creating a scroll view, providing it with subviews, and making those subviews viewable by scrolling, entirely in code.

Manual Content Size

In the first instance, let’s not use autolayout. Our project is based on the Single View Application template, with a single view controller class, ViewController. In the ViewController’s viewDidLoad, I’ll create the scroll view to fill the main view, and populate it with 30 UILabels whose text contains a sequential number so that we can see where we are when we scroll:

let sv = UIScrollView(frame: self.view.bounds)
sv.autoresizingMask = [.FlexibleWidth, .FlexibleHeight]
self.view.addSubview(sv)
sv.backgroundColor = UIColor.whiteColor()
var y : CGFloat = 10
for i in 0 ..< 30 {
    let lab = UILabel()
    lab.text = "This is label (i+1)"
    lab.sizeToFit()
    lab.frame.origin = CGPointMake(10,y)
    sv.addSubview(lab)
    y += lab.bounds.size.height + 10
}
var sz = sv.bounds.size
sz.height = y
sv.contentSize = sz // *

The crucial move is the last line, where we tell the scroll view how large its content is to be. If we omit this step, the scroll view won’t be scrollable; the window will appear to consist of a static column of labels.

There is no rule about the order in which you perform the two operations of setting the contentSize and populating the scroll view with subviews. In that example, we set the contentSize afterward because it is more convenient to track the heights of the subviews as we add them than to calculate their total height in advance. Similarly, you can alter a scroll view’s content (subviews) or contentSize, or both, dynamically as the app runs.

Any direct subviews of the scroll view may need to have their autoresizing set appropriately in case the scroll view is resized, as would happen, for instance, if our app performs compensatory rotation. To see this, add these lines to the preceding example, inside the for loop:

lab.frame.width = self.view.bounds.width - 20
lab.backgroundColor = UIColor.redColor()
lab.autoresizingMask = .FlexibleWidth

Run the app, and rotate the device or the Simulator. The labels are wider in portrait orientation because the scroll view itself is wider.

This, however, has nothing to do with the contentSize! The contentSize does not change just because the scroll view’s bounds change; if you want the contentSize to change in response to rotation, you will need to change it manually, in code. Conversely, resizing the contentSize has no effect on the size of the scroll view’s subviews; it merely determines the scrolling limit.

Automatic Content Size With Autolayout

With autolayout, things are different. The difficult thing to understand — and it is certainly counterintuitive — is that a constraint between a scroll view and its direct subview is not a way of positioning the subview relative to the scroll view (as it would be if the superview were an ordinary UIView). Instead, it’s a way of describing the scroll view’s contentSize.

To see this, let’s rewrite the preceding example to use autolayout. The scroll view and its subviews have their translatesAutoresizingMaskIntoConstraints set to false, and we’re giving them explicit constraints:

let sv = UIScrollView()
sv.backgroundColor = UIColor.whiteColor()
sv.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(sv)
var con = [NSLayoutConstraint]()
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat(
        "H:|[sv]|",
        options:[], metrics:nil,
        views:["sv":sv]))
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat(
        "V:|[sv]|",
        options:[], metrics:nil,
        views:["sv":sv]))
var previousLab : UILabel? = nil
for i in 0 ..< 30 {
    let lab = UILabel()
    // lab.backgroundColor = UIColor.redColor()
    lab.translatesAutoresizingMaskIntoConstraints = false
    lab.text = "This is label (i+1)"
    sv.addSubview(lab)
    con.appendContentsOf(
        NSLayoutConstraint.constraintsWithVisualFormat(
            "H:|-(10)-[lab]",
            options:[], metrics:nil,
            views:["lab":lab]))
    if previousLab == nil { // first one, pin to top
        con.appendContentsOf(
            NSLayoutConstraint.constraintsWithVisualFormat(
                "V:|-(10)-[lab]",
                options:[], metrics:nil,
                views:["lab":lab]))
    } else { // all others, pin to previous
        con.appendContentsOf(
            NSLayoutConstraint.constraintsWithVisualFormat(
                "V:[prev]-(10)-[lab]",
                options:[], metrics:nil,
                views:["lab":lab, "prev":previousLab!]))
    }
    previousLab = lab
}
NSLayoutConstraint.activateConstraints(con)

The labels are correctly positioned relative to one another, but the scroll view isn’t scrollable. Moreover, setting the contentSize manually doesn’t help. The reason is that we are missing a constraint. We have to add one more constraint, showing the scroll view what the height of its contentSize should be. Replace the last line of that code with this:

// ... everything else as before ...
// last one, pin to bottom, this dictates content size height!
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat(
        "V:[lab]-(10)-|",
        options:[], metrics:nil,
        views:["lab":previousLab!]))
NSLayoutConstraint.activateConstraints(con)

The constraints of the scroll view’s subviews now describe the contentSize height: the top label is pinned to the top of the scroll view, the next one is pinned to the one above it, and so on — and the bottom one is pinned to the bottom of the scroll view. Consequently, the runtime calculates the contentSize height from the inside out, as it were, as the sum of all the vertical constraints (including the intrinsic heights of the labels), and the scroll view is vertically scrollable to show all the labels.

Using a Content View

Instead of putting all of our scroll view’s content directly inside the scroll view as its immediate subviews, we can provide a generic UIView as the sole immediate subview of the scroll view; everything else inside the scroll view is to be a subview of this generic UIView, which we may term the content view. This is a commonly used arrangement.

Under autolayout, we then have two choices for setting the scroll view’s contentSize:

  • Set the content view’s translatesAutoresizingMaskIntoConstraints to true, and set the scroll view’s contentSize manually to the size of the content view.
  • Set the content view’s translatesAutoresizingMaskIntoConstraints to false, set its size with constraints, and pin its edges to its superview (the scroll view) with a constant of 0.

A convenient consequence of this arrangement is that it works independently of whether the content view’s own subviews are positioned explicitly by their frames or using constraints. There are thus four possible combinations:

No constraints
The content view is sized by its frame, its contents are positioned by its frame, and the scroll view’s contentSize is set explicitly.
Content view constraints
The content view is sized by its own height and width constraints and is pinned to the scroll view to set its content size.
Content view and content constraints
The content view is sized from the inside out by the constraints of its subviews and is pinned to the scroll view to set its content size.
Content constraints only
The content view is sized by its frame and the scroll view’s contentSize is set explicitly, but the content view’s subviews are positioned using constraints.

I’ll illustrate by rewriting the previous example to use a content view. All four possible combinations start the same way:

let sv = UIScrollView()
sv.backgroundColor = UIColor.whiteColor()
sv.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(sv)
var con = [NSLayoutConstraint]()
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat(
        "H:|[sv]|",
        options:[], metrics:nil,
        views:["sv":sv]))
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat(
        "V:|[sv]|",
        options:[], metrics:nil,
        views:["sv":sv]))
let v = UIView() // content view
sv.addSubview(v)

The differences lie in what happens next. The first combination is that no constraints are used (apart from the constraints that frame the scroll view), and the scroll view’s content size is set explicitly. It’s just like the first example in the chapter, except that the labels are added to the content view, not to the scroll view:

var y : CGFloat = 10
for i in 0 ..< 30 {
    let lab = UILabel()
    lab.text = "This is label (i+1)"
    lab.sizeToFit()
    lab.frame.origin = CGPointMake(10,y)
    v.addSubview(lab) // *
    y += lab.bounds.size.height + 10
}
// set content view frame and content size explicitly
v.frame = CGRectMake(0,0,0,y)
sv.contentSize = v.frame.size
NSLayoutConstraint.activateConstraints(con)

The second combination is that the content view uses explicit constraints, but its subviews don’t. It’s just like the preceding code, except that we set the content view’s constraints rather than the scroll view’s content size:

var y : CGFloat = 10
for i in 0 ..< 30 {
    let lab = UILabel()
    lab.text = "This is label (i+1)"
    lab.sizeToFit()
    lab.frame.origin = CGPointMake(10,y)
    v.addSubview(lab)
    y += lab.bounds.size.height + 10
}
// set content view width, height, and frame-to-superview constraints
// content size is calculated for us
v.translatesAutoresizingMaskIntoConstraints = false
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat("V:|[v(y)]|",
        options:[], metrics:["y":y], views:["v":v]))
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat("H:|[v(0)]|",
        options:[], metrics:nil, views:["v":v]))
NSLayoutConstraint.activateConstraints(con)

The third combination is that explicit constraints are used throughout. This is just like the second example in the chapter, except that the labels are added to the content view:

var previousLab : UILabel? = nil
for i in 0 ..< 30 {
    let lab = UILabel()
    // lab.backgroundColor = UIColor.redColor()
    lab.translatesAutoresizingMaskIntoConstraints = false
    lab.text = "This is label (i+1)"
    v.addSubview(lab) // *
    con.appendContentsOf( // *
        NSLayoutConstraint.constraintsWithVisualFormat(
            "H:|-(10)-[lab]",
            options:[], metrics:nil,
            views:["lab":lab]))
    if previousLab == nil { // first one, pin to top
        con.appendContentsOf( // *
            NSLayoutConstraint.constraintsWithVisualFormat(
                "V:|-(10)-[lab]",
                options:[], metrics:nil,
                views:["lab":lab]))
    } else { // all others, pin to previous
        con.appendContentsOf( // *
            NSLayoutConstraint.constraintsWithVisualFormat(
                "V:[prev]-(10)-[lab]",
                options:[], metrics:nil,
                views:["lab":lab, "prev":previousLab!]))
    }
    previousLab = lab
}
// last one, pin to bottom, this dictates content size height!
con.appendContentsOf( // *
    NSLayoutConstraint.constraintsWithVisualFormat(
        "V:[lab]-(10)-|",
        options:[], metrics:nil,
        views:["lab":previousLab!]))
// pin content view to scroll view, sized by its subview constraints
// content size is calculated for us
v.translatesAutoresizingMaskIntoConstraints = false
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat("V:|[v]|",
        options:[], metrics:nil, views:["v":v])) // *
con.appendContentsOf(
    NSLayoutConstraint.constraintsWithVisualFormat("H:|[v]|",
        options:[], metrics:nil, views:["v":v])) // *
NSLayoutConstraint.activateConstraints(con)

The fourth combination is that the content view’s subviews are positioned using constraints, but we set the content view’s frame and the scroll view’s content size explicitly. There is no y to track as we position the subviews, so how can we find out the final content size height? Fortunately, systemLayoutSizeFittingSize: tells us:

// ... same as previous ...
// last one, pin to bottom, this dictates content size height!
con.appendContentsOf( // *
    NSLayoutConstraint.constraintsWithVisualFormat(
        "V:[lab]-(10)-|",
        options:[], metrics:nil,
        views:["lab":previousLab!]))
NSLayoutConstraint.activateConstraints(con)
// autolayout helps us learn the consequences of those constraints
let minsz = v.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
// set content view frame and content size explicitly
v.frame = CGRectMake(0,0,0,minsz.height)
sv.contentSize = v.frame.size

Scroll View in a Nib

A UIScrollView is available in the nib editor in the Object library, so you can drag it into a view in the canvas and give it subviews. Alternatively, you can wrap existing views in the canvas in a UIScrollView as an afterthought: to do so, select the views and choose Editor → Embed In → Scroll View. The scroll view can’t be scrolled in the nib editor, so to design its subviews, you make the scroll view large enough to accommodate them; if this makes the scroll view too large, you can resize the actual scroll view instance when the nib loads. If the scroll view is inside the view controller’s main view, you may have to make that view too large as well, in order to see and work with the full scroll view and its contents (Figure 7-1). Set the view controller’s Size pop-up menu in the Simulated Metrics section of its Attributes inspector to Freeform; now you can change the main view’s size.

A scroll view in the nib editor
Figure 7-1. A scroll view in the nib editor

If you’re not using autolayout, judicious use of autoresizing settings in the nib editor can be a big help here. In Figure 7-1, the scroll view is the main view’s subview; the scroll view’s edges are pinned (struts) to its superview, and its width and height are flexible (springs). Thus, when the app runs and the main view is resized (as I discussed in Chapter 6), the scroll view will be resized too, to fit the main view. The content view, on the other hand, must not be resized, so its width and height are not flexible (they are struts, not springs), and only its top and left edges are pinned to its superview (struts).

But although everything is correctly sized at runtime, the scroll view doesn’t scroll. That’s because we have failed to set the scroll view’s contentSize. Unfortunately, the nib editor provides no way to do that! Thus, we’ll have to do it in code. This, in fact, is why I’m using a content view. The content view is the correct size in the nib, and it won’t be resized through autoresizing, so at runtime, when the nib loads, its size will be the desired contentSize. I have an outlet to the scroll view (self.sv) and an outlet to the content view (self.cv), and I set the scroll view’s contentSize to the content view’s size in viewDidLayoutSubviews:

var didSetup = false
override func viewDidLayoutSubviews() {
    if !self.didSetup {
        self.didSetup = true
        self.sv.contentSize = self.cv.bounds.size
    }
}

If you are using autolayout, constraints take care of everything; there is no need for any code to set the scroll view’s contentSize. The scroll view’s own size is determined by constraints; typically, its edges are pinned to those of its superview. The content view’s edges are also pinned to those of its superview, the scroll view. Be sure to set the constant of each constraint between the content view and the scroll view to 0! That tells the scroll view: “The contentSize is the size of the content view.”

The only question now is how you’d like to dictate the content view’s size. You have two choices, corresponding to the second and third combinations in the preceding section: you can set the content view’s width and height constraints explicitly, or you can let the content view’s width and height be completely determined by the constraints of its subviews. Do whichever feels suitable. The nib editor understands this aspect of scroll view configuration, and will alert you with a warning (about the “scrollable content size”) until you’ve provided enough constraints to determine unambiguously the scroll view’s contentSize.

Scrolling

For the most part, the purpose of a scroll view will be to let the user scroll. A number of properties affect the user experience with regard to scrolling:

scrollEnabled
If false, the user can’t scroll, but you can still scroll in code (as explained later in this section). You could put a UIScrollView to various creative purposes other than letting the user scroll; for example, scrolling in code to a different region of the content might be a way of replacing one piece of interface by another, possibly with animation.
scrollsToTop
If true (the default), and assuming scrolling is enabled, the user can tap on the status bar as a way of making the scroll view scroll its content to the top (that is, the content moves all the way down). You can also override this setting dynamically through the scroll view’s delegate, discussed later in this chapter.
bounces
If true (the default), then when the user scrolls to a limit of the content, it is possible to scroll somewhat further (possibly revealing the scroll view’s backgroundColor behind the content, if a subview was covering it); the content then snaps back into place when the user releases it. Otherwise, the user experiences the limit as a sudden inability to scroll further in that direction.
alwaysBounceVertical
alwaysBounceHorizontal
If true, and assuming that bounces is true, then even if the contentSize in the given dimension isn’t larger than the scroll view (so that no scrolling is actually possible in that dimension), the user can nevertheless scroll somewhat and the content then snaps back into place when the user releases it; otherwise, the user experiences a simple inability to scroll in that dimension.
directionalLockEnabled
If true, and if scrolling is possible in both dimensions (even if only because the appropriate alwaysBounce... is true), then the user, having begun to scroll in one dimension, can’t scroll in the other dimension without ending the gesture and starting over. In other words, the user is constrained to scroll vertically or horizontally but not both at once.
decelerationRate

The rate at which scrolling is damped out, and the content comes to a stop, after the user’s gesture ends. As convenient examples, standard constants are provided:

  • UIScrollViewDecelerationRateNormal (0.998)
  • UIScrollViewDecelerationRateFast (0.99)

Lower values mean faster damping; experimentation suggests that values lower than 0.5 are viable but barely distinguishable from one another. You can also effectively override this value dynamically through the scroll view’s delegate, discussed later in this chapter.

showsHorizontalScrollIndicator
showsVerticalScrollIndicator

The scroll indicators are bars that appear only while the user is scrolling in a scrollable dimension (where the content is larger than the scroll view), and serve to indicate both the size of the content in that dimension relative to the scroll view and where the user is within it. The default is true for both.

Because the user cannot see the scroll indicators except when actively scrolling, there is normally no indication that the view is scrollable. I regard this as somewhat unfortunate, because it makes the possibility of scrolling less discoverable; I’d prefer an option to make the scroll indicators constantly visible. Apple suggests that you call flashScrollIndicators when the scroll view appears, to make the scroll indicators visible momentarily.

Warning

The scroll indicators are subviews of the scroll view (they are actually UIImageViews). Do not assume that the subviews you add to a UIScrollView are its only subviews!

indicatorStyle
The way the scroll indicators are drawn. Your choices (UIScrollViewIndicatorStyle) are .Black, .White, and .Default (black with a white border).

You can scroll in code even if the user can’t scroll. The content simply moves to the position you specify, with no bouncing and no exposure of the scroll indicators. You can specify the new position in two ways:

contentOffset

The point (CGPoint) of the content that is located at the scroll view’s top left (effectively the same thing as the scroll view’s bounds origin). You can get this property to learn the current scroll position, and set it to change the current scroll position. The values normally go up from (0.0,0.0) until the limit dictated by the contentSize and the scroll view’s own bounds size is reached.

To set the contentOffset with animation, call setContentOffset:animated:. The animation does not cause the scroll indicators to appear; it just slides the content to the desired position.

If a scroll view participates in state restoration (Chapter 6), its contentOffset is saved and restored, so when the app is relaunched, the scroll view will reappear scrolled to the same position as before.

scrollRectToVisible:animated:
Adjusts the content so that the specified CGRect of the content is within the scroll view’s bounds. This is less precise than setting the contentOffset, because you’re not saying exactly what the resulting scroll position will be, but sometimes guaranteeing the visibility of a certain portion of the content is exactly what you’re after.

If you call a method to scroll with animation and you need to know when the animation ends, implement scrollViewDidEndScrollingAnimation: in the scroll view’s delegate.

Finally, these properties affect the scroll view’s structural dimensions:

contentInset

A UIEdgeInsets struct (four CGFloats: top, left, bottom, right) specifying margin space around the content.

If a scroll view participates in state restoration (Chapter 6), its contentInset is saved and restored.

scrollIndicatorInsets
A UIEdgeInsets struct specifying a shift in the position of the scroll indicators.

A typical use for the contentInset would be that your scroll view underlaps an interface element, such as a status bar, navigation bar, or toolbar, and you want your content to be visible even when scrolled to its limit.

A good example is the app with 30 labels that we created at the start of this chapter. The scroll view occupies the entirety of the view controller’s main view. But that means that the scroll view underlaps the status bar. And that means that at launch time, and whenever the scroll view’s content is scrolled all the way down, the first label, which is as far down as it can go, is partly hidden by the text of the status bar. We can prevent this by setting the scroll view’s contentInset:

sv.contentInset = UIEdgeInsetsMake(20, 0, 0, 0)

The scroll view still underlaps the status bar, and its scrolled content is still visible behind the status bar; what’s changed is only that at the extreme scrolled-down position, where the content offset is (0.0,0.0), the scroll view’s content is not behind the status bar.

When changing the contentInset, you will probably want to change the scrollIndicatorInsets to match. Consider again the scroll view whose contentInset we have just set. When scrolled all the way down, it now has a nice gap between the bottom of the status bar and the top of the first label; but the top of the scroll indicator is still up behind the status bar. We can prevent this by setting the scrollIndicatorInsets to the same value as the contentInset:

sv.contentInset = UIEdgeInsetsMake(20, 0, 0, 0)
sv.scrollIndicatorInsets = sv.contentInset

As I mentioned in Chapter 6, top bars and bottom bars are likely to be translucent, and the runtime would like to make your view underlap them. With a scroll view, this looks cool, because the scroll view’s contents are visible in a blurry way through the translucent bar; but the contentInset and scrollIndicatorInsets need to be adjusted so that the scrolling limits stay between the top bar and the bottom bar. Moreover, the height of the bars can change, depending on such factors as how the interface is rotated.

Therefore, if a scroll view is going to underlap top and bottom bars, it would be nice, instead of hard-coding the top inset as in the preceding code, to make the scroll view’s inset respond to its environment. A layout event seems the best place for such a response, and we can use the view controller’s topLayoutGuide and bottomLayoutGuide to help us:

override func viewWillLayoutSubviews() {
    if let sv = self.sv {
        let top = self.topLayoutGuide.length
        let bot = self.bottomLayoutGuide.length
        sv.contentInset = UIEdgeInsetsMake(top, 0, bot, 0)
        sv.scrollIndicatorInsets = self.sv.contentInset
    }
}

Even better, if our view controller’s main view contains one primary scroll view, and if it contains it sufficiently early — in the nib, for example — then if our view controller’s automaticallyAdjustsScrollViewInsets property is true, the runtime will adjust our scroll view’s contentInset and scrollIndicatorInsets with no code on our part. This property won’t help us in the examples earlier in this chapter where we create the scroll view in code. But if the scroll view is created from the nib, as in Figure 7-1, this property applies and works. Moreover, a value of true is the default! In the nib editor, you can change it with the Adjust Scroll View Insets checkbox in the Attributes inspector. Be sure to set this property to false if you want to take charge of adjusting a scroll view’s contentInset and scrollIndicatorInsets yourself.

Paging

If its pagingEnabled property is true, the scroll view doesn’t let the user scroll freely; instead, the content is considered to consist of equal-sized sections. The user can scroll only in such a way as to move to a different section. The size of a section is set automatically to the size of the scroll view’s bounds. The sections are the scroll view’s pages.

When the user stops dragging, a paging scroll view gently snaps automatically to the nearest whole page. For example, let’s say that the scroll view scrolls only horizontally, and that its subviews are image views showing photos, sized to match the scroll view’s bounds. If the user drags horizontally to the left to a point where less than half of the next photo to the right is visible, and raises the dragging finger, the paging scroll view snaps its content back to the right until the entire first photo is visible again. If the user drags horizontally to the left to a point where more than half of the next photo to the right is visible, and raises the dragging finger, the paging scroll view snaps its content further to the left until the entire second photo is visible.

The usual arrangement is that a paging scroll view is at least as large, or nearly as large, in its scrollable dimension, as the screen. A moment’s thought will reveal that, under this arrangement, it is impossible for the user to move the content more than a single page in any direction with a single gesture. The reason is that the size of the page is the size of the scroll view’s bounds. Thus the user will run out of surface area to drag on before being able to move the content the distance of a page and a half, which is what would be needed to make the scroll view snap to a page not adjacent to the page we started on.

Sometimes, indeed, the paging scroll view will be slightly larger than the window in its scrollable dimension. This allows each page’s content to fill the scroll view while also providing gaps between the pages, visible when the user starts to scroll. The user is still able to move from page to page, because it is still readily possible to drag more than half a new page into view (and the scroll view will then snap the rest of the way when the user raises the dragging finger).

When the user raises the dragging finger, the scroll view’s action in adjusting its content is considered to be decelerating, and the scroll view’s delegate (discussed in more detail later in this chapter) will receive scrollViewWillBeginDecelerating:, followed by scrollViewDidEndDecelerating: when the scroll view’s content has stopped moving and a full page is showing. Thus, these messages can be used to detect efficiently that the page may have changed.

You can take advantage of this, for example, to coordinate a paging scroll view with a UIPageControl (Chapter 12). In this example, a page control (self.pager) is updated whenever the user causes a horizontally scrollable scroll view (self.sv) to display a different page:

func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
    let x = self.sv.contentOffset.x
    let w = self.sv.bounds.size.width
    self.pager.currentPage = Int(x/w)
}

Conversely, we can scroll the scroll view to a new page manually when the user taps the page control; in this case we have to calculate the page boundaries ourselves:

@IBAction func userDidPage(sender:AnyObject?) {
    let p = self.pager.currentPage
    let w = self.sv.bounds.size.width
    self.sv.setContentOffset(CGPointMake(CGFloat(p)*w,0), animated:true)
}

A useful interface is a paging scroll view where you supply pages dynamically as the user scrolls. In this way, you can display a huge number of pages without having to put them all into the scroll view at once. A scrolling UIPageViewController (Chapter 6) provides exactly that interface. Its UIPageViewControllerOptionInterPageSpacingKey even provides the gap between pages that I mentioned earlier.

A compromise between a UIPageViewController and a completely preconfigured paging scroll view is a scroll view whose contentSize can accommodate all pages, but whose actual page content is supplied lazily. The only pages that have to be present at all times are the page visible to the user and the two pages adjacent to it on either side (so that there is no delay in displaying a new page’s content when the user starts to scroll). This approach is exemplified by Apple’s PageControl sample code. Unfortunately, that example does not also remove page content that is no longer needed, so there is ultimately no conservation of memory.

There are times when a scroll view, even one requiring a good deal of dynamic configuration, is better than a scrolling UIPageViewController, because the scroll view provides full information to its delegate about the user’s scrolling activity (as described later in this chapter). For example, if you wanted to respond to the user’s dragging one area of the interface by programmatically scrolling another area of the interface in a coordinated fashion, you might want what the user is dragging to be a scroll view, because it tells you what the user is up to at every moment.

Tiling

Suppose we have some finite but really big content that we want to display in a scroll view, such as a very large image that the user can inspect, piecemeal, by scrolling. To hold the entire image in memory may be onerous or impossible.

Tiling is one solution to this kind of problem. It takes advantage of the insight that there’s really no need to hold the entire image in memory; all we need at any given moment is the part of the image visible to the user right now. Mentally, divide the content rectangle into a matrix of rectangles; these rectangles are the tiles. In reality, divide the huge image into corresponding rectangles. Then whenever the user scrolls, we look to see whether part of any empty tile has become visible, and if so, we supply its content. At the same time, we can release the content of all tiles that are completely offscreen. Thus, at any given moment, only the tiles that are showing have content. There is some latency associated with this approach (the user scrolls, then any empty newly visible tiles are filled in), but we will have to live with that.

There is actually a built-in CALayer subclass for helping us implement tiling — CATiledLayer. Its tileSize property sets the dimensions of a tile. Its drawLayer:inContext: is called when content for an empty tile is needed; calling CGContextGetClipBoundingBox on the context reveals the location of the desired tile, and now we can supply that tile’s content.

The usual approach is to implement drawRect: in a UIView that hosts the CATiledLayer. Here, the CATiledLayer is the view’s underlying layer; therefore the view is the CATiledLayer’s delegate (see Chapter 3). This means that when the CATiledLayer’s drawLayer:inContext: is called, the host view’s drawRect: is called, and the drawRect: parameter is the same as the result of calling CGContextGetClipBoundingBox — namely, it’s the rect of the tile we are to draw.

The tileSize may need to be adjusted for the screen resolution. On a double-resolution device, for example, the CATiledLayer’s contentsScale will be doubled, and the tiles will be half the size that we ask for. If that isn’t acceptable, we can double the tileSize dimensions.

To illustrate, we’ll use some tiles already created for us as part of Apple’s own PhotoScroller sample code. In particular, I’ll use a few of the “CuriousFrog_500” images. These all have names of the form CuriousFrog_500_x_y.png, where x and y are integers corresponding to the picture’s position within the matrix. The images are 256×256 pixels, except for the ones on the extreme right and bottom edges of the matrix, which are shorter in one dimension, but I won’t be using those in this example; I’ve selected a square matrix of 9 square images.

We will give our scroll view (self.sv) one subview, a TiledView, a UIView subclass that exists purely to give our CATiledLayer a place to live. TILESIZE is defined as 256, to match the image dimensions:

override func viewDidLoad() {
    let f = CGRectMake(0,0,3*TILESIZE,3*TILESIZE)
    let content = TiledView(frame:f)
    let tsz = TILESIZE * content.layer.contentsScale
    (content.layer as! CATiledLayer).tileSize = CGSizeMake(tsz, tsz)
    self.sv.addSubview(content)
    self.sv.contentSize = f.size
    self.content = content
}

Here’s the code for TiledView. As Apple’s sample code points out, we must fetch images with init(contentsOfFile:) in order to avoid the automatic caching behavior of init(named:) — after all, we’re going to all this trouble exactly to avoid using more memory than we have to:

override class func layerClass() -> AnyClass {
    return CATiledLayer.self
}
override func drawRect(r: CGRect) {
    let tile = r
    let x = Int(tile.origin.x/TILESIZE)
    let y = Int(tile.origin.y/TILESIZE)
    let tileName = String(format:"CuriousFrog_500_(x+3)_(y)")
    let path = NSBundle.mainBundle().pathForResource(tileName, ofType:"png")!
    let image = UIImage(contentsOfFile:path)!
    image.drawAtPoint(CGPointMake(CGFloat(x)*TILESIZE,CGFloat(y)*TILESIZE))
}

In this configuration, our TiledView’s drawRect is called on a background thread. This is unusual, but it shouldn’t cause any trouble as long as you confine yourself to standard thread-safe activities. Fortunately, fetching the image and drawing it are thread-safe.

Warning

You may encounter a nasty issue where a CATiledLayer’s drawRect: is called simultaneously on multiple background threads. It isn’t clear to me whether this problem is confined to the Simulator or whether it can also occur on a device. The workaround is to wrap the whole interior of drawRect: in a call to dispatch_sync on a serial queue (see Chapter 25).

There is no special call for invalidating an offscreen tile. You can call setNeedsDisplay or setNeedsDisplayInRect: on the TiledView, but this doesn’t erase offscreen tiles. You’re just supposed to trust that the CATiledLayer will eventually clear offscreen tiles if needed to conserve memory.

CATiledLayer has a class method fadeDuration that dictates the duration of the animation that fades a new tile into view. You can create a CATiledLayer subclass and override this method to return a value different from the default (0.25), but this is probably not worth doing, as the default value is a good one. Returning a smaller value won’t make tiles appear faster; it just replaces the nice fade-in with an annoying flash.

Zooming

To implement zooming of a scroll view’s content, you set the scroll view’s minimumZoomScale and maximumZoomScale so that at least one of them isn’t 1 (the default). You also implement viewForZoomingInScrollView: in the scroll view’s delegate to tell the scroll view which of its subviews is to be the scalable view. The scroll view then zooms by applying a scale transform (Chapter 1) to this subview. The amount of that transform is the scroll view’s zoomScale property. Typically, you’ll want the scroll view’s entire content to be scalable, so you’ll have one direct subview of the scroll view that acts as the scalable view, and anything else inside the scroll view will be a subview of the scalable view, so as to be scaled together with it. This is another reason for arranging your scroll view’s subviews inside a single content view, as I suggested earlier.

To illustrate, we can start with any of the four content view–based versions of our scroll view containing 30 labels. I called the content view v. Now we add these lines:

v.tag = 999
sv.minimumZoomScale = 1.0
sv.maximumZoomScale = 2.0
sv.delegate = self

We have assigned a tag to the view that is to be scaled, so that we can refer to it later. We have set the scale limits for the scroll view. And we have made ourselves the scroll view’s delegate. Now all we have to do is implement viewForZoomingInScrollView: to return the scalable view:

func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
    return scrollView.viewWithTag(999)
}

This works: the scroll view now responds to pinch gestures by scaling appropriately! But it doesn’t look quite as good as I’d like when we zoom, and in particular I don’t like the way the labels snap into place when we stop zooming. The reason is that, in my earlier examples, I gave the content view and the contentSize a zero width; that was sufficient to prevent the scroll view from scrolling horizontally, which was all that mattered. Now, however, these widths also affect how the content behaves as the user zooms it. This particular example, I think, looks best while zooming if the content view width is a bit wider than the widest label. (Implementing that is left as an exercise for the reader.)

The user can actually scale considerably beyond the limits we set in both directions; in that case, when the gesture ends, the scale snaps back to the limit value. If we wish to confine scaling strictly to our defined limits, we can set the scroll view’s bouncesZoom to false; when the user reaches a limit, scaling will simply stop.

The actual amount of zoom is reflected as the scroll view’s current zoomScale. If a scroll view participates in state restoration, its zoomScale is saved and restored, so when the app is relaunched, the scroll view will reappear zoomed by the same amount as before.

If the minimumZoomScale is less than 1, then when the scalable view becomes smaller than the scroll view, it is pinned to the scroll view’s top left. If you don’t like this, you can change it by subclassing UIScrollView and overriding layoutSubviews, or by implementing the scroll view delegate method scrollViewDidZoom:. Here’s a simple example (drawn from a WWDC 2010 video) demonstrating an override of layoutSubviews that keeps the scalable view centered in either dimension whenever it is smaller than the scroll view in that dimension:

override func layoutSubviews() {
    super.layoutSubviews()
    if let v = self.delegate?.viewForZoomingInScrollView?(self) {
        let svw = self.bounds.width
        let svh = self.bounds.height
        let vw = v.frame.width
        let vh = v.frame.height
        var f = v.frame
        if vw < svw {
            f.origin.x = (svw - vw) / 2.0
        } else {
            f.origin.x = 0
        }
        if vh < svh {
            f.origin.y = (svh - vh) / 2.0
        } else {
            f.origin.y = 0
        }
        v.frame = f
    }
}

Earlier, I said that the scroll view zooms by applying a scale transform to the scalable view. This has two important secondary consequences that can surprise you if you’re unprepared:

  • The frame of the scalable view is scaled to match the current zoomScale. This follows as a natural consequence of applying a scale transform to the scalable view.
  • The scroll view is concerned to make scrolling continue to work correctly: the limits as the user scrolls should continue to match the limits of the content, and commands like scrollRectToVisible:animated: should continue to work the same way for the same values. Therefore, the scroll view automatically scales its own contentSize to match the current zoomScale.

Zooming Programmatically

To zoom programmatically, you have two choices:

setZoomScale:animated:
Zooms in terms of scale value. The contentOffset is automatically adjusted to keep the current center centered and the content occupying the entire scroll view.
zoomToRect:animated:
Zooms so that the given rectangle of the content occupies as much as possible of the scroll view’s bounds. The contentOffset is automatically adjusted to keep the content occupying the entire scroll view.

In this example, I implement double tapping as a zoom gesture. In my action handler for the double-tap UITapGestureRecognizer attached to the scalable view, a double tap means to zoom to maximum scale, minimum scale, or actual size, depending on the current scale value:

@IBAction func tapped(tap : UIGestureRecognizer) {
    let v = tap.view!
    let sv = v.superview as! UIScrollView
    if sv.zoomScale < 1 {
        sv.setZoomScale(1, animated:true)
    }
    else if sv.zoomScale < sv.maximumZoomScale {
        sv.setZoomScale(sv.maximumZoomScale, animated:true)
    }
    else {
        sv.setZoomScale(sv.minimumZoomScale, animated:true)
    }
}

Zooming with Detail

By default, when a scroll view zooms, it merely applies a scale transform to the scaled view. The scaled view’s drawing is cached beforehand into its layer, so when we zoom in, the bits of the resulting bitmap are drawn larger. This means that a zoomed-in scroll view’s content may be fuzzy (pixellated). In some cases this might be acceptable, but in others you might like the content to be redrawn more sharply at its new size.

(On a high-resolution device, this might not be such an issue. For example, if the user is allowed to zoom only up to double scale, you can draw at double scale right from the start; the results will look good at single scale, because the screen has high resolution, as well as at double scale, because that’s the scale you drew at.)

One solution is to take advantage of a CATiledLayer feature that I didn’t mention earlier. It turns out that CATiledLayer is aware not only of scrolling but also of scaling: you can configure it to ask for tiles to be drawn when the layer is scaled to a new order of magnitude. When your drawing routine is called, the graphics context itself has already been scaled appropriately by a transform.

In the case of an image into which the user is to be permitted to zoom deeply, you would be forearmed with multiple tile sets constituting the image, each set having double the tile size of the previous set (as in Apple’s PhotoScroller example). In other cases, you may not need tiles at all; you’ll just draw again, at the new resolution.

Besides its tileSize, you’ll need to set two additional CATiledLayer properties:

levelsOfDetail
The number of different resolutions at which you want to redraw, where each level has twice the resolution of the previous level. So, for example, with two levels of detail we can ask to redraw when zooming to double size (2x) and when zooming back to single size (1x).
levelsOfDetailBias
The number of levels of detail that are larger than single size (1x). For example, if levelsOfDetail is 2, then if we want to redraw when zooming to 2x and when zooming back to 1x, the levelsOfDetailBias needs to be 1, because one of those levels is larger than 1x. (If we were to leave levelsOfDetailBias at 0, the default, we would be saying we want to redraw when zooming to 0.5x and back to 1x — we have two levels of detail but neither is larger than 1x, so one must be smaller than 1x.)

The CATiledLayer will ask for a redraw at a higher resolution as soon as the view’s size becomes larger than the previous resolution. In other words, if there are two levels of detail with a bias of 1, the layer will be redrawn at 2x as soon as it is zoomed even a little bit larger than 1x. This is an excellent approach, because although a level of detail would look blurry if scaled up, it looks pretty good scaled down.

For example, let’s say I have a TiledView that hosts a CATiledLayer, in which I intend to draw an image. I haven’t broken the image into tiles, because the maximum size at which the user can view it isn’t prohibitively large; the original image is 838×958, and can be held in memory easily. Rather, I’m using a CATiledLayer in order to take advantage of its ability to change resolutions automatically. The image will be displayed initially at 208×238, and if the user never zooms in to view it larger, we can save memory by drawing a quarter-size version of the image.

The CATiledLayer is configured as follows:

let scale = lay.contentsScale
lay.tileSize = CGSizeMake(208*scale,238*scale)
lay.levelsOfDetail = 3
lay.levelsOfDetailBias = 2

The tileSize has been adjusted for screen resolution, so the result is as follows:

  • As originally displayed at 208×238, there is one tile and we can draw our image at quarter size.
  • If the user zooms in, to show the image larger than its originally displayed size, there will be 4 tiles and we can draw our image at half size.
  • If the user zooms in still further, to show the image larger than double its originally displayed size (416×476), there will be 16 tiles and we can draw our image at full size, which will continue to look good as the user zooms all the way in to the full size of the original image.

We do not, however, need to draw each tile individually. Each time we’re called upon to draw a tile, we’ll draw the entire image into the TiledView’s bounds; whatever falls outside the requested tile will be clipped out and won’t be drawn.

Here’s my TiledView’s drawRect: implementation. I have a UIImage property currentImage, initialized to nil, and a CGRect property currentSize initialized to CGSizeZero. Each time drawRect: is called, I compare the tile size (the incoming rect parameter’s size) to currentSize. If it’s different, I know that we’ve changed by one level of detail and we need a new version of currentImage, so I create the new version of currentImage at a scale appropriate to this level of detail. Finally, I draw currentImage into the TiledView’s bounds:

override func drawRect(rect: CGRect) {
    let oldSize = self.currentSize
    if !CGSizeEqualToSize(oldSize, rect.size) {
        // make a new size
        self.currentSize = rect.size
        // make a new image
        let lay = self.layer as! CATiledLayer
        let tr = CGContextGetCTM(UIGraphicsGetCurrentContext()!)
        let sc = tr.a/lay.contentsScale
        let scale = sc/4.0
        let path = NSBundle.mainBundle().pathForResource(
            "earthFromSaturn", ofType:"png")!
        let im = UIImage(contentsOfFile:path)!
        let sz = CGSizeMake(im.size.width * scale, im.size.height * scale)
        UIGraphicsBeginImageContextWithOptions(sz, true, 1)
        im.drawInRect(CGRectMake(0,0,sz.width,sz.height))
        self.currentImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }
    self.currentImage.drawInRect(self.bounds)
}

An alternative and much simpler approach (from a WWDC 2011 video) is to make yourself the scroll view’s delegate so that you get an event when the zoom ends, and then change the scalable view’s contentScaleFactor to match the current zoom scale, compensating for the high-resolution screen at the same time:

func scrollViewDidEndZooming(scrollView: UIScrollView,
    withView view: UIView?,
    atScale scale: CGFloat) {
        if let view = view {
            view.contentScaleFactor = scale * UIScreen.mainScreen().scale
        }
}

In response, the scalable view’s drawRect: will be called, and its rect parameter will be the CGRect to draw into. Thus, the view may appear fuzzy for a while as the user zooms in, but when the user stops zooming, the view is redrawn sharply. That approach comes with a caveat, however: you mustn’t overdo it. If the zoom scale, screen resolution, and scalable view size are high, you will be asking for a very large graphics context to be maintained in memory, which could cause your app to run low on memory or even to be abruptly terminated by the system.

For more about displaying a large image in a zoomable scroll view, see Apple’s Large Image Downsizing example.

Scroll View Delegate

The scroll view’s delegate (adopting the UIScrollViewDelegate protocol) receives lots of messages that can help you track, in great detail, exactly what the scroll view is up to:

scrollViewDidScroll:
If you scroll in code without animation, you will receive this message once afterward. If the user scrolls, either by dragging or with the scroll-to-top feature, or if you scroll in code with animation, you will receive this message repeatedly throughout the scroll, including during the time the scroll view is decelerating after the user’s finger has lifted; there are other delegate messages that tell you, in those cases, when the scroll has finally ended.
scrollViewDidEndScrollingAnimation:
If you scroll in code with animation, you will receive this message afterward, when the animation ends.
scrollViewWillBeginDragging:
scrollViewWillEndDragging:withVelocity:targetContentOffset:
scrollViewDidEndDragging:willDecelerate:

If the user scrolls by dragging, you will receive these messages at the start and end of the user’s finger movement. If the user brings the scroll view to a stop before lifting the finger, willDecelerate is false and the scroll is over. If the user lets go of the scroll view while the finger is moving, or when paging is turned on, willDecelerate is true and we proceed to the delegate messages reporting deceleration.

The purpose of scrollViewWillEndDragging:... is to let you customize the outcome of the content’s deceleration. The third argument is a pointer to a CGPoint; you can use it to set a different CGPoint, specifying the contentOffset value the scroll view should have when the deceleration is over.

scrollViewWillBeginDecelerating:
scrollViewDidEndDecelerating:
Sent once each after scrollViewDidEndDragging:willDecelerate: arrives with a value of true. When scrollViewDidEndDecelerating: arrives, the scroll is over.
scrollViewShouldScrollToTop:
scrollViewDidScrollToTop:
These have to do with the feature where the user can tap the status bar to scroll the scroll view’s content to its top. You won’t get either of them if scrollsToTop is false, because the scroll-to-top feature is turned off in that case. The first lets you prevent the user from scrolling to the top on this occasion even if scrollsToTop is true. The second tells you that the user has employed this feature and the scroll is over.

So, if you wanted to do something after a scroll ends completely regardless of how the scroll was performed, you’d need to implement multiple delegate methods:

  • scrollViewDidEndDragging:willDecelerate: in case the user drags and stops (willDecelerate is false).
  • scrollViewDidEndDecelerating: in case the user drags and the scroll continues afterward.
  • scrollViewDidScrollToTop: in case the user uses the scroll-to-top feature.
  • scrollViewDidEndScrollingAnimation: in case you scroll with animation.

(You don’t need a delegate method to tell you when the scroll is over after you scroll in code without animation: it’s over immediately, so if you have work to do after the scroll ends, you can do it in the next line of code.)

In addition, the scroll view has read-only properties reporting its state:

tracking
The user has touched the scroll view, but the scroll view hasn’t decided whether this is a scroll or some kind of tap.
dragging
The user is dragging to scroll.
decelerating
The user has scrolled and has lifted the finger, and the scroll is continuing.

There are also three delegate messages that report zooming:

scrollViewWillBeginZooming:withView:
If the user zooms or you zoom in code, you will receive this message as the zoom begins.
scrollViewDidZoom:
If you zoom in code, even with animation, you will receive this message once. If the user zooms, you will receive this message repeatedly as the zoom proceeds. (You will probably also receive scrollViewDidScroll:, possibly many times, as the zoom proceeds.)
scrollViewDidEndZooming:withView:atScale:
If the user zooms or you zoom in code, you will receive this message after the last scrollViewDidZoom:.

In addition, the scroll view has read-only properties reporting its state during a zoom:

zooming
The scroll view is zooming. It is possible for dragging to be true at the same time.
zoomBouncing
The scroll view is returning automatically from having been zoomed outside its minimum or maximum limit. As far as I can tell, you’ll get only one scrollViewDidZoom: while the scroll view is in this state.

Scroll View Touches

Improvements in UIScrollView’s internal implementation have eliminated most of the worry once associated with scroll view touches. A scroll view will interpret a drag or a pinch as a command to scroll or zoom, and any other gesture will fall through to the subviews; thus buttons and similar interface objects inside a scroll view work just fine.

You can even put a scroll view inside a scroll view, and this can be quite a useful thing to do, in contexts where you might not think of it at first. Apple’s PhotoScroller example, based on principles discussed in a delightful WWDC 2010 video, is an app where a single photo fills the screen: you can page-scroll from one photo to the next, and you can zoom into the current photo with a pinch gesture. This is implemented as a scroll view inside a scroll view: the outer scroll view is for paging between images, and the inner scroll view contains the current image and is for zooming (and for scrolling to different parts of the zoomed-in image). Similarly, a WWDC 2013 video deconstructs the iOS 7 lock screen in terms of scroll views embedded in scroll views.

Gesture recognizers (Chapter 5) have also greatly simplified the task of adding custom gestures to a scroll view. For instance, some older code in Apple’s documentation, showing how to implement a double tap to zoom in and a two-finger tap to zoom out, uses old-fashioned touch handling, but this is no longer necessary. Simply attach to your scroll view’s scalable subview any gesture recognizers for these sorts of gesture, and they will mediate automatically among the possibilities.

In the past, making something inside a scroll view draggable required setting the scroll view’s canCancelContentTouches property to false. (The reason for the name is that the scroll view, when it realizes that a gesture is a drag or pinch gesture, normally sends touchesCancelled:forEvent: to a subview tracking touches, so that the scroll view and not the subview will be affected.) However, unless you’re implementing old-fashioned direct touch handling, you probably won’t have to concern yourself with this. Regardless of how canCancelContentTouches is set, a draggable control, such as a UISlider, remains draggable inside a scroll view.

Here’s an example of a draggable object inside a scroll view implemented through a gesture recognizer. Suppose we have an image of a map, larger than the screen, and we want the user to be able to scroll it in the normal way to see any part of the map, but we also want the user to be able to drag a flag into a new location on the map. We’ll put the map image in an image view and wrap the image view in a scroll view, with the scroll view’s contentSize the same as the map image view’s size. The flag is a small image view; it’s another subview of the scroll view, and it has a UIPanGestureRecognizer. The gesture recognizer’s action handler allows the flag to be dragged, exactly as described in Chapter 5:

@IBAction func dragging (p : UIPanGestureRecognizer) {
    let v = p.view!
    switch p.state {
    case .Began, .Changed:
        let delta = p.translationInView(v.superview!)
        v.center.x += delta.x
        v.center.y += delta.y
        p.setTranslation(CGPointZero, inView: v.superview)
    default: break
    }
}

The user can now drag the map or the flag (Figure 7-2). Dragging the map brings the flag along with it, but dragging the flag doesn’t move the map. The state of the scroll view’s canCancelContentTouches is irrelevant, because the flag view isn’t tracking the touches manually.

A scrollable map with a draggable flag
Figure 7-2. A scrollable map with a draggable flag

An interesting addition to that example would be to implement autoscrolling, meaning that the scroll view scrolls itself when the user drags the flag close to its edge. This, too, is greatly simplified by gesture recognizers; in fact, we can add autoscrolling code directly to the dragging: action handler:

@IBAction func dragging (p : UIPanGestureRecognizer) {
    let v = p.view!
    switch p.state {
    case .Began, .Changed:
        let delta = p.translationInView(v.superview!)
        v.center.x += delta.x
        v.center.y += delta.y
        p.setTranslation(CGPointZero, inView: v.superview)
        if p.state == .Changed {fallthrough}
    case .Changed:
        // autoscroll
        let sv = self.sv
        let loc = p.locationInView(sv)
        let f = sv.bounds
        var off = sv.contentOffset
        let sz = sv.contentSize
        var c = v.center
        // to the right
        if loc.x > CGRectGetMaxX(f) - 30 {
            let margin = sz.width - CGRectGetMaxX(sv.bounds)
            if margin > 6 {
                off.x += 5
                sv.contentOffset = off
                c.x += 5
                v.center = c
                self.keepDragging(p)
            }
        }
        // to the left
        if loc.x < f.origin.x + 30 {
            let margin = off.x
            if margin > 6 {
                // ...
            }
        }
        // to the bottom
        if loc.y > CGRectGetMaxY(f) - 30 {
            let margin = sz.height - CGRectGetMaxY(sv.bounds)
            if margin > 6 {
                // ...
            }
        }
        // to the top
        if loc.y < f.origin.y + 30 {
            let margin = off.y
            if margin > 6 {
                // ...
            }
        }
    default: break
    }
}
func keepDragging (p : UIPanGestureRecognizer) {
    let del = 0.1
    delay(del) {
        self.dragging(p)
    }
}

The delay in keepDragging: (see Appendix B), combined with the change in offset, determines the speed of autoscrolling. The material marked as omitted in the second, third, and fourth cases is obviously parallel to the first case, and is left as an exercise for the reader.

A scroll view’s touch handling is itself based on gesture recognizers attached to the scroll view, and these are available to your code through the scroll view’s panGestureRecognizer and pinchGestureRecognizer properties. This means that if you want to customize a scroll view’s touch handling, it’s easy to add more gesture recognizers and have them interact with those already attached to the scroll view.

To illustrate, I’ll build on the previous example. Suppose we want the flag to start out offscreen, and we’d like the user to be able to summon it with a rightward swipe. We can attach a UISwipeGestureRecognizer to our scroll view, but it will never recognize its gesture because the scroll view’s own pan gesture recognizer will recognize first. But we have access to the scroll view’s pan gesture recognizer, so we can compel it to yield to our swipe gesture recognizer by sending it requireGestureRecognizerToFail::

self.sv.panGestureRecognizer.requireGestureRecognizerToFail(self.swipe)

The UISwipeGestureRecognizer will recognize a rightward swipe. In my implementation of its action handler, we position the flag, which has been waiting invisibly offscreen, just off to the top left of the scroll view’s visible content, and animate it onto the screen:

@IBAction func swiped (g: UISwipeGestureRecognizer) {
    let sv = self.sv
    let p = sv.contentOffset
    self.flag.frame.origin = p
    self.flag.frame.origin.x -= self.flag.bounds.size.width
    self.flag.hidden = false
    UIView.animateWithDuration(0.25, animations:{
        self.flag.frame.origin.x = p.x
        // thanks for the flag, now stop operating altogether
        g.enabled = false
    })
}

Floating Scroll View Subviews

A scroll view’s subview will appear to “float” over the scroll view if it remains stationary while the rest of the scroll view’s content is being scrolled. Before autolayout, this sort of thing was rather tricky to arrange; you had to use a delegate event to respond to every change in the scroll view’s bounds origin by shifting the “floating” view’s position to compensate, so as to appear to remain fixed. With autolayout, however, all you have to do is set up constraints pinning the subview to something outside the scroll view. Here’s an example:

let iv = UIImageView(image:UIImage(named:"smiley.png"))
iv.translatesAutoresizingMaskIntoConstraints = false
self.sv.addSubview(iv)
let sup = self.sv.superview!
NSLayoutConstraint.activateConstraints([
    iv.rightAnchor.constraintEqualToAnchor(sup.rightAnchor, constant: -5),
    iv.topAnchor.constraintEqualToAnchor(sup.topAnchor, constant: 25)
])

Scroll View Performance

At several points in earlier chapters I’ve mentioned performance problems and ways to increase drawing efficiency. Nowhere are you so likely to need these as in connection with a scroll view. As a scroll view scrolls, views must be drawn very rapidly as they appear on the screen. If the drawing system can’t keep up with the speed of the scroll, the scrolling will visibly stutter.

Performance testing and optimization is a big subject, so I can’t tell you exactly what to do if you encounter stuttering while scrolling. But certain general suggestions (mostly extracted from a really great WWDC 2010 video) should come in handy:

  • Everything that can be opaque should be opaque: don’t force the drawing system to composite transparency, and remember to tell it that an opaque view or layer is opaque by setting its opaque property to true. If you really must composite transparency, keep the size of the nonopaque regions to a minimum; for example, if a large layer is transparent at its edges, break it into five layers — the large central layer, which is opaque, and the four edges, which are not.
  • If you’re drawing shadows, don’t make the drawing system calculate the shadow shape for a layer: supply a shadowPath, or use Core Graphics to create the shadow with a drawing. Similarly, avoid making the drawing system composite the shadow as a transparency against another layer; for example, if the background layer is white, your opaque drawing can itself include a shadow already drawn on a white background.
  • Don’t make the drawing system scale images for you; supply the images at the target size for the correct resolution.
  • In a pinch, you can just eliminate massive swatches of the rendering operation by setting a layer’s shouldRasterize to true. You could, for example, do this when scrolling starts and then set it back to false when scrolling ends.

Apple’s documentation also says that setting a view’s clearsContextBeforeDrawing to false may make a difference. I can’t confirm or deny this; it may be true, but I haven’t encountered a case that positively proves it.

Xcode provides tools that will help you detect inefficiencies in the drawing system. In the Simulator, the Debug menu shows you blended layers (where transparency is being composited) and images that are being copied, misaligned, or rendered offscreen. On the device, the Core Animation module of Instruments provides the same functionality, plus it tracks the frame rate for you, allowing you to scroll and measure performance objectively where it really counts.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.204.201