How and when to use KVO in Swift and UIKit
KVO is often an example of something you should not use if you want to avoid crashes. But what if you need to use it?
Part of large code re-factoring we do in recent months is replacing a very old custom sliding pages implementation with straight-forward UIScrollView
with isPagingEnabled
set to true
. It’s a complex piece of UI, with 3 to 8 embedded controllers with lots of content shown inside each embedded UIVC.
We employed that custom component because it allowed us to embed only the currently visible UIVCs and efficiently discard stuff which are not visible. This was neat benefit for older iOS devices. Today though, app’s minimum deployment target is iOS 11 and all such devices can easily survive the amount of data we throw at it. Component in question was the last Objective-C code we had in the project. It was showing its age and some very weird bugs and crashes seemed to have been caused by its internal layout code which was pointless to resolve. It was time to ditch it and use plain scroll view.
Now, what we needed is to target any of the horizontal “pages” to show on viewDidAppear
. This turned out to be quite an issue, as even inside said method, UIScrollView.contentSize
was being reported as {375,600}
, as in — identical to .bounds
. So none of the code we tried — setContentOffset, scrollToRect etc — worked. It just stayed on 0,0
origin. It appears that UIScrollView
renders its internal content and properly calculates contentSize
slightly after viewDidAppear
code.
private func scrollToCurrentPage(_ animated: Bool = true) {
var rect = pagesScrollView.bounds
rect.origin.y = 0
rect.origin.x = CGFloat(currentPage) * rect.width
pagesScrollView.scrollRectToVisible(rect, animated: animated)
}
Chasing this with DispatchQueue.main.asyncAfter(.now() + 0.3)
is fool’s errand; there’s bound to be 1 in 100 cases where even that is not enough. Plus, it looks ugly; it appears wrong then immediately corrects itself. Yuck.
So, what else to do..? Use KVO, of course. ☺️ KVO has a bad reputation. Really bad.
This is all the you need to setup KVO:
final class PagesController: UIViewController {
private var kvoContext = 0
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &kvoContext {
if !isViewLoaded {
return
}
scrollToCurrentPage(false)
return
}
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
}
The remaining question now is when to register PagesController
as observer for UIScrollView.contentSize
and more importantly: when to de-register it? If you screw this up, you are bound to have crashes. If register/de-register calls are not perfectly paired, you will get crashes due to calls to de-referenced pointers and similar shenanigans.
To avoid crashes, you need to answer these 3 questions:
- What is the earliest moment in lifecycle I need KVO to tell me things?
- What’s the opposite lifecycle moment of the previous one?
- At what moment in lifecycle will the object being observed actually materialise and/or change?
The usual moments most examples you can find online go for: init
and deinit
. I find that for UIKit that’s pretty much always wrong. Views are irrelevant until after viewDidLoad
. UIKit can load and unload UIVC’s view as it wants, many times during UIVC lifetime.
I’ve also seen — and used many times myself, I must admit 😩 — the horrifically bad combination of register in viewDidLoad
and unregister in deinit
.
In this case: I am observing contentSize
of the UIScrollView
; the first moment where it makes sense to get info about it is after viewDidLayoutSubviews
.
Thus the most sensible moment to register KVO observer is inside viewWillAppear
which means that opposite moment is viewWillDisappear
:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
pagesScrollView.addObserver(self, forKeyPath: #keyPath(UIScrollView.contentSize), options: [.new], context: &kvoContext)
}
override func viewWillDisappear(_ animated: Bool) {
pagesScrollView.removeObserver(self, forKeyPath: #keyPath(UIScrollView.contentSize), context: &kvoContext)
super.viewWillDisappear(animated)
}
And that’s about it. KVO block will be fired when contentSize
changes and my code to move the visible bounds to proper page executes right after.
It seems to work reliably in initial tests but we will know [for sure] only when app gets deployed to all customers. 🤞🏻