00:07 Let's continue talking about loading data from the network. We've
covered the networking part already, so today we'll talk about ways to handle
asynchronous requests in the UI. When loading data from the network, we always
face the same problem: we don't have the data yet, but we want to show the user
some kind of activity or progress indicator. Once the data arrives, we want to
configure the views.
00:41 We start by implementing this pattern inside of a single view
controller. Afterward, we'll look at different approaches of how we can factor
out the loading logic.
Making View Controllers Asynchronous
00:57 The starting point is a simple view controller,
EpisodeDetailViewController
, which simply displays the title of one episode.
01:08 In viewDidLoad
, we set the background color and add the label as
a subview:
final class EpisodeDetailViewController: UIViewController {
let titleLabel = UILabel()
convenience init(episode: Episode) {
self.init()
titleLabel.text = episode.title
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .whiteColor()
titleLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(titleLabel)
titleLabel.constrainEdges(toMarginOf: view)
}
}
01:17 For the loading part, we add another initializer to the view
controller. This initializer takes a Resource<Episode>
instead of just an
Episode
. 01:34 In the Networking
episode, we talked about the networking part and
how we work with resources. 01:42 Within this initializer, we use the
sharedWebservice
to load the data for the resource that gets passed in.
01:50 Once the network request is finished, we get a callback with a
result. 02:00 If we can't extract an episode from this result, we just
return immediately, and we use a guard statement for this early exit.
02:21 After the guard
, we know that we have an episode and we can
update the title label. Since we're referencing self
when we access the title
label, we add self
as weak
to the callback's capture list, because we don't
want to reference the view controller unnecessarily. After all, it might not be
onscreen anymore once the network call comes back. 02:46 Lastly, we also
need to call the super class's initializer to make this work:
convenience init(resource: Resource<Episode>) {
self.init()
sharedWebservice.load(resource) { [weak self] result in
guard let value = result.value else { return } self?.titleLabel.text = value.title
}
}
02:59 With this initializer in place, we can update the call site.
Instead of handing an episode, we hand a resource to the view controller:
let episodesVC = EpisodeDetailViewController(resource: episodeResource)
03:33 We're still missing an activity indicator. 03:41 We'll add
a spinner
property — which holds an instance of UIActivityIndicatorView
— to
the view controller. We start the spinner right before we make the network call.
03:57 Once the network request is finished, we stop the spinner,
regardless of whether or not the request succeeded. Again, we reference
spinner
with self?
in order to not capture the view controller strongly:
convenience init(resource: Resource<Episode>) {
self.init()
spinner.startAnimating()
sharedWebservice.load(resource) { [weak self] result in
self?.spinner.stopAnimating()
guard let value = result.value else { return } self?.titleLabel.text = value.title
}
}
04:06 We configure the spinner in viewDidLoad
. 04:16 We set
hidesWhenStopped
to true, disable the resizing mask translation, and add the
spinner as a subview. 04:38 Finally, we center it in the current view
using one of our convenience auto layout extensions:
spinner.hidesWhenStopped = true
spinner.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(spinner)
spinner.center(inView: view)
05:01 To make the spinner show up, we still need to initialize it with a
particular style. For now, we'll simply use the .Gray
style:
let spinner = UIActivityIndicatorView(activityIndicatorStyle: .Gray)
05:26 That was a lot of boilerplate code to make a basic activity
indicator work, and we'll have to type this over and over again in lots of
different view controllers. 05:39 As such, it would be much better to
factor this logic out of the view controller. Doing so would save us quite a bit
of work, and it would make the view controller simpler as well.
Creating the Loading
Protocol
05:57 The first approach we can try is to create a protocol and pull the
code we wrote into a protocol extension. This approach is very similar to what
Apple demoed in the WWDC 2015 session for Protocol-Oriented
Programming.
06:25 We start by adding a Loading
protocol, which defines the spinner
as a read-only property. 06:47 We also add a load
method — which
performs the actual network call and starts and stops the spinner — to the
protocol. This method takes a resource as parameter. Since we can't specify the
generic parameter of Resource
any further, we add an associatedtype
called
ResourceType
to the protocol so that we can work with resources of any type:
protocol Loading {
associatedtype ResourceType
var spinner: UIActivityIndicatorView { get }
func load(resource: Resource<ResourceType>)
}
07:08 Since we want to provide an implementation for the load
method,
we create a protocol extension. 07:21 Actually, it's sufficient to only
declare the load
method in the protocol extension, because we don't want this
method to be overwritten by the classes that conform to the Loading
protocol:
protocol Loading {
associatedtype ResourceType
var spinner: UIActivityIndicatorView { get }
}
extension Loading {
func load(resource: Resource<ResourceType>) {
}
}
07:36 Now we just move the code that we wrote in the view controller's
initializer into the protocol's load
method. 07:52 The method still
performs the same tasks: starting the spinner, loading the data from the web
service, and stopping the spinner. To make the code work, we constrain the
protocol extension to instances of UIViewController
:
extension Loading where Self: UIViewController {
func load(resource: Resource<ResourceType>) {
spinner.startAnimating()
sharedWebservice.load(resource) { [weak self] result in
self?.spinner.stopAnimating()
guard let value = result.value else { return } }
}
}
08:18 In our protocol extension, we don't know how to configure the
views after we get the data back from the network. As such, we have to delegate
this task back to the view controller itself. 08:33 For this, we add a
configure
method to the protocol that takes a value of type ResourceType
.
08:47 We call configure
once the data comes back from the network:
protocol Loading {
func configure(value: ResourceType)
}
extension Loading where Self: UIViewController {
func load(resource: Resource<ResourceType>) {
spinner.startAnimating()
sharedWebservice.load(resource) { [weak self] result in
self?.spinner.stopAnimating()
guard let value = result.value else { return } self?.configure(value)
}
}
}
09:14 Now we can make the view controller conform to Loading
. The
spinner
property already exists, so we just have to implement the configure
method, where we set the label's text to the episode's title:
final class EpisodeDetailViewController: UIViewController, Loading {
func configure(value: Episode) {
titleLabel.text = value.title
}
}
09:45 We can now call the protocol's implementation of load
in the
initializer:
final class EpisodeDetailViewController: UIViewController, Loading {
convenience init(resource: Resource<Episode>) {
self.init()
load(resource)
}
}
10:05 We could also factor out the code that sets up the spinner in
viewDidLoad
, but we'll keep it as it is for now.
10:12 This is already much better than what we had before, since we've
removed a lot of code from the view controller. 10:24 However, it
doesn't feel like the perfect solution. 10:34 One drawback is that we're
just hiding code that was previously in the view controller in the protocol. The
view controller still has a hard dependency on this code though. 10:45
For example, we cannot instantiate an EpisodeDetailViewController
without a
network stack. This makes testing unnecessarily complex.
Using Container View Controllers
11:06 Let's try a different approach and use container view controllers
to separate the part that shows the loading activity from the part that displays
the data. The container view controller will make the network call, and once the
data comes back, we can add the final view controller as a child. 11:22
We got this idea from a
talk by
Ayaka Nonaka at try! Swift.
11:33 The first step is to create a new UIViewController
called
LoadingViewController
. 11:46 The initializer takes a resource of
anything, so we have to add a generic parameter to the initializer itself.
11:58 As a second parameter, the initializer takes a build
function,
which takes the result from the network call and returns a view controller:
final class LoadingViewController: UIViewController {
init<A>(resource: Resource<A>, build: (A) -> UIViewController) {
}
}
12:12 Now we move the code from the Loading
protocol's load
method
into the initializer. 12:22 To make this work, we also have to move the
spinner
property from the EpisodeDetailViewController
into the
LoadingViewController
. 12:42 Lastly, instead of calling configure
when we have the data from the network, we now call the build
function and add
the resulting view controller as a child view controller:
init<A>(resource: Resource<A>, build: (A) -> UIViewController) {
super.init(nibName: nil, bundle: nil)
spinner.startAnimating()
sharedWebservice.load(resource) { [weak self] result in
self?.spinner.stopAnimating()
guard let value = result.value else { return } let viewController = build(value)
self?.add(content: viewController)
}
}
13:10 The add
method goes through the standard steps to add a child
view controller. 13:24 First, we call addChildViewController
and add
the child view controller's view as a subview. 13:47 Then, we lay it out
by constraining its edges to the container view's edges. 14:06 Finally,
we call didMoveToParentViewController
on the child view controller:
func add(content content: UIViewController) {
addChildViewController(content)
view.addSubview(content.view)
content.view.translatesAutoresizingMaskIntoConstraints = false
content.view.constrainEdges(toMarginOf: view)
content.didMoveToParentViewController(self)
}
14:16 To make the LoadingViewController
compile, we also have to add
the required initializer. Xcode can fix this for us using the "Fix all in scope"
shortcut.
14:24 To add the spinner to the view hierarchy, we copy the code from
the EpisodeDetailViewControllers
's viewDidLoad
method:
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .whiteColor()
spinner.hidesWhenStopped = true
spinner.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(spinner)
spinner.center(inView: view)
}
14:58 With the LoadingViewController
in place, we can now clean up the
EpisodeDetailViewController
and remove all the unused code. Actually, we can
just revert it to its original state.
15:21 To try out the new LoadingViewController
, we instantiate it with
the episode resource. Then we just return an instance of
EpisodeDetailViewController
from the build function:
let episodesVC = LoadingViewController(resource: episodeResource, build: { episode in
return EpisodeDetailViewController(episode: episode)
})
16:38 Another improvement is to directly use
EpisodeDetailViewController.init
as the build function. In doing this, we
don't need the anonymous function around it anymore:
let episodesVC = LoadingViewController(resource: episodeResource, build: EpisodeDetailViewController.init)
17:05 What we like about this approach is that the
EpisodeDetailViewController
is very simple again. 17:12 It's also
synchronous, and therefore pretty easy to test in isolation.
17:24 However, we could still improve the LoadingViewController
by
removing its dependency on the shared web service. 17:32 Instead of
passing in a resource, we could pass in a load function that performs the actual
loading of the data. The loading view controller calls the load
function with
a callback as its parameter. Once this callback gets called, we can proceed as
before and call the build
function. 17:57 The type of the load
parameter is a function with one parameter and no return type. The parameter is
a function that takes a Result
of A
. 18:13 Instead of the call to
sharedWebservice.load
, we can now call the load
function, and everything
else stays the same:
init<A>(load: ((Result<A>) -> ()) -> (), build: (A) -> UIViewController) {
super.init(nibName: nil, bundle: nil)
spinner.startAnimating()
load() { [weak self] result in
self?.spinner.stopAnimating()
guard let value = result.value else { return } let viewController = build(value)
self?.add(content: viewController)
}
}
18:26 Now we move the call to the shared web service down to where we
instantiate the LoadingViewController
. 18:29 Implementing the load
function is straightforward: we get a callback in, and then we call the shared
web service with the episode resource and the callback as completion handler:
let sharedWebservice = Webservice()
let episodesVC = LoadingViewController(load: { callback in
sharedWebservice.load(episodeResource, completion: callback)
}, build: EpisodeDetailViewController.init)
18:52 If we mostly want to create instances of LoadingViewController
that load data using a Resource
, we could add a convenience initializer in an
extension on LoadingViewController
in order to avoid code repetition.
19:02 However, it's much better that the loading view controller itself
doesn't depend on the shared web service anymore. 19:08 The loading
view controller doesn't depend on anything, and the
EpisodeDetailViewController
is completely decoupled as well, which makes it
very easy to test.
Pros and Cons
19:24 There are advantages and disadvantages to both approaches of
using a protocol and a container view controller. 19:32 The solution
using container view controllers has the problem of nested view controllers that
sometimes don't play nicely with UIKit. For example, if you put the
LoadingViewController
in a navigation stack, it might interfere with UIKit's
layout adjustments for the extended edges. 19:52 Despite these issues,
it's still very helpful during development. Additionally, sometimes we use it
just for a temporary solution. In these cases, it's nice to be able to make any
view controller asynchronous almost instantaneously. 20:09 In places
where we use child view controllers anyway, this solution could work very nicely
in the final code as well.
20:17 Another problem with child view controllers is that navigation
items don't really work anymore. 20:25 As such, you'd have to write
extra code to make them work. Nevertheless, the LoadingViewController
comes in
handy in cases where you don't have access to the code of view controllers. For
example, we used it to wrap an AVPlayerViewController
.
20:48 In the cases where child view controllers don't work very well,
you could still factor out the common pattern of loading data asynchronously
using a different approach, e.g. the one we showed before using a protocol.
21:03 The protocol solution also has the advantage of being a bit more
lightweight, because it doesn't add the extra layer in the view hierarchy.
21:13 Another example where we used the LoadingViewController
in our
project was as a temporary solution for a table view controller, before we
implemented pull-to-refresh. 21:30 Once we had pull-to-refresh, we
simply removed this wrapper. It's a handy tool to have in your project for
development.