In this post I’ll write about how to implement a custom scroll to top feature with the ability to restore the old
contentOffset. The first app I saw implementing this feature was TweetBot in its 4.8 update and it became instantly a must have for me.
While working on side-project application (stay tuned 😉), I implemented this feature as well. Let’s see how it can be done entirely using RxSwift 🤘
My love for RxSwift began mid 2016 when I joined Heetch. Since then, it helps me to write complex features in such a simple, expressive, and readable way. I think I will speak about RxSwift often on this blog, because IMHO it definitely helps to write elegant code.
The scroll to top is usually triggered by a tap on the status bar, but as it will be implemented here it will also be possible to add new sources to trigger. For instance a tap on tab bar item, or on
viewWillAppear(), or on everything else as soon as it’s an
- Implement an
Observable<Void>that emits whenever the user taps on the
UIApplication.shared.keyWindowin status bar’s frame
- Associate 1. to a
UIViewControllerand filter its events to emit them if and only if the
UIViewControllerinstance is visible (ie. between
- Implement a
ScrollTargetenum to let switch over different target (either
- Implement an
Observablethat emits whenever the user has finished to scroll an
UIScrollViewin order to save the current
- Implement the final subscription that combine 2. and 4. to scroll the
UIScrollViewto the desired target.
For the implementation I used RxSwift, RxCocoa and RxSwiftExt. There are also two little Rx extensions I use.
The first one transforms any
Observable<Void>. It’s quite convenient when we don’t need the value. Typically when you use the
Observable as a sampler.
The second is a
startWith operator that takes a closure instead of a value. It avoids a strong reference on the initial value.
1. Detect tap on status bar
To do this without any subclassing, RxCocoa will be a precious help.
First let’s make an
Observable<UIWindow?> that emits the
- On lines 3 to 6 we listen for
UIWindowDidBecomeKeynotification and get the associated object (the window) once a notification is posted
- On lines 8 to 11 we use the current
base.keyWindowas a start value
Now that we always have the latest
keyWindow we can
flatMap over it to detect when user taps in it. The best way to do this is to attach an
UITapGestureRecognizer to the window. It would be really easy to do with RxGesture for example.
Unfortunately, on iOS the view system won’t deliver the touch event to any gesture recognizer if the touch location is in status bar’s frame. The only way I found to bypass this limitation is to intercept the invocation of:
And RxCocoa has a powerful built-in
.methodInvoked() operator to do this.
- On line 3 we use the
keyWindow: Observable<UIWindow?>defined earlier
- On line 7 we use the
.methodInvoked()operator to intercept the invocation of
- On lines 8 to 14 we
mapthe previous result to get the point location of the touch event. In practice, it would be safe to return directly a forced unwrapped
arg.first as! CGPointbecause we know the exact method signature, but I still prefer to keep an optional
- On line 11, we make sure that there’s really an event given in order to avoid false positives
- On line 15 we unwrap the point with
.unwrap()operator of RxSwiftExt.
- On line 18 you can notice that I add an extra
statusBarFrame. It makes the tappable target a little bit higher. M. Fitts approves it 👍.
- On line 21, we use the
.debounce()operator with a delay of
0and an async instance of the
MainScheduler. It’s important because
UIView.hitTest(_:with:)will be called many times during the same run loop, so we need to filter repetitive events. You can see this as similar to an other UIKit pattern like
💪 Awesome, we’re done with the first step.
2. Detect status bar tap on a visible ViewController
As you will likely use this feature on a
UIScrollView included in a specific
UIViewController, you better make sure that this
UIViewController is actually visible before reacting to this event.
Otherwise, imagine you have several
UIViewController implementing this gesture in a
UITabBarController. If you don’t emit the event only for the visible
UIViewController, a tap on the status bar will scroll to top all
UIScrollView of all view controllers. We obviously don’t want this.
Once again, RxCocoa’s
.methodInvoked() operator is a great help as it allows us to intercept appearance lifecycle methods and map them to a boolean indicating if the view controller is visible or not. Here,
viewDidAppear is mapped to
true (line 11) and other methods are mapped to
To finish, we reuse
UIApplication.shared.rx.statusBarTap we created earlier and use the
.pausable() operator of RxSwiftExt in order to emit values only if latest value from
4. Save contentOffset after scroll
Starting from here, I will simplify and write all the code we need in our
viewDidLoad(). I will also assume there are a
scrollView and a
disposeBag around there.
Let’s start with the code.
- On line 4 we create a
BehaviorSubjectthat will hold our next
ScrollTarget. The initial target will obviously be
- On line 6 we prepare our source. It’s just the
UIViewController.rx.statusBarTapwe created earlier, combined with the next target, and we finish with a
share(). It’s important to share here because as on line 27 we update the target, we want to be sure that the subscription to actually scrolls the scroll view, use the correct target.
- On lines 8 to 19 we save the next target. If current target was
.top, then the next target will be
.offsetwith the current
scrollViewoffset. Otherwise, the next target will be
.top. This allows us to alternatively use one target or the other.
- On lines 22 to 28 we add a mechanism that reset the next target to
.topas soon as the user interacts with the
scrollView, because it wouldn’t make sense to restore the old offset.
5. The final piece
Now we can implement the actual scrolling.
No big deal here, we just get the good offset for each
ScrollTarget cases and we animate the
To conclude, we’ve seen some interesting techniques offered by RxSwift and RxCocoa that allowed us to compose an interesting feature without subclassing, or using a mutable shared state.
As an exercise, you can factorize the code we added in the
viewDidLoad() in order to make it easily reusable on any
⚠️ Despite how elegant and clean the final code looks like, there are still some trade-offs because we use some RxCocoa features that depends on Objective-C runtime and, event if we don’t use any private methods, you still should be careful when you use such techniques.