iOS Nested Scroll with RxCocoa
As phones get bigger and bigger, so do our problems as iOS developers. Designers like to work with the most recent phone bezels and screen sizes like the iPhoneX+, and it looks cool for sure, but they tend to forget about smaller devices. The iPhone SE will still get the new iOS 13 update, so it’s still in the game!
I work as an iOS developer at Supercharge for a few years now. Most of the cases I’m the one who wins the UI part of the projects. My most recent one was a mobile bank. Since it was a banking application, one of the most important part was to list transactions.
The problem(s)
Here’s a quick introduction to the app that I worked on: to get an overview about your transactions, you had to navigate to your account details. This screen was responsible for showing your balance, starting a new transaction, copying your account details and telling about your transaction history. To be able to imagine it, here’s a screenshot, where the empty space represents the balances, the “Click me” button the actions, and the white card is the transaction history list.
(In the app the cells are much bigger)
It looked perfect on bigger devices but on the smaller ones only two cells were visible from the table view. The design was already fixed, but we still could alter a little bit with animations and tweaks. So we asked for our designer’s help to solve the problem. The answer was: “Just make the card scrollable a little bit”. I knew it wouldn’t be as easy as it sounds, but I like challenges.
So in order to solve this issue, I had to deal with the following requirements:
- The transactions card should be scrolled by a gesture, but it should stop after it hides the action, so we win some space but won’t hide crucial elements like your balance.
- The scrolling content change should be seamless because any kind of interruption is annoying and we aim for smooth frame rates and good UX
- The user should be able to grab the top part of the content view and move it without scrolling anything else so they can reach the buttons any time
Fails
First I tried to toggle the ScrollViews isScrollEnabled property when the outer scroll views offsets reach 0.0 (so it’s on top because we set some content inset to show the button at the background).
The solution worked, but when I changed the isScrollEnabled properties, I also lost the gesture and the inside scroll view wouldn’t scroll unless I released and tapped it again. Now I knew I had to let both ScrollViews recognize the gesture.
(I’m not a UIGestureRecongnizer master, but as far as I know, you cannot change and pass gesture events to some other view on the fly).
The solution is pretty straightforward: you just implement the UIGestureRecognizerDelegate’s gestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer.
and return true
in one of the scroll views (I choose the outer one).
But now both scroll views were handling my gesture, so the inside scroll view was scrolling twice as fast as it should!
Not to mention my button wasn’t tappable, as the gesture recognizer handled the tap, and when it bounced it was a mess.
At this point, I was pretty sad, and the fact that the people who made the Jira app had managed to solve this problem so nicely, wasn’t helping either.
Solution (kinda)
First I wanted to fix the button tap issue, so I created a subclass of ScrollView and added this code.
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return subviews.first?.hitTest(point, with: nil) != nil
}
This way, my ScrollView only responded when I tapped the contentView.
I created a subclass for the TableView to save the last content offset, to fix the double scrolling.
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
lastContentOffset = contentOffset
}func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
lastContentOffset = contentOffset
}func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
lastContentOffset = contentOffset
}
And for the finale, I had to salt it with some Rx
(This part is totally optional, you can use the scrollView:didScroll delegate method)
containterScrollView.rx.didScroll.filter { [unowned self] _ in
self.containterScrollView.contentOffset.y <= CGFloat(0.0)
}
.filter { [unowned self] _ in self.tableView.isTracking }
.map { [unowned self] _ in self.tableView.lastContentOffset }
.bind(to: tableView.rx.contentOffset)
.disposed(by: bag)
Let’s break down the operators:
1. Filter out every scroll event until it reaches the top
2. Filter out if we grabbed the table view
3. Get the last content offset from our improved table view
4. Bind it to the table view’s content offset
This binding handles the double scrolling issue.
Things to improve
- I wanted to have “pull to refresh” as well, but for some reason, once the table view reaches either end of it, it won’t bounce anymore; so, I cannot trigger the pull to refresh unless I move it to the other direction and pull it down
- The solution for the double scrolling is not perfect, and it jitters a little bit
(If anyone wants to improve it just create a PR with some comment)
There was an alternative solution where I tried to use only one scroll view and a pan gesture recognizer and handle it myself, but I just faced so many new problems I headed back to the nested ScrollViews.
Conclusion
This was an excellent task to learn more about gesture recognizers and UI event handling. Even though UIKit may seem a little old or outdated, especially with SwiftUI on the horizon, it is still very powerful and well thought out. You can achieve anything if you dig deeper. I hope this post will help other developers as I could not find a solution for our needs, and I’m happy to receive any suggestions for improvement.
You can check out and play with the example repo here
NestedScrollingExample