In this article

Our goal here is to come up with a way to observe the lifecycle of a view controller (or SwiftUI View) and perform various actions. For example, we might want to capture analytics that a screen has shown or we might want to log an event to the device. We might even want to add fundamental behavior to the view, like making it so that when a user taps on a form field the view scrolls and the form field shows over the keyboard.

With UIKit

Let's start with UIKit, it turns out that if you add a UIViewController to another one as a child it receives all the same lifecycle events. So the trick is really just about making that child view controller as cheap as we can to add. Let's start by defining a protocol:

protocol UIViewControllerLifecycleObservable { }

We don't actually need to add any methods to this, because we're really using it for identification purposes. Next let's add some methods that allow us to add this observer to our UIViewController:

extension UIViewController {
    func addLifecycleObserver<T: UIViewController & UIViewControllerLifecycleObservable>(_ observer:T) {
        addChild(observer)
        view.addSubview(observer.view)
        observer.view.frame = .zero
        observer.view.autoresizingMask = []
        observer.didMove(toParent: self)
    }
}

Now that we've got an easy way to add a lifecycle observer, what should these look like? Here's an example of tracking analytics that's fairly straightforward:

class TrackableObserver: UIViewController, UIViewControllerLifecycleObservable {
    override func viewDidAppear(_ animated: Bool) {
        // tell our analytics solution that "\(Self.self)" was presented
    }
}

You can use the delegate pattern to apply different types of behaviors to a view. For example, we added some around dismissing the keyboard when you tap outside the field you're entering text into. But how do you test observers that are added?

func XCTAssertObservableAttached<T: UIViewControllerLifecycleObservable>(on viewController: UIViewController, observableType:T.Type) {
    let observables = viewController.children.filter{ $0 is T }
    XCTAssertFalse(observables.isEmpty, "\(observableType) is not a child of view controller: \(viewController)")
    XCTAssertEqual(observables.count, 1, "multiple instances of \(observableType) found on view controller: \(viewController)")
}

Now from your tests, you can assert that an observable was added:

func testAnalyticsTrackedOnView() {
    class SomeVC: UIViewController { // normally this view would come from production code, but you get the point
        let tracker = TrackableObserver();
        override func viewDidLoad() {
            super.viewDidLoad()
            addLifecycleObserver(tracker)
        }
    }
    let testViewController = SomeVC()
    
    testViewController.viewDidLoad()
    testViewController.viewDidAppear(false)
    
    XCTAssertObservableAttached(on: testViewController, observableType: TrackableObserver.self)
}

With SwiftUI

SwiftUI is going to have a slightly different approach, but it's a very similar concept. Once again let's start with our protocol:

protocol ViewLifecycleObservable {
    func onAppear()
    func onDisappear()
}

Great! Now let's add an extension to View so that we can add these lifecycleObservers. SwiftUI makes this incredibly easy.

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
    func addLifecycleObserver(_ observer:ViewLifecycleObservable) -> some View {
        return self
               .onAppear(perform: observer.onAppear)
               .onDisappear(perform: observer.onDisappear)
    }
}

The tests for this are going to be different. Unlike UIKit you cannot just inspect children and assert the observer was added. You'll need to assert on the actions that the observer takes. Note: While there aren't many choices for mocking frameworks in Swift check out Cuckoo, it is great at mocking this type of thing. You can just point it at the ViewLifecycleObservable protocol and get a useful mock.

In lieu of using Cuckoo, for this article let's just hand roll a mock we can use.

// hand rolled mock
struct MockLifecycleObserver: ViewLifecycleObservable {
    var onAppearCalled = 0
    func onAppear() { onAppearCalled += 1 }
    
    var onDisappearCalled = 0
    func onDisappear() { onDisappearCalled += 1 }
}

Now, there is no simple way to "just load" a View from SwiftUI onto the main window. There is, however, a really great library for it. We'll pull in the ViewInspector library and showcase what a test for this might look like.

import ViewInspector

...

func testV1AddsLifecycleObserver() throws {
	let mock = MockLifecycleObserver()
	struct V1: View, Inspectable { // normally this view would come from production code, but you get the point
		let trackableObserver: ViewLifecycleObservable
		init(trackableObserver: ViewLifecycleObservable = TrackableObserver()) {
			self.trackableObserver = trackableObserver
		}
        var body: some View {
        	VStack {
            	Text("Hello World!")
            }.addLifecycleObserver(trackableObserver)
        }
    }
	let v1 = V1(trackableObserver: mock)
        
	try v1.inspect().vStack().callOnAppear()
        
	XCTAssertEqual(mock.onAppearCalled, 1)
}

Wrapping up

These observers can be a powerful way of composing together behaviors you want. We've called out analytics and logging as potential items, and we've used this for a variety of other behaviors we want on views. Let us know in the comments what you think!

Special thanks to:

Clint Bandy — This whole adventure started because of one of our many morning conversations.
Richard Gist — Pushed for an easy way to test this and helped not only make the code possible, but the article as well.