Chapter 7. Scroll Views

This chapter has been revised for Early Release. It reflects iOS 14, Xcode 12, and Swift 5.3. But screenshots have not been retaken; they still show the Xcode 11 / iOS 13 interface.

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. The scroll view functions as a limited window on a larger world of content.

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 true, so any content positioned within the scroll view is visible and any content positioned outside it is not.

A scroll view has the following specialized 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 delegate methods so that your code knows how the user is scrolling and zooming.

Content Size

How far should a scroll view scroll? Clearly, that depends on how much content it has. The scroll view 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 CGPoint.zero. 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 be helpful to think of the scroll view’s scrollable content as the rectangle defined by CGRect(origin:.zero, 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 .zero — 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, the contentSize can be calculated for you based on the autolayout constraints of the scroll view’s subviews (as I’ll demonstrate in a moment).

How big should a scroll view’s content size be? Clearly that depends on the size of its subviews. Typically, you’ll want the content size to be just large enough to embrace all the subviews: they, after all, are the content the user needs to be able to see. You’ll likely want to set the content size correctly at the outset. You can subsequently alter a scroll view’s content (subviews) or contentSize, or both, dynamically as the app runs; in and of themselves, they are independent.

A contentSize that has been set manually, in code, does not change just because the scroll view’s bounds change; you might not want the contentSize to change in response to rotation, but if you do, you will need to change it manually, in code, again. On the other hand, if a scroll view’s content size is set automatically using autolayout based on the scroll view’s subviews, then if the content size embraces those subviews it will continue to do so when the app orientation changes.

Creating a Scroll View in Code

Let’s start by creating a scroll view, providing it with subviews, and making those subviews viewable by scrolling, entirely in code. There are two ways to set the contentSize, so I’ll demonstrate each of them in turn.

Manual Content Size

In the first instance, let’s not use autolayout at all. Our project is based on the basic App template, with a single view controller class, ViewController. In ViewController’s viewDidLoad, I’ll create the scroll view to fill the main view, and populate it with a vertical column of 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 = .white
var y : CGFloat = 10
for i in 0 ..< 30 {
    let lab = UILabel()
    lab.text = "This is label (i+1)"
    lab.sizeToFit()
    lab.frame.origin = CGPoint(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 contentSize is to be. Our sz.height accommodates all the labels, but sz.width matches the scroll view’s width; the scroll view will be scrollable vertically but not horizontally (a common scenario).

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.

Automatic Content Size with Autolayout

With autolayout, things are different. Under autolayout, a scroll view interprets the constraints of its immediate subviews in a special way. Constraints between a scroll view and its direct subviews are not a way of positioning the subviews relative to the scroll view (as they would be if the superview were an ordinary UIView). Rather, they are a way of describing the scroll view’s contentSize from the inside out.

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 = .white
sv.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(sv)
NSLayoutConstraint.activate([
    sv.topAnchor.constraint(equalTo:self.view.topAnchor),
    sv.bottomAnchor.constraint(equalTo:self.view.bottomAnchor),
    sv.leadingAnchor.constraint(equalTo:self.view.leadingAnchor),
    sv.trailingAnchor.constraint(equalTo:self.view.trailingAnchor),
])
var previousLab : UILabel? = nil
for i in 0 ..< 30 {
    let lab = UILabel()
    // lab.backgroundColor = .red
    lab.translatesAutoresizingMaskIntoConstraints = false
    lab.text = "This is label (i+1)"
    sv.addSubview(lab)
    lab.leadingAnchor.constraint(
        equalTo: sv.leadingAnchor, constant: 10).isActive = true
    lab.topAnchor.constraint(
        // first one, pin to top; all others, pin to previous
        equalTo: previousLab?.bottomAnchor ?? sv.topAnchor,
        constant: 10).isActive = true
    previousLab = lab
}

The labels are correctly positioned relative to one another, but the scroll view isn’t scrollable. Moreover, setting the contentSize manually doesn’t help; it has no effect!

Why is that? It’s because we’re using autolayout, so we must generate the contentSize by means of constraints between the scroll view and its immediate subviews. We’ve almost done that, but not quite. We are missing a constraint. We have to add one more constraint, showing the scroll view what the height of its contentSize should be:

sv.bottomAnchor.constraint(
    equalTo: previousLab!.bottomAnchor, constant: 10).isActive = true

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 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.

We should also provide a contentSize width; here, I’ll add a trailing constraint from the bottom label, which will be narrower than the scroll view, so we won’t actually scroll horizontally:

previousLab!.trailingAnchor.constraint(
    equalTo:sv.trailingAnchor).isActive = true

Scroll View Layout Guides

Starting in iOS 11, there’s another way to determine a scroll view’s content size. A UIScrollView has a contentLayoutGuide. There are two ways to use it:

Direct dimension constraints

If we give the content layout guide a width constraint or a height constraint, we can determine the contentSize directly.

Self-sizing based on subviews

If we pin the scroll view’s subviews to the content layout guide, we can determine the contentSize from the inside out; this is like pinning the subviews to the scroll view itself, but it’s preferable, because it’s clearer what we’re doing.

I’ll rewrite the preceding example to use the contentLayoutGuide:

let sv = UIScrollView()
sv.backgroundColor = .white
sv.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(sv)
NSLayoutConstraint.activate([
    sv.topAnchor.constraint(equalTo:self.view.topAnchor),
    sv.bottomAnchor.constraint(equalTo:self.view.bottomAnchor),
    sv.leadingAnchor.constraint(equalTo:self.view.leadingAnchor),
    sv.trailingAnchor.constraint(equalTo:self.view.trailingAnchor),
])
let svclg = sv.contentLayoutGuide // *
var previousLab : UILabel? = nil
for i in 0 ..< 30 {
    let lab = UILabel()
    // lab.backgroundColor = .red
    lab.translatesAutoresizingMaskIntoConstraints = false
    lab.text = "This is label (i+1)"
    sv.addSubview(lab)
    lab.leadingAnchor.constraint(
        equalTo: svclg.leadingAnchor,
        constant: 10).isActive = true
    lab.topAnchor.constraint(
        // first one, pin to top; all others, pin to previous
        equalTo: previousLab?.bottomAnchor ?? svclg.topAnchor,
        constant: 10).isActive = true
    previousLab = lab
}
svclg.bottomAnchor.constraint(
    equalTo: previousLab!.bottomAnchor, constant: 10).isActive = true
svclg.widthAnchor.constraint(equalToConstant:0).isActive = true // *

The last line demonstrates that we can set the content layout guide’s height or width constraint directly to determine that dimension of the content size. Thanks to the content layout guide, I’m able to set the content size width directly to zero, which states precisely what I mean: don’t scroll horizontally.

Also starting in iOS 11, there’s a second UIScrollView property, its frameLayoutGuide, which is pinned to the scroll view’s frame. This gives us an even better way to state that the scroll view should not scroll horizontally, by making the content layout guide width the same as the frame layout guide width:

let svflg = sv.frameLayoutGuide
svclg.widthAnchor.constraint(equalTo:svflg.widthAnchor).isActive = true

Using a Content View

A commonly used arrangement is to give a scroll view just one immediate subview; all other views inside the scroll view are subviews of this single immediate subview of the scroll view, which is often called the content view. The content view is usually a generic UIView; the user won’t even know it’s there. It has no purpose other than to contain the other subviews — and to help determine the scroll view’s content size. The idea is that the scroll view’s contentSize should exactly match the dimensions of the content view. There are two ways to achieve that, depending on whether we want to set the content size manually or using autolayout:

  • 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 with constraints to the scroll view’s content layout guide. If all four of those edge constraints have a constant of 0, the scroll view’s contentSize will be the same as the size of the content view.

This arrangement works independently of whether the content view’s own subviews are positioned explicitly by their frames or using constraints, so there are four possible combinations:

No constraints

The content view is sized by its frame, its contents are positioned by their frames, 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 its edges are pinned to the content layout guide to set the scroll view’s content size.

Content view and content constraints

The content view is sized from the inside out by the constraints of its subviews, and its edges are pinned to the content layout guide to set the scroll view’s 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. (This option is rather far-fetched, but I list it for the sake of completeness.)

I’ll illustrate by rewriting the previous example to use a content view (v) in each of those ways. All four possible combinations start the same way:

let sv = UIScrollView()
sv.backgroundColor = .white
sv.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(sv)
NSLayoutConstraint.activate([
    sv.topAnchor.constraint(equalTo:self.view.topAnchor),
    sv.bottomAnchor.constraint(equalTo:self.view.bottomAnchor),
    sv.leadingAnchor.constraint(equalTo:self.view.leadingAnchor),
    sv.trailingAnchor.constraint(equalTo:self.view.trailingAnchor),
])
let v = UIView() // content view!
sv.addSubview(v)

The differences lie in what happens next. The first combination is that no constraints are used, and the scroll view’s content size is set explicitly. It’s very like the first example in this chapter, except that the labels are added to the content view, not to the scroll view. The content view’s height is a little taller than the bottom of the lowest label, and its width is a little wider than the widest label, so it neatly contains all the labels:

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

The second combination is that the content view is sized by explicit width and height constraints, and its edges are pinned by constraints to the scroll view’s content layout guide to give the scroll view a content size:

var y : CGFloat = 10
var maxw : CGFloat = 0
for i in 0 ..< 30 {
    let lab = UILabel()
    lab.text = "This is label (i+1)"
    lab.sizeToFit()
    lab.frame.origin = CGPoint(10,y)
    v.addSubview(lab)
    y += lab.bounds.size.height + 10
    maxw = max(maxw, lab.frame.maxX + 10)
}
// set content view width, height, and edge constraints
// content size is calculated for us
v.translatesAutoresizingMaskIntoConstraints = false
let svclg = sv.contentLayoutGuide
NSLayoutConstraint.activate([
    v.widthAnchor.constraint(equalToConstant:maxw),
    v.heightAnchor.constraint(equalToConstant:y),
    svclg.topAnchor.constraint(equalTo:v.topAnchor),
    svclg.bottomAnchor.constraint(equalTo:v.bottomAnchor),
    svclg.leadingAnchor.constraint(equalTo:v.leadingAnchor),
    svclg.trailingAnchor.constraint(equalTo:v.trailingAnchor),
])

The third combination is that the content view is self-sizing based on constraints from its subviews (“Self-Sizing Views”), and the content view’s edges are pinned by constraints to the scroll view’s content layout guide. In a very real sense, the scroll view gets its content size from the labels. This is similar to the second example in this 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 = .red
    lab.translatesAutoresizingMaskIntoConstraints = false
    lab.text = "This is label (i+1)"
    v.addSubview(lab)
    lab.leadingAnchor.constraint(
        equalTo: v.leadingAnchor,
        constant: 10).isActive = true
    lab.topAnchor.constraint(
        // first one, pin to top; all others, pin to previous
        equalTo: previousLab?.bottomAnchor ?? v.topAnchor,
        constant: 10).isActive = true
    previousLab = lab
}
// last one, pin to bottom, this dictates content size height
v.bottomAnchor.constraint(
    equalTo: previousLab!.bottomAnchor, constant: 10).isActive = true
// need to do something about width
v.trailingAnchor.constraint(
    equalTo: previousLab!.trailingAnchor, constant: 10).isActive = true
// pin content view to scroll view, sized by its subview constraints
// content size is calculated for us
v.translatesAutoresizingMaskIntoConstraints = false
let svclg = sv.contentLayoutGuide
NSLayoutConstraint.activate([
    svclg.topAnchor.constraint(equalTo:v.topAnchor),
    svclg.bottomAnchor.constraint(equalTo:v.bottomAnchor),
    svclg.leadingAnchor.constraint(equalTo:v.leadingAnchor),
    svclg.trailingAnchor.constraint(equalTo:v.trailingAnchor),
])

The fourth (and somewhat improbable) 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. How can we derive the content view size from the constraints of its subviews? By calling systemLayoutSizeFitting(_:) to perform layout for us:

var previousLab : UILabel? = nil
for i in 0 ..< 30 {
    let lab = UILabel()
    // lab.backgroundColor = .red
    lab.translatesAutoresizingMaskIntoConstraints = false
    lab.text = "This is label (i+1)"
    v.addSubview(lab)
    lab.leadingAnchor.constraint(
        equalTo: v.leadingAnchor,
        constant: 10).isActive = true
    lab.topAnchor.constraint(
        // first one, pin to top; all others, pin to previous
        equalTo: previousLab?.bottomAnchor ?? v.topAnchor,
        constant: 10).isActive = true
    previousLab = lab
}
// last one, pin to bottom, this dictates content size height!
v.bottomAnchor.constraint(
    equalTo: previousLab!.bottomAnchor, constant: 10).isActive = true
// need to do something about width
v.trailingAnchor.constraint(
    equalTo: previousLab!.trailingAnchor, constant: 10).isActive = true
// autolayout helps us learn the consequences of those constraints
let minsz = v.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
// set content view frame and content size explicitly
v.frame = CGRect(origin:.zero, size:minsz)
sv.contentSize = minsz

Scroll View in a Nib

A UIScrollView object is available in the nib editor’s 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: to do so, select the views and choose Editor → Embed In → Scroll View (or choose Scroll View from the Embed button at the lower right of the canvas).

There is no provision for entering a scroll view’s contentSize numerically in the nib editor, so you’ll have to use autolayout. Starting in Xcode 11, a scroll view’s content layout guide and frame layout guide are present in the nib editor (if you don’t see them, check Content Layout Guides in the scroll view’s Size inspector). Unfortunately, you can’t configure the content layout guide’s width or height constraints directly (I regard that as a bug). But you can constrain subviews of the scroll view in terms of the layout guides, and so you can use autolayout to configure a scroll view in the nib editor much as you’d configure it in code. The nib editor understands how scroll view configuration works, 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.

While designing the scroll view’s subviews initially, you might need to make the view controller’s main view large enough to accommodate them. To do so, set the view controller’s Simulated Size pop-up menu in its Size inspector to Freeform; now you can change the main view’s size. But once you’ve provided sufficient constraint information, the scroll view is scrollable directly in the nib editor.

Figure 7-1 shows a scroll view whose content size is configured in the nib editor using a content view. The content view is the scroll view’s subview; it has explicit width and height constraints and is pinned with zero-constant constraints to the scroll view’s content layout guide. The width and height constraint constants of the content view are 320 and 772 respectively, so this is saying that the scroll view’s contentSize should be (320,772).

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

We can now proceed to populate the content view with subviews. Purely for demonstration purposes, I’ll use four plain vanilla UIViews, and I won’t use autolayout. I can design the first three views directly, because they fit within the main view (Figure 7-2, left). But what about the last view? The scroll view’s content size is determined, so I can scroll the scroll view directly in the nib editor; once the bottom of the content view is within the main view, I can add the fourth view (Figure 7-2, right).

pios 2001b
Figure 7-2. Designing a scroll view’s contents

Instead of dictating the scroll view’s content size numerically with explicit width and height constraints on the content view, you can apply constraints to the content view from its subviews and let those subviews size the content view from the inside out, setting the scroll view’s contentSize as a result. In Figure 7-3, the subviews have explicit height and width constraints and are pinned to one another and to the top and bottom of the content view, and the content view is pinned to the scroll view’s content layout guide, so the scroll view is scrollable to view all of the subviews. Moreover, I’ve given the subviews zero-constant leading and trailing constraints to the content view, and I’ve centered the content view horizontally within the scroll view’s frame layout guide; the result is that the subviews are centered horizontally on any screen size.

pios 2001c
Figure 7-3. A scroll view whose content is sized by its subviews

(Strictly speaking, I suppose the content view in that example is unnecessary; we could pin the scroll view’s subviews directly to its content layout guide.)

Content Inset

The content inset of a scroll view is a margin space around its content. In effect, it changes where the content stops when the scroll view is scrolled all the way to its extreme limit.

The main reason why this is important is that a scroll view will typically underlap other interface. In 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 — and the view controller’s main view underlaps the status bar. That means that the top of 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, there’s a danger that the first label, which is now as far down as it can go, will be partly hidden by the text of the status bar. The issue will be even more acute on a device without a bezel, such as the iPhone X, where the status bar is taller to accommodate the “notch” containing the front camera; the “notch” will prevent the user from ever being able to see the top center of the scroll view’s content.

You might say: Well, don’t do that! Don’t let the scroll view underlap the status bar in the first place; pin the top of the scroll view to the bottom of the status bar. But that isn’t necessarily what we want; and in any case, the problem is not where the top of the scroll view is, but where the top of its content is considered to be. When the content is being scrolled upward, it’s fine for that content to pass behind the status bar! The problem is what happens when the content is moved downward as far as it can go. Its content top shouldn’t stop at the top of the scroll view; the stopping point should be further down, at the bottom of the status bar.

That’s the problem that the scroll view’s content inset solves. It positions the top of the scroll view’s content lower than the top of the scroll view, by the amount that the scroll view underlaps the status bar.

But there’s more. The content inset can’t be a simple static value; it needs to be live. The status bar can come and go. The top of the scroll view’s content should be further down than the top of the scroll view itself when the iPhone is in portrait orientation and the status bar is present; but when the iPhone is in landscape orientation and the status bar vanishes, the content inset needs to be adjusted so that the top of the content will be identical to the top of the scroll view.

And the status bar isn’t the only kind of top bar; there’s also the navigation bar, which can come and go as well, and can change its height. Plus there are bottom bars, which can also come and go, and can change their heights. The runtime would like your view to underlap those bars. With a scroll view, this looks cool, because the scroll view’s contents are visible in a blurry way through the translucent bar; but clearly the top and bottom values of the scroll view’s content inset need to be adjusted so that the scrolling limits stay between the top bar and the bottom bar, even as these bars can come and go and change their heights.

The boundaries I’m describing here, as has no doubt already occurred to you, are the boundaries of the safe area (“Safe area”). If a scroll view would simply adjust its content inset automatically to correspond to the safe area, its content would scroll in exactly the right way so as to be visible regardless of how any bars are underlapped.

That, by default, is in fact exactly what does happen! A scroll view knows where the top and bottom bars are because the safe area is propagated down the view hierarchy, and it knows how to adjust its content inset to correspond to the safe area. It will do that in accordance with its contentInsetAdjustmentBehavior property (UIScrollView.ContentInsetAdjustmentBehavior):

.always

The content is inset to match the safe area.

.never

The content is not inset to match the safe area.

.scrollableAxes

The content is inset to match the safe area only for a dimension in which the scroll view is scrollable.

.automatic

Similar to scrollableAxes, but is also backward compatible. In iOS 10 and before, a view controller had an automaticallyAdjustsScrollViewInsets property (now deprecated); .automatic means that the scroll view can respond to that as well.

The default contentInsetAdjustmentBehavior is .automatic — which means that your scroll view will probably adjust its content inset appropriately with no work on your part! (To see what would have happened without adjustment of the content inset, set the scroll view’s contentInsetAdjustmentBehavior to .never.)

To learn numerically how the scroll view has set its content inset, consult its adjustedContentInset property. Suppose we’re in a navigation interface, where the scroll view coincides with the view controller’s main view and underlaps the top bars. Suppose further that the navigation bar doesn’t have a large title. Then in portrait orientation on an iPhone, the status bar and the navigation bar together add 64 points of height to the top of the safe area. So if the scroll view’s content inset adjustment behavior isn’t .never, its adjustedContentInset is (64.0,0.0,0.0,0.0).

A scroll view also has a separate contentInset property; unlike the adjustedContentInset, it is settable. It will usually be .zero; if you set it to some other value, that value is applied additively to the adjustedContentInset. In the navigation interface scenario from the preceding paragraph, if we also set the scroll view’s contentInset to a UIEdgeInsets whose top: is 30, then the adjustedContentInset will have a top value of 94, and there will be an additional 30-point gap between the top of the content and the bottom of the navigation bar when the content is scrolled all the way down.

Tip

Starting in iOS 13, if a navigation bar has a large title, then as the scroll view’s content is scrolled downward and the large title appears, the navigation bar becomes transparent by default, revealing the scroll view behind it. This shouldn’t make any appreciable difference, because ex hypothesi the entire content of the scroll view is already scrolled down past the bottom of the navigation bar, so the navigation bar won’t overlap that content.

Scrolling

For the most part, the purpose of a scroll view will be to let the user scroll. Here are some scroll view properties that affect the user experience with regard to scrolling:

isScrollEnabled

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; 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 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 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 this dimension.

isDirectionalLockEnabled

If true, and if scrolling is possible in both dimensions (even if only because 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 (UIScrollView.DecelerationRate):

  • .normal (0.998)

  • .fast (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 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); they indicate both the size of the content in that dimension and the user’s position 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.

indicatorStyle

The way the scroll indicators are drawn. Your choices (UIScrollView.IndicatorStyle) are .black, .white, and .default; like the status bar style, .default responds to the user interface style (light or dark mode), contrasting with a system background color.

Tip

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!

Scrolling in Code

You can scroll in code, and you can do so even if the user can’t scroll. The content 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:

scrollRectToVisible(_:animated:)

Adjusts the content so that the specified CGRect of the content is within the scroll view’s bounds. This is imprecise, 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.

contentOffset

A property signifying 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). Setting it changes the current scroll position, or call setContentOffset(_:animated:) to set the contentOffset with animation. 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.

The adjustedContentInset (discussed in the previous section) can affect the meaning of the contentOffset. Recall the scenario where the scroll view underlaps the status bar and a navigation bar and acquires an adjustedContentInset with a top of 64. Then when the scroll view’s content is scrolled all the way down, the contentOffset is not (0.0,0.0) but (0.0,-64.0). The (0.0,0.0) point is the top of the content rect, which is located at the bottom of the navigation bar; the point at the top left of the scroll view itself is 64 points above that.

That fact manifests itself particularly when you want to scroll, in code. If you scroll by setting the contentOffset, you need to subtract the corresponding adjustedContentInset value. Staying with our scroll view that underlaps a navigation bar, if your goal is to scroll the scroll view so that the top of its content is visible, you do not say this (the scroll view is self.sv):

self.sv.contentOffset.y = 0

Instead, you say this:

self.sv.contentOffset.y = -self.sv.adjustedContentInset.top

Paging

If its isPagingEnabled 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. This is a paging scroll view.

When the user stops dragging, a paging scroll view gently snaps automatically to the nearest whole page. Let’s say that a paging 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 as large, or nearly as large, in its scrollable dimension, as the window. 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 size of the page is the size of the scroll view’s bounds, so 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 skip a page.

Another possibility is for the paging scroll view to 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 possible to drag more than half a new page into view.

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. These messages can be used to detect efficiently that the page may have changed.

Using the delegate methods, a paging scroll view can be coordinated with a UIPageControl (Chapter 13). 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:

@IBAction func userDidPage(_ sender: Any) {
    let p = self.pager.currentPage
    let w = self.sv.bounds.size.width
    self.sv.setContentOffset(CGPoint(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. In fact, a scrolling UIPageViewController (Chapter 6) implements exactly that interface! Its .interPageSpacing options key 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.)

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. One solution to this kind of problem is tiling.

The idea behind tiling is that there’s 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. 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 newly visible empty 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. The usual approach to using CATiledLayer is to implement draw(_:) in a UIView whose underlying layer is the CATiledLayer; under that arrangement, the host view’s draw(_:) is called every time a new tile is needed, and its parameter is 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, 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, I’ll use as my tiles a few of the “CuriousFrog” images already created for us as part of Apple’s own PhotoScroller sample code. The images 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; for this example, I’ve selected a 3×3 matrix of images.

We will give our scroll view (self.sv) one subview, a UIView subclass (called TiledView) 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 = CGRect(0,0,3*TILESIZE,3*TILESIZE)
    let content = TiledView(frame:f)
    let tsz = TILESIZE * content.layer.contentsScale
    (content.layer as! CATiledLayer).tileSize = CGSize(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 var layerClass : AnyClass {
    return CATiledLayer.self
}
override func draw(_ 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 = Bundle.main.path(forResource: tileName, ofType:"png")!
    let image = UIImage(contentsOfFile:path)!
    image.draw(at:CGPoint(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 tile image and drawing it are thread-safe.

There is no special call for invalidating an offscreen tile. You’re just supposed to trust that the CATiledLayer will eventually clear offscreen tiles if needed in order 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 viewForZooming(in:) 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 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 from earlier in this chapter (“Using a Content View”). 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 viewForZooming(in:) to return the scalable view:

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

This works: the scroll view now responds to pinch gestures by scaling! Recall that in our 30 labels example, the scroll view is not scrollable horizontally. Nevertheless, in this scenario, the width of the content view matters, because when it is scaled up, including during the act of zooming, the user will be able to scroll to see any part of it. So a good policy would be for the content view to embrace its content quite tightly.

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.

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?.viewForZooming?(in: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
    }
}

Zooming is, in reality, the application of a scale transform to the scalable view. This has two important consequences that can surprise you if you’re unprepared:

The frame of the scalable view

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 contentSize of the scroll 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:

zoomTo(_: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.

setZoomScale(_:animated:)

Zooms in terms of scale value. The contentOffset is automatically adjusted to keep the current center centered, with the content occupying the entire scroll view.

In this example, I implement double tapping as a zoom gesture. In my action method 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)
        let pt = CGPoint((v.bounds.width - sv.bounds.width)/2.0,0)
        sv.setContentOffset(pt, animated:false)
    }
    else if sv.zoomScale < sv.maximumZoomScale {
        sv.setZoomScale(sv.maximumZoomScale, animated:true)
    }
    else {
        sv.setZoomScale(sv.minimumZoomScale, animated:true)
    }
}

Zooming with Detail

Sometimes, you may want more from zooming than the mere application of 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). You might prefer the content to be redrawn more sharply at its new size.

(On a high-resolution device, this might not be an issue. 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 redrawn 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 by a transform.

In the case of an image into which the user is to be permitted to zoom deeply, you might 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.

levelsOfDetailBias

The number of levels of detail that are larger than single size (1x).

Those two properties work together. To illustrate, suppose we specify two levels of detail. Then we can ask to redraw when zooming to double size (2x) and when zooming back to single size (1x). But that isn’t the only thing two levels of detail might mean; to complete the meaning, we need to set the levelsOfDetailBias. 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.

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 happens to be 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 drawn initially at less than quarter-size (namely 208×238), and we will permit the user to zoom in to the full size of the image. If the user never zooms in to view the image larger than the initial display, we will be saving a considerable amount of memory; if the user does zoom in, that will cost us more memory, but we have determined that this won’t be prohibitive.

The CATiledLayer is configured like this:

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

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

  • 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 don’t 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 draw(_:) implementation. I have an Optional UIImage property currentImage, initialized to nil, and a CGSize property currentSize initialized to .zero. Each time draw(_:) 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 (lay, bounds) = DispatchQueue.main.sync {
        return (self.layer as! CATiledLayer, self.bounds)
    }
    let oldSize = self.currentSize
    if !oldSize.equalTo(rect.size) {
        // make a new size
        self.currentSize = rect.size
        // make a new image
        let tr = UIGraphicsGetCurrentContext()!.ctm
        let sc = tr.a/lay.contentsScale
        let scale = sc/4.0
        let path = Bundle.main.path(
            forResource: "earthFromSaturn", ofType:"png")!
        let im = UIImage(contentsOfFile:path)!
        let sz = CGSize(im.size.width * scale, im.size.height * scale)
        let f = UIGraphicsImageRendererFormat.default()
        f.opaque = true; f.scale = 1 // *
        let r = UIGraphicsImageRenderer(size: sz, format: f)
        self.currentImage = r.image { _ in
            im.draw(in:CGRect(origin:.zero, size:sz))
        }
    }
    self.currentImage?.draw(in:bounds)
}

(The DispatchQueue.main.sync call at the start initializes my local variables lay and bounds on the main thread, even though drawRect is called on a background thread; see Chapter 25.)

An alternative 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,
    with view: UIView?, atScale scale: CGFloat) {
        if let view = view {
            scrollView.bounces = self.oldBounces
            view.contentScaleFactor = scale * UIScreen.main.scale // *
        }
}

In response, the scalable view’s draw(_:) will be called, and its rect parameter will be the CGRect to draw into. 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: 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, which could cause your app to use too much memory.

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 messages that let you track in great detail 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. By taking the velocity: into account, you can allow the user to “fling” the scroll view with momentum before it comes to a halt.

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. 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.

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.

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

isTracking

The user has touched the scroll view, but the scroll view hasn’t decided whether this is a scroll or some kind of tap.

isDragging

The user is dragging to scroll.

isDecelerating

The user has scrolled and has lifted the finger, and the scroll is continuing.

There are also three delegate messages that report zooming:

scrollViewWillBeginZooming(_:with:)

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(_:with: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:

isZooming

The scroll view is zooming. It is possible for isDragging to be true at the same time.

isZoomBouncing

The scroll view’s bouncesZoom is true, and now it is bouncing: it was zoomed beyond its minimum or maximum limit, and now it is returning automatically to that limit. As far as I can tell, you’ll get only one scrollViewDidZoom(_:) while the scroll view is in this state.

The delegate also receives scrollViewDidChangeAdjustedContentInset(_:) when the adjusted content inset changes. This is matched by a method adjustedContentInsetDidChange that can be overridden in a UIScrollView subclass.

Scroll View Touches

Since the early days of iOS, improvements in UIScrollView’s internal implementation have eliminated most of the worry once associated with touches inside a scroll view. 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; 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 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, used 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(_:with:) to a subview tracking touches, so that the scroll view and not the subview will be affected.) But unless you’re implementing old-fashioned direct touch handling, you probably won’t have to concern yourself with this.

Some draggable controls might benefit from setting the scroll view’s delaysContentTouches to false; for instance, a UISwitch or a UISlider in a horizontally scrollable scroll view might be more responsive to a sliding gesture. Nevertheless, the user can slide the control even if delaysContentTouches is true; it’s just that the user might need to hold down on the control a bit longer first, to get the scroll view’s gesture recognizer to bow out.

(You can set both delaysContentTouches and canCancelContentTouches in the nib editor.)

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 pan gesture recognizer’s action method 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.translation(in:v.superview!)
        v.center.x += delta.x
        v.center.y += delta.y
        p.setTranslation(.zero, in: v.superview)
    default: break
    }
}

The user can now drag the map or the flag (Figure 7-4). Dragging the map brings the flag along with it, but dragging the flag doesn’t move the map.

pios 2004
Figure 7-4. 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 method:

@IBAction func dragging (_ p: UIPanGestureRecognizer) {
    let v = p.view!
    switch p.state {
    case .began, .changed:
        let delta = p.translation(in:v.superview!)
        v.center.x += delta.x
        v.center.y += delta.y
        p.setTranslation(.zero, in: v.superview)
        // autoscroll
        let sv = self.sv!
        let loc = p.location(in:sv)
        let f = sv.bounds
        var off = sv.contentOffset
        let sz = sv.contentSize
        var c = v.center
        // to the right
        if loc.x > f.maxX - 30 {
            let margin = sz.width - sv.bounds.maxX
            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 > f.maxY - 30 {
            let margin = sz.height - sv.bounds.maxY
            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 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 mediate between them and the gesture recognizers 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 require(toFail:):

self.sv.panGestureRecognizer.require(toFail:self.swipe)

The UISwipeGestureRecognizer can now recognize a rightward swipe. The flag has been waiting invisibly offscreen; in the gesture recognizer’s action method, we position the flag just off to the top left of the scroll view’s visible content and animate it onto the screen. We then disable the swipe gesture recognizer; its work is done:

@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.width
    self.flag.isHidden = false
    UIView.animate(withDuration: 0.25) {
        self.flag.frame.origin.x = p.x
        // thanks for the flag, now stop operating altogether
        g.isEnabled = 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, that 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, an easy solution is to set up constraints pinning the subview to something outside the scroll view. Even better, the scroll view itself provides a frameLayoutGuide; pin a subview to that, to make the subview stand still while the scroll view scrolls.

In this example, the image view is a subview of the scroll view that doesn’t move during scrolling:

let iv = UIImageView(image:UIImage(named:"smiley"))
iv.translatesAutoresizingMaskIntoConstraints = false
self.sv.addSubview(iv)
let svflg = self.sv.frameLayoutGuide
NSLayoutConstraint.activate([
    iv.rightAnchor.constraint(equalTo:svflg.rightAnchor, constant: -5),
    iv.topAnchor.constraint(equalTo:svflg.topAnchor, constant: 25)
])

Scroll View Performance

Several times in earlier chapters I’ve mentioned performance problems and ways to increase drawing efficiency. The likeliest place to encounter such issues is 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 (and see also “Layer Efficiency”, some of which I’m repeating here):

  • 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 isOpaque property to true. If you really must composite transparency, keep the size of the nonopaque regions to a minimum.

  • 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; 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.

  • Coalesce layers (including views). The fewer layers constitute the render tree, the less work the drawing system has to do in order to render them.

  • In a pinch, you can just eliminate massive swatches of the rendering operation by setting a layer’s shouldRasterize to true. You could 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 lets you display blended layers (where transparency is being composited) and images that are being copied, misaligned, or rendered offscreen; the Xcode Debug → View Debugging → Rendering hierarchical menu provides even more options. On a device, the Core Animation template of Instruments tracks the frame rate for you, allowing you to measure performance objectively while scrolling. New in Xcode 12, in a UI test, XCTOSSignpostMetric lets you track the efficiency of scroll dragging and deceleration by measuring the ratio of animation “hitches,” as well as frame rate, between os_signpost calls; create the starting signpost with the OSSignpostAnimationBegin case .animationBegin, as explained in the WWDC 2020 video on this topic.

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

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