Advanced ScrollView Techniques
Description: Come learn about how to achieve the appearance of infinite scrolling in either one or two dimensions. We'll also look at how to change the resolution of drawn content during zooming, without requiring the use of CATiledLayer.
Basics
- To enable scrolling your content in a
UIScrollView
, set itscontentSize
, which tells theUIScrollView
how much content there is. - To know what portion of the content is currently shown on screen, use
UIScrollView
'scontentOffset
, which represents the top left current visible point on the scroll view frame (a.k.a. The point at which the origin of the content view is offset from the origin of the scroll view). - To get zooming working on a scroll view:
- create a type conforming to
UIScrollViewDelegate
, implementviewForZooming(in:)
- set an instance of this type as your
UIScrollView
instancedelegate
- set the
minimumZoomScale
and themaximumZoomScale
in yourUIScrollView
instance to be different (both are1.0
by default)
- create a type conforming to
Advanced Techniques
- Infinite scrolling
- Stationary views
- Custom touch handling
- Redraw after zooming
1. Infinite scrolling
The user can keep scrolling in one direction and never hit the edge of the content (e.g. a photo carousel that automatically wraps).
How to achieve this:
- make the
contentSize
about twice the size of what's visible on screen - when the user is about to hit the content edge, adjust the
contentOffset
to go into the middle of thecontentSize
(a.k.a the scrollable area) - adjust the frames of our content subviews to the same amount as the
contentOffset
so that they're still centered in the visible content area
The last two steps needs to be done concurrently and the user won't be able to notice.
Where to implement this: the idea is to re-layout those subviews every time the user scrolls.
We have two possible ways:
- sub-class
UIScrollView
, and override thelayoutSubviews()
method (the WWDC session uses this one).layoutSubviews()
is called at every frame of zooming and scrolling (a.k.a. anytime the scroll view bounds change) - use
UIScrollViewDelegate
'sscrollViewDidScroll(_:)
In layoutSubviews()
we will:
- call
UIScrollView
'ssetContentOffset(_:animated:)
, to shift the content back - set
UIView
'scenter
offrame
, to shift subviews by the same amount as the scroll view content
Code for infinite horizontal scroll view:
@implementation InfiniteScrollView
// Recenter content periodically to achieve impression of infinite scrolling
- (void)recenterIfNecessary
{
CGPoint currentOffset = [self contentOffset];
CGFloat contentWidth = [self contentSize].width;
CGFloat centerOffsetX = (contentWidth - [self bounds].size.width) / 2.0;
CGFloat distanceFromCenter = fabs(currentOffset.x - centerOffsetX);
// We re-center when the offset is greater than 25% off the center, this is arbitrary.
if (distanceFromCenter > (contentWidth / 4.0)) {
self.contentOffset = CGPointMake(centerOffsetX, currentOffset.y);
// Move content by the same amount so it appears to stay still
for (UILabel *label in self.visibleLabels) {
CGPoint center = [self.labelContainerView convertPoint:label.center toView:self];
center.x += (centerOffsetX - currentOffset.x);
label.center = [self convertPoint:center toView:self.labelContainerView];
}
}
}
- (void)layoutSubviews
{
[super layoutSubviews];
[self recenterIfNecessary];
}
...
@end
2. Stationary views
Views that remain pinned in place in one dimension/direction, but scroll with the scrolling content on the other axis. Think like headers and footers.
This can be the case where we have one piece of the content that should not zoom or scroll along with the rest of the content (e.g. the title of an image).
In the session they implement a scenario where:
- there's an image title that sticks on top of the scroll view
- the image can be scrolled and zoomed, the title stays in place
- the title only disappears when the user scrolls down on the image, so that the image can be seen in full
- any other interaction (zoom or scroll will make the title reappear)
Configuration:
We have one scroll view that takes the whole available space, which has two subviews:
- the header/title view, which doesn't zoom
- the
UIImageView
that can be zoomed, this is the view returned inviewForZooming(in:)
What to do next:
- Since only our
UIImageView
can scroll, the first thing we need to do is to make sure that our header view stays centered horizontally when we zoom/scroll in the image view. This is done by setting the headerframe.origin.x
to be equivalent tocontentOffset.x
inlayoutSubviews()
. - when we zoom in a scroll view, the scroll view content size is automatically updated to the zoomed size of the view returned in
viewForZooming(in:)
. In our case, we also need the scroll view to consider the header view size, hence we need to override thecontentSize
setter to also consider the header.
3. Custom touch handling
The session focuses on adding multi-touch handlers to subviews of the scroll view.
You can get the UIScrollView
's pan and pitch gesture recognizers via the panGestureRecognizer
and pinchGestureRecognizer
properties. These are the same recognizers tat UIScrollView
uses to manage its own gestures (for scrolling and zooming, respectively).
In this session they implement a scenario where:
- swiping up/down from the bottom of the scroll view will make another view appear/disappear instead of scrolling in the scroll view
Implementation (you can add this code in loadView()
/viewDidLoad()
:
UIScrollView *scrollView = [self scrollView]:
UISwipeGestureRecognizer *swipeUp = [[UISwipeGestureRecognizer alloc] initWithTarget:self action: @selector (handleSwipeUp:)];
swipeUp.direction = UISwipeGestureRecognizerDirectionUp;
[scrollView addGestureRecognizer: swipeUp];
// 👇🏻 this is required, otherwise the scrollView.panGestureRecognizer would always trigger before our swipe up
[scrollView.panGestureRecognizer requireGestureRecognizerToFail:swipeUp];
Note that this implementation makes the scroll view pan gesture to wait to trigger because it needs to make sure the gesture is not a swipe. However, in our case we want this behavior only for the bottom of the scroll view and not the whole screen. So we can limit the target area:
- (BOOL) gestureRecognizer: (UIGestureRecognizer *)gestureRecognizer
shouldReceiveTouch: (UITouch *) touch
{
UIScrollView #scrollView = [self scrollView];
CGRect visibleBounds = [scrollView bounds]:
CGPoint touchPoint = [touch locationInView:scrollView];
if (touchPoint.y < CGRectGetMaxY(visibleBounds) - 75)
return NO:
return YES;
}
4. Redraw after zooming
📚 Download
ScrollViewSuite
code sample. DownloadPhotoScroller
code sample.
The session focuses on small bits of content that need to be redrawn once the user is done zooming.
The idea is that we content gets zoomed in, it starts to get blurry, so you want to redraw it to make it crisp once again.
We do this redraw only after the zoom has ended, not while the user is zooming (the reason being this operation is expensive and we'd discard things constantly as the user keeps zooming): this can be obtained via scrollViewDidEndZooming(_:with:atScale:)
, which also lets us know what is the final scale.
How-to (⚠️ only for small pieces of content):
- (void)scrollViewDidEndZooming: (UIScrollView *) sv
withView: (UIView *) view
atScale: (float)scale
{
scale *= [[[scrollView window] screen] scale]:
[view setContentScaleFactor:scale]:
}
The contentScaleFactor
of a view is essentially a multiplier applied to the bound size of the view used to determine how big your view rect backing storage should be.