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() {
loadingIndicator.hidesWhenStopped = true
loadingIndicator.startAnimating()
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.