This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We experiment with reactive view bindings that don't rely on runtime programming.

00:06 Let's continue working on the running routes app, Laufpark. We've made a few cosmetic changes to the track info view since the last episode so that it shows more information, but everything still works the same way. Today we'll do an architectural experiment.

Loading Indicator

00:24 When the app launches, it shows a loading indicator until the GPX data is loaded. This is done in a standard way, but the code is spread out over four places: we create a UIActivityIndicatorView in the view controller, we do some configuration in viewDidLoad, we add the view to the layout a bit further down, and we start and stop the indicator in the update method:

final class ViewController: UIViewController {

    // ...
    private let loadingIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
    // ...

    override func viewDidLoad() {
        // Configuration
        // ...
        loadingIndicator.hidesWhenStopped = true
        loadingIndicator.startAnimating()
        // ...


        // Layout
        // ...
        view.addSubview(loadingIndicator)
        loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
        // ...
    }

    // ...

    private func update(old: State) {
        if state.loading {
            loadingIndicator.startAnimating()
        } else {
            loadingIndicator.stopAnimating()
        }
        // ...
    }

    // ...
}

01:35 It's easy to understand how the program works, but wouldn't it be nice if we could define the loading indicator in one place? Instead of explicitly telling the indicator to start and stop animating when the state changes, we want the indicator to react to a state change. We need a reactive approach for this.

Incremental Programming

02:15 We're going to use a small library called Incremental, which is similar to reactive programming but more experimental. In a later episode, we want to go into more detail about Incremental, but for now we can rely on what we know from reactive programming: working with mutable variables that can be observed.

02:48 We first refactor the view controller's state property. Eventually we want to get rid of it entirely, but we'll do this progressively. We rename the property to _state, so anytime we see code that uses this variable, we're reminded we should refactor that code. Until everything is updated, we'll have a hybrid architecture with two state properties.

03:24 We import Incremental and we need two new properties — an input and an observable. Every time we set our old version state, we also forward the value to the new input:

import Incremental

final class ViewController: UIViewController {
    // ...
    private let stateInput: Input<State> = Input(State(tracks: []))
    private var state: I<State> { return stateInput.i }
    private var _state: State = State(tracks: []) {
        didSet {
            stateInput.write(_state)
            update(old: oldValue)
        }
    }
    // ...
}

04:01 The Input property is similar to ReactiveSwift's mutable property or RxSwift's variable. The new state is an observable property (comparable to a signal), and its type is called I in incremental programming. We define it as a computed property that returns stateInput.i — the naming isn't perfect but it works for now.

04:54 So far, the code still builds and we haven't really changed anything. But now we can make use of our new observable state and move some view-updating code from the update method to the place where the views are defined.

Gathering All Code

05:16 We put all loading indicator code together in one place, in viewDidLoad, and start observing the state. We get the loading Boolean out of state and observe it with a closure. We have to pass unowned self to the closure; the closure shouldn't point to the view controller, because the view controller will eventually store a disposable that points back to the observer.

07:08 We add an array to the view controller for storing the disposable. This will be the place where we store anything we have to keep a reference to:

final class ViewController: UIViewController {
    // ...
    private var disposables: [Any] = []
    // ...
}

07:42 By adding the disposable to the array, we actually keep the observer around for the lifetime of the view controller:

func viewDidLoad() {
    // ...
    
    loadingIndicator.hidesWhenStopped = true
    let disposable = state.map { $0.isLoading }.observe { [unowned self] isLoading in
        if state.loading {
            self.loadingIndicator.startAnimating()
        } else {
            self.loadingIndicator.stopAnimating()
        }
    }
    disposables.append(disposable)
    view.addSubview(loadingIndicator)
    loadingIndicator.translatesAutoresizingMaskIntroConstraints = false
    NSLayoutConstraint.activate([
        loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    ])
}

07:50 We remove the line where we start animating the indicator at setup, because the observer will be called immediately, thus starting the animation. When we run the app, we see the loading indicator appear and disappear, so everything works as expected.

08:18 There's just one piece of code that isn't local yet — the line where we instantiate the loading indicator. We convert the property into a local variable, which allows us to remove self from the observer closure again:

func viewDidLoad() {
    // ...
    let loadingIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
    loadingIndicator.hidesWhenStopped = true
    let disposable = state.map { $0.isLoading }.observe { isLoading in
        if state.loading {
            loadingIndicator.startAnimating()
        } else {
            loadingIndicator.stopAnimating()
        }
    }
    disposables.append(disposable)
    view.addSubview(loadingIndicator)
    loadingIndicator.translatesAutoresizingMaskIntroConstraints = false
    NSLayoutConstraint.activate([
        loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    ])
}

08:55 Everything concerning the loading indicator is now in one place: its declaration, its configuration, its constraints, and the observing. If we later want to remove the loading indicator for some reason, this single block of code can be removed and we'd be done.

Wrapping Views

09:21 Another improvement would be to write the code in a more declarative way. Currently, we still call the methods to start and stop animating based on the state — a kind of glue code that we want to eliminate from viewDidLoad by binding the animating of the indicator to the loading Boolean of the state. Almost every reactive library has a convenient way to bind a signal to a property. They usually do this at runtime in order to keep references to disposables. We'll take a different approach and create a box around the indicator view that also holds the disposables.

10:54 The box can hold anything, and we call its value unbox because that works nicely at the call site:

final class Box<A> {
    let unbox: A
    var disposables: [Any] = []

    init(_ value: A) {
        self.unbox = value
    }
}

11:59 We can now create the box around the loading indicator and add the observer disposable to the box as well. At this point, the box still feels pointless, because we now have to add the box to the view controller's disposable. We'll fix that later:

let loadingIndicator = UIActivityLoadingIndicator(activityIndicatorStyle: .gray)
let boxedLoadingIndicator = Box(loadingIndicator)
// ...
let disposable = state.map { $0.isLoading }.observe { /*...*/ }
boxedLoadingIndicator.disposables.append(disposable)
disposables.append(boxedLoadingIndicator)

Binding

13:26 Let's clean the code up more. If we take out the isLoading observable, it becomes clear that we can move the whole Boolean observer into the indicator view and provide an interface to bind the animation to any Boolean:

let isLoading: I<Bool> = state.map { $0.isLoading }
let disposable = isLoading.observe { /*...*/ }

14:17 We write an extension of Box if it holds an activity indicator:

extension Box where A: UIActivityIndicatorView {
    func bindIsAnimating(to isAnimating: I<Bool>) {
        let disposable = isAnimating.observe { [unowned self] animating in
            if animating {
                self.unbox.startAnimating()
            } else {
                self.unbox.stopAnimating()
            }
        }
        references.append(disposable)
    }
}

15:46 This gives us a much cleaner way to bind the animation to the loading state:

boxedLoadingIndicator.bindIsAnimating(to: state.map { $0.isLoading })

Hiding Disposables

16:32 The last ugly part we want to remove is where we add the boxed loading indicator to the view controller's disposables. While we continue to refactor the view controller, more and more views will be wrapped in a box, and with each box comes another disposable. By boxing up the root view, we could write our own "add boxed subview" method that will add the subview and store the box disposable for us.

17:26 We repurpose the disposables array property of the view controller to become a root view. Then, in viewDidLoad, we can add the boxed loading indicator to this root view:

final class ViewController: UIViewController {
    // ...
    private var rootView: Box<UIView>!
    // ...
    func viewDidLoad() {
        rootView = Box(view)
        // ...

        let loadingIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
        let boxedLoadingIndicator = Box(loadingIndicator)
        loadingIndicator.hidesWhenStopped = true
        boxedLoadingIndicator.bindIsAnimating(to: state.map { $0.loading })
        rootView.addSubview(boxedLoadingIndicator)
        loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }
    // ...
}

18:34 In an extension of Box, we write a method to add subviews. We have to constrain this extension to boxes that contain views to begin with:

extension Box where A: UIView {
    func addSubview<V: UIView>(_ view: Box<V>) {
        unbox.addSubview(view.unbox)
        disposables.append(view)
    }
}

19:32 In the context of a Box, the term disposables doesn't make much sense, so we rename the property to references.

Conclusion

20:52 Looking at the loading indicator code in viewDidLoad, it's pretty clear code that also describes all the updates that will happen with the view. So it's declarative but still transparent enough to understand everything that's going on.

21:21 There are more ways to improve the code, like adding a constructor function that creates and configures a boxed activity indicator. Constructing the layout constraints makes for pretty verbose code that we need for every single view — we'll look at refactoring this next time.

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes