Creating a Fluid Scroll Experience on iOS
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!
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.
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:
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
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
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
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:
.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 because
UITableView inherits from
UIScrollView and we still have access to its delegate methods!
By setting this scroll view’s
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.
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!