00:06 Today we'll talk about storyboards and how we can refactor some
parts of them. 00:13 We have an app that displays a table view. If you
tap on one of the items in the table, it shows the detail view controller.
There's also a profile button, which pops up a modal navigation controller with
another view controller inside. If you click "Done," the modal navigation
controller gets dismissed:
00:33 In the storyboard, we have a few view controllers: the navigation
controller; the episodes list; the detail view controller; and finally, the
navigation controller, which contains the modal profile view. 00:51 In
the code, we have three classes. The first two are simple: the
ProfileViewController
and the DetailViewController
. The
EpisodesViewController
is a little bit more complicated. It's a simple table
view controller, but it also has the prepareForSegue
method, which we want to
refactor. The prepareForSegue
method distinguishes between two different
segues and configures the respective view controllers. 01:34 Finally,
there's the unwind IBAction
, which gets a segue whenever the modal view
controller is dismissed.
01:59 It's nice that the storyboard gives us a visual representation of
how the view controllers are connected. 02:11 However, it's difficult to
change the way the view controllers are connected, because not everything is
done in the storyboard: we have segues in the storyboard, but we have the
prepareForSegue
methods in the view controllers. We need to be careful to
ensure that the code matches up with the storyboard. Instead of having the
connections in both the storyboard and the code, it would be nice to have a
single place where this all happens.
Refactoring the Storyboard
02:44 First, we remove some segues from the storyboard. We'll keep the
storyboard around for now, in order to define our view controllers and lay out
views. 03:03 We remove the push segue, the modal presentation segue, and
the unwind IBAction
. 03:25 Now our storyboard is only used to define
the view controllers.
03:33 In our code, we start by removing the prepareForSegue
method,
along with the unwind IBAction
. 03:50 Now we have to find a different
way to connect our view controllers. 03:58 The starting point is to
override the tableView:didSelectRowAtIndexPath
method. 04:07 Within
this method, we don't want to push the next view controller, because it would
entangle these two view controllers. Instead, we want to control this flow from
the outside. That way, the view controllers are standalone and don't know about
the context they're used in. 04:28 To achieve that, when a row gets
selected, we just call a function, didSelect
, with the selected episode:
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let episode = episodes[indexPath.row]
didSelect(episode)
}
04:51 The didSelect
property is simple — it's a function with type
Episode -> ()
, and we provide a default implementation, which does nothing:
var didSelect: (Episode) -> () = { _ in }
05:17 Now we configure this property in our AppDelegate
. 05:21
First, we get a reference to the navigation controller via the window object.
05:45 Second, we get a reference to the EpisodesViewController
, which
is the first view controller in the navigation controller's stack:
let nc = window?.rootViewController as! UINavigationController
let episodesVC = nc.viewControllers[0] as! EpisodesViewController
06:03 The force casting isn't nice, but it's a result of us still
holding on to the storyboard. We could get rid of it if we would instantiate all
our view controllers in code, but as long as we use storyboards, we have to deal
with force casting. However, we can create a central place where we do this —
for example, in an extension on UIStoryboard
.
Connecting Two View Controllers
06:42 Now that we have a reference to the EpisodesViewController
, we
can set the didSelect
property. The anonymous function gets the episode in and
calls pushViewController
on the navigation controller. 07:08 Since we
want to instantiate our detail view controller from the storyboard, we first
create a reference to the storyboard. We can then get our detail view controller
by calling instantiateViewControllerWithIdentifier
:
let storyboard = UIStoryboard(name: "Main", bundle: nil)
episodesVC.didSelect = { episode in
let detailVC = storyboard.instantiateViewControllerWithIdentifier("Detail") as! DetailViewController
nc.pushViewController(detailVC, animated: true)
}
07:49 We've connected these two view controllers, and our episodes view
controller doesn't know anything about the detail view controller, as the flow
is controlled only within the didSelect
callback. 08:01 Finally, we
need to configure the detail view controller, so we pass in the episode we
received in the didSelect
callback:
episodesVC.didSelect = { episode in
let detailVC = storyboard.instantiateViewControllerWithIdentifier("Detail") as! DetailViewController
detailVC.episode = episode
nc.pushViewController(detailVC, animated: true)
}
08:24 In our app, we can now select cells, and the detail view
controller gets configured correctly. However, the profile screen doesn't work
yet. 08:35 In our storyboard, we have to hook up the "My Profile" button
with an action in the episodes view controller:
class EpisodesViewController: UITableViewController {
@IBAction func showProfile(sender: AnyObject) {
}
}
09:04 We also need to dismiss the profile screen at a later point, so
we'll create an action for that as well:
class ProfileViewController: UIViewController {
@IBAction func close(sender: AnyObject) {
}
}
09:31 In the showProfile
action, we don't want to hardcode the
presentation of the ProfileViewController
. Just like before, we introduce a
function, didTapProfile
, which we hand into EpisodesViewController
:
class EpisodesViewController: UITableViewController {
var didTapProfile: () -> () = {}
@IBAction func showProfile(sender: AnyObject) {
didTapProfile()
}
}
10:13 We do the same thing for our close action in
ProfileViewController
:
class ProfileViewController: UIViewController {
var didTapClose: () -> () = {}
@IBAction func close(sender: AnyObject) {
didTapClose()
}
}
10:45 In the AppDelegate
, we configure the didTapProfile
property
with a closure, which instantiates the ProfileViewController
and presents it:
episodesVC.didTapProfile = {
let profileVC = storyboard.instantiateViewControllerWithIdentifier("Profile") as! UINavigationController
nc.presentViewController(profileVC, animated: true, completion: nil)
}
11:37 To make the dismissal work as well, we have to set the
didTapClose
property. For this, we need to get the ProfileViewController
out
of the navigation controller:
episodesVC.didTapProfile = {
let profileNC = storyboard.instantiateViewControllerWithIdentifier("Profile") as! UINavigationController
let profileVC = profileNC.viewControllers[0] as! ProfileViewController
profileVC.didTapClose = {
nc.dismissViewControllerAnimated(true, completion: nil)
}
nc.presentViewController(profileNC, animated: true, completion: nil)
}
12:44 This pattern decouples our view controllers: they don't know about
each other and they don't present each other. They're connected in a central
place outside, so the view controllers don't even know that they're inside a
navigation controller. 13:02 However, it's not nice that all this code
is still living in the AppDelegate
. We need to refactor that.
Creating an App
class
13:12 The easiest way to improve this situation is to put this code
inside its own class, App
:
class App {
init(window: UIWindow) {
let nc = window.rootViewController as! UINavigationController
let episodesVC = nc.viewControllers[0] as! EpisodesViewController
let storyboard = UIStoryboard(name: "Main", bundle: nil)
episodesVC.didSelect = { episode in
let detailVC = storyboard.instantiateViewControllerWithIdentifier("Detail") as! DetailViewController
detailVC.episode = episode
nc.pushViewController(detailVC, animated: true)
}
episodesVC.didTapProfile = {
let profileNC = storyboard.instantiateViewControllerWithIdentifier("Profile") as! UINavigationController
let profileVC = profileNC.viewControllers[0] as! ProfileViewController
profileVC.didTapClose = {
nc.dismissViewControllerAnimated(true, completion: nil)
}
nc.presentViewController(profileNC, animated: true, completion: nil)
}
}
}
13:54 In the AppDelegate
, we create a property for App
and
instantiate it:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var app: App?
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
if let window = window {
app = App(window: window)
}
return true
}
}
14:27 One problem is that our App.init
is a large blob of code. There
are many callbacks, including callbacks inside callbacks. We should improve the
code by factoring those callbacks out, since the callback situation will only
get more complicated with time: each level in the navigation stack will result
in another nested callback.
Reducing Nested Callbacks
14:50 Instead of configuring a callback with a closure, we can put that
code into a method called didSelectEpisode
:
func didSelectEpisode(episode: Episode) {
let detailVC = storyboard.instantiateViewControllerWithIdentifier("Detail") as! DetailViewController
detailVC.episode = episode
navigationController.pushViewController(detailVC, animated: true)
}
15:19 In our closure, we can call the didSelectEpisode
method:
episodesVC.didSelect = { episode in
self.didSelectEpisode(episode)
}
15:27 didSelectEpisode
sounds like a delegate method. To improve the
naming, something more active — like showEpisode
— would be better:
func showEpisode(episode: Episode) {
let detailVC = storyboard.instantiateViewControllerWithIdentifier("Detail") as! DetailViewController
detailVC.episode = episode
navigationController.pushViewController(detailVC, animated: true)
}
15:47 To make it compile, we pull the storyboard and the navigation
controller out into properties on App
:
final class App {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let navigationController: UINavigationController
init(window: UIWindow) {
navigationController = window.rootViewController as! UINavigationController
}
}
16:44 Instead of writing the closure and calling the method, we can
directly assign the method to didSelect
, which cleans up the code a lot:
episodesVC.didSelect = showEpisode
17:00 We can do the same thing for the profile selection. We create a
method called showProfile
and pull out the closure:
init(window: UIWindow) {
navigationController = window.rootViewController as! UINavigationController
let episodesVC = navigationController.viewControllers[0] as! EpisodesViewController
episodesVC.didSelect = showEpisode
episodesVC.didTapProfile = showProfile
}
func showProfile() {
let profileNC = self.storyboard.instantiateViewControllerWithIdentifier("Profile") as! UINavigationController
let profileVC = profileNC.viewControllers[0] as! ProfileViewController
profileVC.didTapClose = {
self.navigationController.dismissViewControllerAnimated(true, completion: nil)
}
navigationController.presentViewController(profileNC, animated: true, completion: nil)
}
17:33 Having these method names really helps readability, as the named
methods are easier to understand than all those nested callbacks.
17:47 We managed to keep the view controllers simple; only the app
class knows how they're connected. 18:04 To show how easy it is to
change things around, we can show the profile view controller when an episode is
selected. 18:15 We only need to make changes in this one place, and now
our app behaves differently:
episodesVC.didSelect = { _ in self.showProfile() }
18:28 The App
class is a dense piece of code, but the rest of the
code is very simple and decoupled. 18:52 One problem though: when you
see a closure with a self
reference inside, you start wondering if there are
reference cycles. In our case, there aren't. For example, only the navigation
controller references the profile view controller. Once you hit dismiss, the
reference goes away. Still, having all these closures in your code might make
you think: "Is this still good, or do I have to mark something as weak
?"
19:27 When refactoring, it's also easy to introduce reference cycles
accidentally.
19:51 Our approach lends itself well to creating more reusable view
controllers. For example, we can introduce some generic view controllers to
reuse in different places. Let's look at that in a future episode.