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 flicking, 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 YES, so any content positioned within the scroll view’s bounds width and height is visible and any content positioned outside them is not.
In addition, a scroll view brings to the table some nontrivial abilities:
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 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}
. 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 {CGPointZero, contentSize}
; this is the rectangle that the user can inspect by scrolling. In effect, therefore, the contentSize
is how large the scrollable content is.
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 contentSize
is {0,0}
by default, meaning that the scroll view isn’t scrollable.
You can set the contentSize
directly, in code. If you’re using autolayout (Chapter 1), the contentSize
is calculated for you based on the constraints of the scroll view’s subviews.
To illustrate, I’ll start by creating a scroll view, providing it with subviews, and making those subviews viewable by scrolling, entirely in code. 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, and with the storyboard’s “Use Auto Layout” unchecked. 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:
UIScrollView* sv = [[UIScrollView alloc] initWithFrame: self.view.bounds]; sv.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [self.view addSubview:sv]; sv.backgroundColor = [UIColor whiteColor]; CGFloat y = 10; for (int i=0; i<30; i++) { UILabel* lab = [UILabel new]; lab.text = [NSString stringWithFormat:@"This is label %d", i+1]; [lab sizeToFit]; CGRect f = lab.frame; f.origin = CGPointMake(10,y); lab.frame = f; [sv addSubview:lab]; y += lab.bounds.size.height + 10; } CGSize 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:
f.size.width = self.view.bounds.size.width - 20; lab.frame = f; lab.backgroundColor = [UIColor redColor]; // make label bounds visible lab.autoresizingMask = UIViewAutoresizingFlexibleWidth;
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.
With autolayout, things are different. The difficult thing to understand — and it is certainly counterintuitive — is that a constraint between a scroll view and a direct subview of that scroll view 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. If the only change is that the storyboard’s “Use Auto Layout” is checked, the example continues to work, because the scroll view and its subviews all have their translatesAutoresizingMaskIntoConstraints
set to YES by default; all constraints are implicit, as if we weren’t using autolayout at all. But what if the scroll view and its subviews have their translatesAutoresizingMaskIntoConstraints
set to NO, and we’re giving them explicit constraints? Let’s try it:
UIScrollView* sv = [UIScrollView new]; sv.backgroundColor = [UIColor whiteColor]; sv.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:sv]; [self.view addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"H:|[sv]|" options:0 metrics:nil views:@{@"sv":sv}]]; [self.view addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[sv]|" options:0 metrics:nil views:@{@"sv":sv}]]; UILabel* previousLab = nil; for (int i=0; i<30; i++) { UILabel* lab = [UILabel new]; lab.translatesAutoresizingMaskIntoConstraints = NO; lab.text = [NSString stringWithFormat:@"This is label %d", i+1]; [sv addSubview:lab]; [sv addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-(10)-[lab]" options:0 metrics:nil views:@{@"lab":lab}]]; if (!previousLab) { // first one, pin to top [sv addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-(10)-[lab]" options:0 metrics:nil views:@{@"lab":lab}]]; } else { // all others, pin to previous [sv addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:[prev]-(10)-[lab]" options:0 metrics:nil views:@{@"lab":lab, @"prev":previousLab}]]; } 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. The solution is to add one more constraint, showing the scroll view what the height of its contentSize
should be:
// pin last label to bottom, dictating content size height [sv addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:[lab]-(10)-|" options:0 metrics:nil views:@{@"lab":previousLab}]];
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.
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
:
translatesAutoresizingMaskIntoConstraints
to YES, and set the scroll view’s contentSize
manually to the size of the generic content view.
translatesAutoresizingMaskIntoConstraints
to NO, set its size with width and height constraints, and pin its edges to its superview (the scroll view).
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.
I’ll illustrate by rewriting the previous example to use a content view. All four possible combinations start the same way:
UIScrollView* sv = [UIScrollView new]; sv.backgroundColor = [UIColor whiteColor]; sv.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:sv]; [self.view addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"H:|[sv]|" options:0 metrics:nil views:@{@"sv":sv}]]; [self.view addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[sv]|" options:0 metrics:nil views:@{@"sv":sv}]]; UIView* v = [UIView new]; // content view [sv addSubview: v];
The first combination is that no constraints are used. It’s just like the first example in the previous section, except that the labels are added to the content view, not to the scroll view:
CGFloat y = 10; for (int i=0; i<30; i++) { UILabel* lab = [UILabel new]; lab.text = [NSString stringWithFormat:@"This is label %d", i+1]; [lab sizeToFit]; CGRect f = lab.frame; f.origin = CGPointMake(10,y); lab.frame = f; [v addSubview:lab]; // add to content view, not scroll view 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;
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:
CGFloat y = 10; for (int i=0; i<30; i++) { // ... same as before, create labels, keep incrementing y } // configure the content view using constraints v.translatesAutoresizingMaskIntoConstraints = NO; [sv addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[v(y)]|" options:0 metrics:@{@"y":@(y)} views:@{@"v":v}]]; [sv addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"H:|[v(0)]|" options:0 metrics:nil views:@{@"v":v}]];
The third combination is that explicit constraints are used throughout. This is just like the second example in the previous section (except that the labels are added to the content view) combined with the preceding code, setting the content view’s constraints:
UILabel* previousLab = nil; for (int i=0; i<30; i++) { UILabel* lab = [UILabel new]; lab.translatesAutoresizingMaskIntoConstraints = NO; lab.text = [NSString stringWithFormat:@"This is label %d", i+1]; [v addSubview:lab]; [v addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-(10)-[lab]" options:0 metrics:nil views:@{@"lab":lab}]]; if (!previousLab) { // first one, pin to top [v addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-(10)-[lab]" options:0 metrics:nil views:@{@"lab":lab}]]; } else { // all others, pin to previous [v addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:[prev]-(10)-[lab]" options:0 metrics:nil views:@{@"lab":lab, @"prev":previousLab}]]; } previousLab = lab; } // last one, pin to bottom, this dictates content view height [v addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:[lab]-(10)-|" options:0 metrics:nil views:@{@"lab":previousLab}]]; // configure the content view using constraints v.translatesAutoresizingMaskIntoConstraints = NO; [sv addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[v]|" options:0 metrics:nil views:@{@"v":v}]]; [sv addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"H:|[v(0)]|" options:0 metrics:nil views:@{@"v":v}]];
The fourth combination is a curious hybrid: 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:
UILabel* previousLab = nil; // ... same as before, add subviews and constraints to content view ... // autolayout helps us learn the consequences of those constraints CGSize 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;
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: 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, 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, and the view controller’s size in the canvas will change with it.
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, and I set its contentSize
to the content view’s size in viewDidLayoutSubviews
. I don’t need an outlet to the content view, because it is known to be the scroll view’s first subview:
-(void)viewDidLayoutSubviews { if (!_didSetup) { _didSetup = YES; self.sv.contentSize = ((UIView*)self.sv.subviews[0]).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 edges are pinned to those of its superview, the main view. The content view’s edges are pinned to those of its superview, the scroll view. The content view’s size will dictate the scroll view’s contentSize
automatically. The only question now is how you’d like to dictate the content view’s size.
You have two choices, roughly 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.
In Xcode 4, configuring a scroll view’s content view under autolayout could be tricky, sometimes requiring a content view constraint’s constant
to be changed at runtime. You won’t need to do that with Xcode 5. Indeed, the Xcode 5 nib editor is extremely helpful: it 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
.
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
scrollsToTop
bounces
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
bounces
is YES, 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
alwaysBounce...
is YES), 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 a flick gesture. 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 YES 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.
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 are:
UIScrollViewIndicatorStyleDefault
(black with a white border)
UIScrollViewIndicatorStyleBlack
(black)
UIScrollViewIndicatorStyleWhite
(white)
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 until the limit dictated by the contentSize
and the scroll view’s own bounds 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:
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 in the order 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 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 in iOS 7, all apps are fullscreen apps, so the scroll view underlaps the status bar. This 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);
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;
In iOS 7, the contentInset
and scrollIndicatorInsets
are of increased importance because top bars and bottom bars are likely to be translucent. When they are, 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 of 20 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:
- (void) viewWillLayoutSubviews { if (self.sv) { CGFloat top = self.topLayoutGuide.length; CGFloat bot = self.bottomLayoutGuide.length; self.sv.contentInset = UIEdgeInsetsMake(top, 0, bot, 0); self.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 (new in iOS 7) is YES, 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 YES 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 NO if you want to take charge of adjusting a scroll view’s contentInset
and scrollIndicatorInsets
yourself.
If its pagingEnabled
property is YES, the scroll view doesn’t let the user scroll freely; instead, the content is considered to consist of 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 be equal 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. I do not believe it is possible for the scroll view not to emit these messages after a drag in a paging scroll view. Thus they 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 (pager
) is updated whenever the user causes a horizontally scrollable scroll view (sv
) to display a different page:
-(void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { CGFloat x = self.sv.contentOffset.x; CGFloat w = self.sv.bounds.size.width; self.pager.currentPage = 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) userDidPage: (id) sender { NSInteger p = self.pager.currentPage; CGFloat w = self.sv.bounds.size.width; [self.sv setContentOffset:CGPointMake(p*w,0) animated:YES]; }
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. Prior to iOS 5, before UIPageViewController was introduced, I was using a paging scroll view that did the same thing. If you’re curious about the technique I was using, watch the Advanced Scroll View Techniques video from WWDC 2011, which describes something very similar, calling it “infinite scrolling.”
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 pages are 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.
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.
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. 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, 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 (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:
-(void)viewDidLoad { CGRect f = CGRectMake(0,0,3*TILESIZE,3*TILESIZE); TiledView* content = [[TiledView alloc] initWithFrame:f]; float tsz = TILESIZE * content.layer.contentsScale; [(CATiledLayer*)content.layer setTileSize: CGSizeMake(tsz, tsz)]; [self.sv addSubview:content]; [self.sv setContentSize: f.size]; }
Here’s the code for TiledView. As Apple’s sample code points out, we must fetch images with imageWithContentsOfFile:
in order to avoid the automatic caching behavior of imageNamed:
— after all, we’re going to all this trouble exactly to avoid using more memory than we have to:
+ (Class) layerClass { return [CATiledLayer class]; } -(void)drawRect:(CGRect)r { CGRect tile = r; int x = tile.origin.x/TILESIZE; int y = tile.origin.y/TILESIZE; NSString *tileName = [NSString stringWithFormat:@"CuriousFrog_500_%d_%d", x+3, y]; NSString *path = [[NSBundle mainBundle] pathForResource:tileName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:path]; [image drawAtPoint:CGPointMake(x*TILESIZE,y*TILESIZE)]; }
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.
There’s an iOS 7 bug that causes a CATiledLayer to unload previously loaded tiles when there’s a low memory situation, and then when the user scrolls, no drawRect:
is sent and the tiles are not reloaded. This results in a partially blank CATiledLayer, and makes CATiledLayer somewhat useless until the bug is fixed.
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.
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:
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView { return [scrollView viewWithTag:999]; }
One more thing from those examples needs fixing. Earlier, 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. However, these widths now also affect how the content behaves as the user zooms it. This particular example, I think, looks good 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 scroll view now responds to pinch gestures by scaling appropriately! 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 NO; 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.
The scroll view zooms by applying a scale transform to the scalable view; therefore the frame of the scalable view is scaled as well. Moreover, 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
.
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:
-(void)layoutSubviews { [super layoutSubviews]; UIView* v = [self.delegate viewForZoomingInScrollView:self]; CGFloat svw = self.bounds.size.width; CGFloat svh = self.bounds.size.height; CGFloat vw = v.frame.size.width; CGFloat vh = v.frame.size.height; CGRect 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; }
To zoom programmatically, you have two choices:
setZoomScale:animated:
contentOffset
is automatically adjusted to keep the current center centered and the content occupying the entire scroll view.
zoomToRect:animated:
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:
- (void) tapped: (UIGestureRecognizer*) tap { UIView* v = tap.view; UIScrollView* sv = (UIScrollView*)v.superview; if (sv.zoomScale < 1) [sv setZoomScale:1 animated:YES]; else if (sv.zoomScale < sv.maximumZoomScale) [sv setZoomScale:sv.maximumZoomScale animated:YES]; else [sv setZoomScale:sv.minimumZoomScale animated:YES]; }
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 double-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 double 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
levelsOfDetailBias
levelsOfDetail
is 2, then if we want to redraw when zooming to 2x and when zooming back to 1x, the levelsOfDetailBias
is 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:
CGFloat 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:
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 an NSValue property currentSize
, initialized to an NSValue wrapping 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:
- (void)drawRect:(CGRect)rect { CGSize oldSize = self.currentSize.CGSizeValue; if (!CGSizeEqualToSize(oldSize, rect.size)) { // make a new current size self.currentSize = [NSValue valueWithCGSize:rect.size]; // make a new current image CATiledLayer* lay = (CATiledLayer*) self.layer; CGAffineTransform tr = CGContextGetCTM(UIGraphicsGetCurrentContext()); CGFloat sc = tr.a/lay.contentsScale; // tr.a is our scale transform CGFloat scale = sc/4.0; NSString* path = [[NSBundle mainBundle] pathForResource:@"earthFromSaturn" ofType:@"png"]; UIImage* im = [[UIImage alloc] initWithContentsOfFile:path]; CGSize sz = CGSizeMake(im.size.width * scale, im.size.height * scale); UIGraphicsBeginImageContextWithOptions(sz, YES, 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 double-resolution screen at the same time:
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(float)scale { 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.
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:
scrollViewDidEndScrollingAnimation:
scrollViewWillBeginDragging:
scrollViewWillEndDragging:withVelocity:targetContentOffset:
scrollViewDidEndDragging:willDecelerate:
If the user scrolls by dragging or flicking, 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 NO 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 YES 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:
scrollViewDidEndDragging:willDecelerate:
arrives with a value of YES. When scrollViewDidEndDecelerating:
arrives, the scroll is over.
scrollViewShouldScrollToTop:
scrollViewDidScrollToTop:
scrollsToTop
is NO, 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 YES. The second tells you that the user has employed this feature and the scroll is over.
In addition, the scroll view has read-only properties reporting its state:
tracking
dragging
decelerating
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 NO).
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.)
There are also three delegate messages that report zooming:
scrollViewWillBeginZooming:withView:
scrollViewDidZoom:
scrollViewDidScroll:
, possibly many times, as the zoom proceeds.)
scrollViewDidEndZooming:withView:atScale:
scrollViewDidZoom:
.
In addition, the scroll view has read-only properties reporting its state during a zoom:
zooming
dragging
to be true at the same time.
zoomBouncing
scrollViewDidZoom:
while the scroll view is in this state.
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).
A WWDC 2013 video discusses the iOS 7 lock screen in terms of scroll views. The lock screen itself is a horizontal paging scroll view; you scroll rightward to see the other page, containing the passcode screen or the springboard. Notifications in the lock screen are listed in a vertical scroll view, and the individual notifications are themselves horizontal 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 NO. (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, as described in Chapter 5:
- (void) dragging: (UIPanGestureRecognizer*) p { UIView* v = p.view; if (p.state == UIGestureRecognizerStateBegan || p.state == UIGestureRecognizerStateChanged) { CGPoint delta = [p translationInView: v.superview]; CGPoint c = v.center; c.x += delta.x; c.y += delta.y; v.center = c; [p setTranslation: CGPointZero inView: v.superview]; } }
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.
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:
- (void) dragging: (UIPanGestureRecognizer*) p { UIView* v = p.view; if (p.state == UIGestureRecognizerStateBegan || p.state == UIGestureRecognizerStateChanged) { CGPoint delta = [p translationInView: v.superview]; CGPoint c = v.center; c.x += delta.x; c.y += delta.y; v.center = c; [p setTranslation: CGPointZero inView: v.superview]; } // autoscroll if (p.state == UIGestureRecognizerStateChanged) { UIScrollView* sv = self.sv; CGPoint loc = [p locationInView: sv]; CGRect f = sv.bounds; CGPoint off = sv.contentOffset; CGSize sz = sv.contentSize; CGPoint c = v.center; // to the right if (loc.x > CGRectGetMaxX(f) - 30) { CGFloat 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) { CGFloat margin = off.x; if (margin > 6) { // ... omitted ... } } // to the bottom if (loc.y > CGRectGetMaxY(f) - 30) { CGFloat margin = sz.height - CGRectGetMaxY(sv.bounds); if (margin > 6) { // ... omitted ... } } // to the top if (loc.y < f.origin.y + 30) { CGFloat margin = off.y; if (margin > 6) { // ... omitted ... } } } } - (void) keepDragging: (UIPanGestureRecognizer*) p { float delay = 0.1; dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delay * NSEC_PER_SEC); dispatch_after(popTime, dispatch_get_main_queue(), ^{ [self dragging: p]; }); }
The delay
in keepDragging:
, 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) swiped: (UISwipeGestureRecognizer*) g { UIScrollView* sv = self.sv; CGPoint p = sv.contentOffset; CGRect f = self.flag.frame; f.origin = p; f.origin.x -= self.flag.bounds.size.width; self.flag.frame = f; self.flag.hidden = NO; [UIView animateWithDuration:0.25 animations:^{ CGRect f = self.flag.frame; f.origin.x = p.x; self.flag.frame = f; // thanks for the flag, now stop operating altogether g.enabled = NO; }]; }
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:
UIImageView* iv = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"smiley.png"]]; iv.translatesAutoresizingMaskIntoConstraints = NO; [sv addSubview:iv]; UIView* sup = sv.superview; [sup addConstraint: [NSLayoutConstraint constraintWithItem:iv attribute:NSLayoutAttributeRight relatedBy:0 toItem:sup attribute:NSLayoutAttributeRight multiplier:1 constant:-5]]; [sup addConstraint: [NSLayoutConstraint constraintWithItem:iv attribute:NSLayoutAttributeTop relatedBy:0 toItem:sup attribute:NSLayoutAttributeTop multiplier:1 constant:30]];
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 view-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:
opaque
property to YES. 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.
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.
shouldRasterize
to YES. You could, for example, do this when scrolling starts and then set it back to NO when scrolling ends.
Apple’s documentation also says that setting a view’s clearsContextBeforeDrawing
to NO 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.