Creating a Fluid Scroll Experience on iOS

Ray Kim
ClassPass Engineering
8 min readJun 24, 2019

--

In the ClassPass app, by far the most common path users take once they open it is tapping the “Find a Class” tab at the bottom and then searching for classes.

Our search experience is crucial because it’s the first impression users have about our product. We’re always looking for ways to improve this experience and recently began to roll out a redesign to better surface all the studios users can choose from. Working on this redesign has been the most challenging, fun, and fulfilling feature I’ve worked on thus far at ClassPass!

Growing Pains

One of the main reasons we decided to redesign search was because our existing list UI was unable to adequately reflect the growing number of studios available. The list shows you available classes offered today and, depending on your device size, may only show a handful of classes at specific times above the fold. As the number of studios on ClassPass increased, we noticed that new users weren’t able to browse studios quickly. We have a map view accessible via a Map button at the top so that users can view studios geographically but user testing videos showed that most people simply didn’t know there was a button up there to switch to a map. As a result, users’ first impressions would be shaped by the small subset of classes they saw on their screen.

I worked closely with Tom, our designer on the Search team, to build a more intuitive search experience that makes it easier for users to browse studios before they book specific classes. The first iteration was simply to test showing a portion of the map above the list of schedules that users can tap to switch to the full map view. We also added buttons on the map to make it easier to toggle between the two views.

This was a great quick win and we got early positive feedback that surfacing the map right away gave users a better sense of how many studios were near their location.

As we invested more heavily into browsing by studios on the map, we started to look at complementary ways we could make it easier to search by studios rather than by classes. What if we added a studios list similar to the classes list and let users choose which list to view? Users would land on the studios list by default so that they can browse studios before diving into specific classes. We also wanted to improve the way users can switch between the list and map views. We looked at how other apps implemented map search interactions. If you’ve used Google Maps or Apple Maps, you’ve probably noticed that draggable list with rounded corners and the small gray handle in its center.

Apple Maps

It’s easy to use and ergonomic since users can simply swipe up and down with their thumb to either focus on the list or the map. The next iteration for us was to try and build this for our app.

First Implementation: Using a Container UIScrollView with a Content Inset

As I researched how to build this from scratch (it’s not a built-in UI component on iOS), Sanjay, another iOS engineer at ClassPass, recommended skagedal’s article on how to build an Apple Maps-like UI. It looked like the right approach–a UIScrollView with a custom contentInset with overrides on UIScrollViewDelegate methods to capture swipes and pan gestures. Our use case was a bit more complex because we weren’t simply dragging a list of directions like on Apple Maps–our scroll view would contain a segmented control component that switched between two types of lists, a UITableView for the studios list, and our existing classes list which lives inside a UIPageViewController. Despite our added complexity, I felt confident that I could apply this approach with our use case.

Turns out, I was almost right. After several days of building and perfecting it, I came close to the right UI but was left with one specific interaction that wasn’t as fluid as I would’ve liked–let’s call it the “continuous scroll” interaction. Compare the Apple Maps interaction with my first attempt below:

Apple Maps
Notice how it stops at the top and only works once the pan gesture finishes and a new one begins.

The problem is with how Apple handles pan gestures on scroll views that themselves contain scroll views. A pan gesture is when a user moves one or more fingers across the screen. Nested scroll views could clearly lead to some confusing interactions so Apple decided to control exactly what happens in ambiguous situations for us (sadly without documentation): when the user swipes up on a scroll view that is offset from its top whose internal scroll view is at its respective top (i.e., its contentOffset is 0), the pan gesture will be registered only by the parent scroll view for as long as that pan gesture has not ended, failed or been cancelled. This means that we can’t convert a pan gesture registered on the container UIScrollView to its internal UIScrollView in the same movement. Once we reach the container’s top, the container will either stop completely or bounce if you have bounce enabled. I tried playing around with gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) to recognize the pan gesture for both the container and its internal scroll view at the same time–I got close but still failed to achieve the right interaction.

Second Implementation: Using a Container UIView with Custom UIPanGestureRecognizers

I started to feel like this would be as close as I could get given the amount of time I had to implement it. Other mobile engineers, Tom, and I weighed our options and despite everything else looking great, we all felt that it was important that we got this interaction right and that it was as smooth as possible before we shipped it to all of our users.

Tom did a bit of research to help me out and came across a StackOverflow thread on various implementations people came up with. At first, I thought I had exhausted my research online on how people got this continuous scroll interaction right using a container UIScrollView. However, as I read through more and more implementations, I came across a few that solved this exact issue but in a completely different way. I realized I had been looking at the wrong posts while trying to troubleshoot because I was trying to make it work with a container UIScrollView.

I went through how they built their version of the interaction and found their solution to be deceptively simply: use a vanilla UIView as a container with a target selector method added to its internal scroll view’s UIPanGestureRecognizer. No UIScrollView, no contentInset. Here’s how I began to apply this for our use case:

Implementing it this way meant scrapping my container UIScrollView approach completely. I decided I had to for the sake of making this work correctly.

Continuous Scroll Part 1

Apple’s API for UIPanGestureRecognizer provides access to five pan gesture states: .began, .changed, .failed, .ended, and .cancelled. Using these five states, we can control when to only drag the container view (e.g., while the container hasn’t reached the top) and when to only drag its internal scroll view. To achieve the effect of dragging only the container view, you need two things. First, while the pan gesture is in the .changed state, update the container view’s frame to a calculated offset between the min and max vertical offsets you want for your draggable list (in our case we had to offset it from a header).

Second, capture the internal UIScrollView's offset through its delegate method scrollViewDidScroll(_:) and pass the scroll view through to ContainerViewController. In our code, the internal scroll view was technically a UITableView but it doesn’t matter becauseUITableView inherits from UIScrollView and we still have access to its delegate methods!

Capturing internal scroll view’s content offset inside ContainerViewController
Pass along scroll view through to parent view via delegate method

By setting this scroll view’s contentOffset.y to 0for a specific range, we can keep the internal scroll view from scrolling while using the pan gesture to move the container view.

Continuous Scroll Part 2

Once we’ve reached the top, we want to stop updating the container view’s offset and start scrolling the internal scroll view. First, because we’ve set the internal scroll view’s contentOffset.y value to be 0 only for a specific range, the scroll view should scroll normally when not in that range. Second, in handlePan(_:), we check whether the internal scroll view’s contentOffset is greater than 0 (i.e., the internal scroll view is now scrolling) and return before calling the update frame code from earlier.

With these two parts, we have the foundation for a continuous scroll!

There’s of course a lot more that goes into getting this entire interaction right. There’s the map parallax, the swipe snapping points, multiple internal scroll views…the list goes on. Yet, none of that was worth the effort if we didn’t solve this interaction. Now, we could finally ship something that was not only functional but delightful to use.

Conclusion

First, I’d like to say thanks to Tom and Sanjay for their guidance and support. Without them, it would’ve taken me a much longer time to get this feature right. I’d also like to thank my team for giving me the opportunity to work on such an impactful feature. From a mobile engineer’s perspective, there’s simply no other part of the app I could work on that has as much impact as working on improving the search experience. It’s by far the most important function of the mobile app and making it as polished and easy to use as possible is key to making it not only highly functional but also enjoyable to use. Thanks for reading!

You’re reading the ClassPass Engineering Blog, a publication written by the engineers at ClassPass where we’re sharing how we work and our discoveries along the way. You can also find us on Twitter at @ClassPassEng.

If you like what you’re reading, you can learn more on our careers website.

--

--