UIScrollView with Vimeo’s latest player screen

Van Nguyen
Vimeo Engineering Blog
12 min readSep 10, 2021

--

On Vimeo’s Mobile team, one of our goals is to empower our users with the latest technologies from iOS and Android as well as with an intuitive, easy-to-use user interface. One of many examples that demonstrate this commitment is the latest player screen that we shipped with version 8.6.0 of the Vimeo flagship app. In planning and designing the new screen, we not only parted ways with the user interface that had been serving us well for many years, but we also decided to refactor a major base of legacy code that no longer served Vimeo’s needs moving forward. In other words, the new player in Figure 1 below wasn’t merely re-skinned but rebuilt from the ground up:

Figure 1: Legacy player (left) vs. new player (right)

Notice in the figure how smooth the transition from one tab to another is compared to the legacy screen. In order to achieve such a smooth user experience, we agreed that the new screen must be architected around UIScrollView. During development, our team had to deal with many technical challenges related to UIScrollView, and at the same time we gained a number of insights in adopting it. In the following sections, we walk you through how we were able to overcome these challenges as well as share some pro tips for incorporating UIScrollView into your apps.

Why UIScrollView?

Before diving into our implementation, we want to stress that if you can architect your solution without using UIScrollView, you should definitely consider it. From our experience with UIScrollView in the past as well as with the new player screen, its reputation of being one of the most complicated while powerful UIView subclasses in UIKit is very true. A good portion of the complaints that we have seen on StackOverflow as well as the Apple Developer Forums regarding UIScrollView involve a scroll view displaying the wrong section of its content when being resized, and if the class’s behavior is not fully understood, it will be hard to track down the root of the problem.

That being said, if your upcoming feature requires the user interface to be scrollable and responsive as the user drags their finger, UIScrollView is the ultimate solution. For instance, with the new screen, the very first question that we asked ourselves was whether a UIPageViewController — the foundation of our legacy player screen — could be retrofitted to fit our needs. Unfortunately, we quickly found the answer was no. The main reason for that is because the user experience that UIPageViewController provided wasn’t flexible and customizable enough for our needs. With a page view controller, you could swipe left or right only, while our team agreed that we also wanted a panning gesture. In addition, when UIPageViewController’s delegate implementation was coupled with a position indicator user interface that could also control the content’s position, there was an inherent lag between when the user committed an action and when the action was finished. The lag was very distracting, and it definitely contributed to the overall perception of the player screen being slow and unresponsive. Eventually, we decided to abandon UIPageViewController and started researching UIScrollView as a next option.

Implementing UIScrollView

In this section, we take you through the three implementation steps for UIScrollView: setup, resizing, and control flow.

Setup

The first step in setting up UIScrollView is to add it into a view and lay it out as you will do with normal views:

let scrollView = UIScrollView(frame: .zero)
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.isPagingEnabled = true
self.view.addSubview(scrollView) NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(
equalTo: self.view.leadingAnchor
),
scrollView.trailingAnchor.constraint(
equalTo: self.view.trailingAnchor
),
scrollView.topAnchor.constraint(
equalTo: self.view.topAnchor
),
scrollView.bottomAnchor.constraint(
equalTo: self.view.bottomAnchor
)
])

This code snippet should be familiar to you if you’ve ever worked with UIKit: it pins a scroll view onto a view controller’s view. It is worth noting that due to the contentInsetAdjustmentBehavior property of the scroll view being set to automatic by default, the top and the bottom safe area insets from the parent view are always incorporated into the overall insets. For our use case, this behavior was desirable, but if you find it to be unsuitable for your feature, you should adjust the value appropriately.

This code also enables pagination on the scroll view, which is very important if you want to implement a tab-scrolling experience similar to ours.

The next step is to lay out subviews inside the scroll view. Our first tip for you is to utilize a container view, be it your custom UIView or UIStackView:

let view1 = UIView(frame: .zero)
view1.backgroundColor = .red
let view2 = UIView(frame: .zero)
view2.backgroundColor = .green
let view3 = UIView(frame: .zero)
view3.backgroundColor = .blue
[view1, view2, view3].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
let stackView = UIStackView(arrangedSubviews: [view1, view2, view3])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .horizontal
scrollView.addSubview(stackView) NSLayoutConstraint.activate([
view1.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
view2.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
view3.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
])

Note that each subview’s width is set up so that it is equal to the scroll view’s width. We don’t need to set up the subview’s height because by default, UIStackView stretches the other dimension of its subviews to fill that dimension of the stack view. If your user interface involves scrolling in a vertical direction, you should pin each subview’s height and let its width grow.

The final step in the setup is to add the container view into the scroll view. This was the first challenge that we faced. Here’s how we solved it:

NSLayoutConstraint.activate([ 
stackView.leadingAnchor.constraint(
equalTo: scrollView.leadingAnchor
),
stackView.trailingAnchor.constraint(
equalTo: scrollView.trailingAnchor
),
stackView.topAnchor.constraint(
equalTo: scrollView.topAnchor
),
stackView.bottomAnchor.constraint(
equalTo: scrollView.bottomAnchor
)
])

This code snippet constrains the stack view’s edges to the scroll view’s edges. Typically, when we do so in a normal UIView subclass, the edges of the stack view must be visible because the view’s bounds is typically pinned to zero origin, and its size is the same as the frame’s. UIScrollView, however, isn’t a normal UIView subclass. In fact, a scroll view’s frame is determined by its relationship with its parent view, while its bounds is a “window” into a portion of its rather large content view. This viewport’s position is defined relative to the scroll view’s content, while its size is typically equal to the frame’s size. When the user performs a pan gesture within the scroll view’s frame, the bounds changes its origin,thus enabling the user to see a different portion of the scroll view’s content. The bounds’ origin is also widely known as contentOffset. In addition, a content area is a union among the contentSize property (defined by the content view’s size), the contentInset property, and the safe area insets that the scroll view inherits from its parent view. For UIScrollView, its frame, bounds, and content area do not necessarily bear any meaningful relationship. Therefore, it’s perfectly valid to set up the stack view this way, and the scroll view will scroll just fine.

To demonstrate different properties of a UIScrollView, consider the setup in Figure 2. This setup is very similar to how we built our new player interface.

Figure 2: Illustration of different UIScrollView properties

In this illustration, a scroll view is pinned to all edges of a view controller’s view that occupies the entire device’s screen real estate. The device’s size is 400 points in width and 660 points in height, so the scroll view’s frame is (x: 0, y: 0, w: 400, h: 660), and its bounds is of size (w: 400, h: 660). Its content view - which is a horizontal stack view - has three subviews, and each of the widths of the subviews is laid out to be equal to that of the scroll view. Since the view controller’s view has a top safe area inset of 20, and the scroll view’s contentInsetAdjustmentBehavior is default to automatic, the scroll view inherits a top safe area inset of 20 from the superview, as drawn in red. Next, we specify that the scroll view has an extra top content inset of 40 via the contentInset property, as drawn in yellow. As for the content size, its width is triple that of the device’s width, and its height is the device’s height minus the top of safe area insets and the top of contentInset property, so the contentSize property is (w: 1200, h: 600), as drawn in green. When taking into account all the insets and the content size, the overall content area is (w: 1200, h: 660), as drawn in black. Last but not least, because the user is looking at the second subview of the content view, and they cannot scroll vertically, the contentOffset property now points to (x: 400, y: -60), as drawn in magenta. The -60 points in the vertical direction means that the scroll view is also showing all the insets.

If there is one lesson that you should take away from this blog post, understanding the relationship between frame, bounds, contentInset, contentSize, and contentOffset properties is probably it. It might help you debug strange layout issues related to resizing UIScrollView.

Resizing

Speaking of resizing, a good chunk of the challenges related to UIScrollView that we came across involve the view displaying an incorrect portion of its content. For example, we faced a fair share of cases where the scroll view displayed a boundary between two consecutive tabs after the app window was resized on the iPad. From our observations, this occurred because after resizing, the contentSize property had changed but the contentOffset property hadn’t. Whether the content size became smaller or larger didn’t matter. The fact that sometimes that occurred was a clear indication we didn’t do a thorough job to account for content size change. If you see this behavior with your scroll view’s implementation, you must take a closer look at your frame calculation.

That leads to the next set of tips that we want to share when it comes to correcting the content offset — let your scroll view’s content area grow as wide as possible, and try your best to know the scroll view’s future size beforehand. From our experience, you might want to do both in many cases. The reasoning for that is because even if you know a correct future size, the scroll view might not be in that size just yet. As a result, when you attempt to scroll to a content offset that is based on the size, UIScrollView might scroll to a different position that it thinks is most suitable based on the current content size.

To let your scroll view’s content area grow big enough to scroll to a correct content offset that might not yet exist, you can set the contentInset property to greatestFiniteMagnitude on the axis that you want to scroll. For example, in our case, we wanted to scroll horizontally, so we set the greatestFiniteMagnitude constant to the left and the right:

scrollView.contentInset = UIEdgeInsets(
top: 0.0,
left: .greatestFiniteMagnitude,
bottom: 0.0,
right: .greatestFiniteMagnitude
)

This code effectively increases the content area to infinity on both the left and the right, so that the scroll view can effectively scroll to any position desired. After scrolling completes, you must set the contentInset property back to zero (or whatever other amount is appropriate) to prevent your scroll view from being able to scroll unboundedly.

Determining the correct size of your scroll view varies from one case to another. In general, your end goal should be to calculate the scroll view’s size beforehand without forcing it to re-layout. If a UIViewController subclass is used, then you have quite a few options to choose from. Your first option is to rely on func viewDidLayoutSubviews(). This method guarantees that all subviews of the view controller’s view will have correct sizes, so you can reliably use the size of the scroll view’s frame to scroll. Your second option - which is the one that we used in the new player screen - involves integrating with the UIViewController’s adaptive layout API. To demonstrate that, let’s take a look at how we implemented the following adaptive layout method:

override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)

scrollView.contentInset = UIEdgeInsets(
top: 0.0,
left: .greatestFiniteMagnitude,
bottom: 0.0,
right: .greatestFiniteMagnitude
)
coordinator.animate(
alongsideTransition: { _ in
scrollView.setContentOffset(
CGPoint(
x: CGFloat(currentIndex) * size.width,
y: 0.0
),
animated: false
)
},
completion: { _ in
scrollView.contentInset = .zero
}
)
}

In this method, we relied on the size that was passed to us to scroll to a future content offset while the animation was happening. If the screen is composed of only one UIViewController subclass, or if the parent view controller’s view has the same size as the child’s view, that is all you need to do. If that isn’t the case, having this method alone isn’t enough because by default, the size parameter reports the size of a container view. To compute the parameter accurately, you also need to implement the following adaptive layout method in the container view controller:

func size(
forChildContentContainer container: UIContentContainer,
withParentContainerSize parentSize: CGSize
) -> CGSize

This method is an opportunity for you to override manually the size passed into the func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) method of a container view controller’s child. In this method, container is just a child view controller, and parentSize is the size of the container view controller’s view. At this point, you might wonder how the child’s size can be known without forcing the entire view hierarchy to re-layout. That leads to the next tip that we want to share with you — you should structure your code such that the layout is predictable for all child view controllers. That also means to iron out any ambiguity regarding the sizes of the views with your designer. For example, at Vimeo, we worked with our designer on the size of each portion of the player screen not only relative to the container but also relative to each other. Those dimensions were then encapsulated into a layout engine class, and because the layout was predictable, we were able to unit test all the variations.

Control flow

The above sections should cover everything that you need to know to scroll a UIScrollView properly. Sometimes, though, you might want to do something more complicated with your scroll view’s implementation. For instance, with our new player screen, we not only wanted to scroll smoothly among the tabs but also to report the content offset on a dedicated user interface that we referred to as the tabs indicator view. This UIView subclass enables the user to be aware of where they are in the tabs collection and to scroll to an arbitrary tab just by tapping its icon.

This custom UI behavior is demonstrated in Figure 3. The user interface is actually a combination between a UIPageControl and a built-in scroll indicator of a UIScrollView.

Figure 3: The new player’s tabs indicator view

If you find yourself building a similar interface, our advice for you is to keep the control flow between the scroll view and your custom UI one-directional, and let the scroll view control the custom UI. That way, it will be much easier to diagnose problems related to this aspect that arise during development. In general, data transfer is performed in the implementation of the func scrollViewDidScroll(_ scrollView: UIScrollView) method. This method is called after the contentOffset property has changed. In case you want the custom UI to have some actions that can affect the content offset of the scroll view, you then use a delegation protocol to let the scroll view know that something significant has happened, perform the scrolling, and let func scrollViewDidScroll(_ scrollView: UIScrollView) change the appearance of the custom UI.

To demonstrate this approach, consider the implementation of the tabs indicator view inside our new player screen. As our scroll view is scrolled, we use func scrollViewDidScroll(_ scrollView: UIScrollView) to report a scroll fraction - which is a proportion of the scroll view’s bounds.origin relative to the content area defined by the content offset’s X position divided by the content size’s width - to the tabs indicator view:

func scrollViewDidScroll(_ scrollView: UIScrollView) { 
var scrollFraction = Float(self.scrollView.contentOffset.x / scrollView.contentSize.width)
self.tabsIndicatorView.scrollFraction = scrollFraction
}

The indicator view then uses this information to move the indicator accordingly.

As a new tab position is selected by the user via either a tap or a drag gesture, we don’t attempt to move the indicator immediately. Instead, we only report the selected index back to the scroll view, and rely on the implementation of func scrollViewDidScroll(_ scrollView: UIScrollView) to move it via the scroll fraction:

func tabsView(
_ tabsIndicatorView: TabsIndicatorView,
didMoveTo index: Int
) {
self.scrollView.setContentOffset(
CGPoint(
x: CGFloat(index) * self.scrollView.bounds.size.width,
y: 0
),
animated: true
)
}

Conclusion

UIScrollView is no doubt a powerful API, but at the same time, its behavior can be complicated and easily misunderstood. The golden rule to remember is, most of the time, it wants to be in control. If your solution tries to challenge this behavior, you will most definitely struggle and end up with unexpected behaviors. On the other hand, if your code lets UIScrollView do its own thing, your feature can certainly bring a delightful experience to your users. We believe that we finally understand how the UIScrollView API works after spending so much time with it, and we hope that the insights that we presented in this blog post can give you the courage to incorporate UIScrollView into your apps.

If you have questions or other insights regarding UIScrollView, we would love to hear from you in the responses!

Interested in flexing your engineering chops at Vimeo? Join our team!

--

--

Van Nguyen
Vimeo Engineering Blog

iOS Engineer. Aspiring photographer. Currently a NYC resident.