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

Brandon from Kickstarter shows their approach of unifying all potential entry points into an iOS app using a common route enum, both in a simple demo implementaion and in their open source code base.

00:06 Today we'll take a look at deep linking in the Kickstarter app. There are a lot of possible entry points into the app: a URL opening the app or bringing it to the foreground, a push notification, or a force touch shortcut from the app icon. As such, the code can easily become messy when trying to map all of those sources to the various routes in the app.

00:46 We'll try to implement deep linking from a URL and a shortcut item in a simple demo app. Afterward, we'll compare our solution to the implementation in Kickstarter's live app.

01:10 Our demo app has a tab bar controller with two tabs and a modal that can be opened on either tab. We want to be able to jump to any of these three views using a URL scheme or a shortcut item.

Handling URLs

01:32 The app can receive URLs to open in two methods of the AppDelegate. If the app isn't yet running and it needs to open a URL, it will be launched by the system and the URL can then be found in the options of the method application(_:didFinishLaunchingWithOptions:). If the app is already running, but in the background, the system can request a URL via the method application(_:open:options:). We have to handle URLs in both places.

02:14 Starting with the first method, we try to retrieve a URL from the options dictionary and figure out if we can do something with it:

let scheme = "io.objc.deeplinking"

class AppDelegate: UIResponder, UIApplicationDelegate {
    // ...

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
        if let url = launchOptions?[.url] as? URL {
            // handle url
        }
        return true
    }

    // ...
}

02:49 We've already defined a URL scheme in Info.plist, which we'll use to check the received URL. We handle the three URLs that we support in a switch. In the default case — if we don't recognize the URL — we return false, which is the convention for when the app can't handle the URL:

if let url = launchOptions?[.url] as? URL {
    switch url.absoluteString {
    case "\(scheme)://tab1": // open tab 1
    case "\(scheme)://tab2": // open tab 2
    case "\(scheme)://modal": // show the modal
    default: return false
    }
}

04:10 Now we can use our root view controller to do something with each of the three URLs. We've already set up methods for this in TabBarViewController, so we can simply call these methods from the app delegate:

if let url = launchOptions?[.url] as? URL {
    let tabController = window?.rootViewController as! TabBarViewController
    switch url.absoluteString {
    case "\(scheme)://tab1": tabController.showTab(.tab1)
    case "\(scheme)://tab2": tabController.showTab(.tab2)
    case "\(scheme)://modal": tabController.showModal(sender: self)
    default: return false
    }
}

05:10 We're going to have to do the same dance in the other delegate method that opens URLs. Instead of copying the code, we'll move the code into a function that can be used in both places:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
    if let url = launchOptions?[.url] as? URL {
        return handle(url: url)
    }
    return true
}

func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
    return handle(url: url)
}

func handle(url: URL) -> Bool {
    let tabController = window?.rootViewController as! TabBarViewController
    switch url.absoluteString {
    case "\(scheme)://tab1": tabController.showTab(.tab1)
    case "\(scheme)://tab2": tabController.showTab(.tab2)
    case "\(scheme)://modal": tabController.showModal(sender: self)
    default: return false
    }
    return true
}

06:22 We made a little helper app to test this functionality, and it contains just three buttons that each open one of the three URLs we defined. We see that our code works so far, so we can move on to handle shortcut items.

Shortcut Items

07:05 Shortcut items invoke another app delegate method that gives us the UIApplicationShortcutItem with some information and a completion handler that we use to tell the system whether we handled the item or not:

func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
    // ...
}

07:22 We've added three possible shortcut items to Info.plist, with a type string we can use to identify which item is being used. In the delegate method, we switch on the shortcut item type and do things similarly to what we did before:

func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {

    let tabController = window?.rootViewController as! TabBarViewController
    switch shortcutItem.type {
    case "io.objc.deeplinking.tab1":
        tabController.showTab(.tab1)
        completionHandler(true)
    case "io.objc.deeplinking.tab2":
        tabController.showTab(.tab2)
        completionHandler(true)
    case "io.objc.deeplinking.modal":
        tabController.showModal(sender: self)
        completionHandler(true)
    default:
        completionHandler(false)
    }
}

08:54 Now we can also force touch the app icon and choose one of the three options, which will open the app to that option. Unfortunately, we ended up with some duplicate code, and the different possibilities to deep link into the app are scattered over the app delegate. This makes our code hard to test, because we'd have to use the app delegate methods to execute our code.

Route

09:46 Kickstarter's app unifies all of its entry points into an enum. We'll try to see how that works by applying the same principle in our demo app.

10:04 We create a Route enum for our entry points. TabBarViewController already has an enum describing the different tabs, so we can reuse that here as an associated value instead of repeating the tabs list as separate cases on Route:

enum Route {
    case modal
    case tab(TabBarViewController.Tab)
}

10:56 The Route can now bring together the various ways of deep linking by offering some initializers — for example, for a URL or a shortcut item. We move the URL handling code into an initializer and adapt it to simply set the enum value. The initializer should be failable for the case where we don't recognize the URL:

enum Route {
    case modal
    case tab(TabBarViewController.Tab)

    init?(url: URL) {
        switch url.absoluteString {
        case "\(scheme)://tab1": self = .tab(.tab1)
        case "\(scheme)://tab2": self = .tab(.tab2)
        case "\(scheme)://modal": self = .modal
        default: return nil
        }
    }
}

12:07 We need another initializer that takes a shortcut item:

enum Route {
    // ...

    init?(shortcutItem: UIApplicationShortcutItem) {
        switch shortcutItem.type {
        case "io.objc.deeplinking.tab1": self = .tab(.tab1)
        case "io.objc.deeplinking.tab2": self = .tab(.tab2)
        case "io.objc.deeplinking.modal": self = .modal
        default: return nil
        }
    }
}

13:16 In didFinishLaunchingWithOptions, we can try creating a Route from the URL:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
    if let url = launchOptions?[.url] as? URL {
        guard let route = Route(url: url) else { return false }
        handle(route: route)
    }
    return true
}

14:20 The other app delegate method for opening a URL can do the same thing:

func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
    guard let route = Route(url: url) else { return false }
    handle(route: route)
    return true
}

14:34 Finally, we can modify the handle(url:) method to now handle a Route. It doesn't need to return a boolean anymore, because we've already established that we have a valid Route at this point. We also don't need a default case anymore because we're no longer dealing with strings:

func handle(route: Route) {
    let tabController = window?.rootViewController as! TabBarViewController
    switch route {
    case .tab(let tab): tabController.showTab(tab)
    case .modal: tabController.showModal(sender: self)
    }
}

15:44 This works for deep linking with a URL. We can also update the shortcut item delegate method to use Route:

func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
    guard let route = Route(shortcutItem: shortcutItem) else {
        completionHandler(false)
        return
    }
    handle(route: route)
    completionHandler(true)
}

Benefits of Refactoring

16:59 We like this approach because just by looking at Route we can see all the different places we can link to without scanning for strings in the app delegate. On top of that, the initializers give us an overview of the different ways to construct a Route. In other words, we can see in which ways the app supports deep linking. Later on, if we want to also support push notifications, we'd simply create a new initializer on Route that takes a push notification.

Kickstarter's Implementation

18:02 The routing could become more complicated, for example when we have to deal with dynamic parameters. We'll look at Kickstarter's open source codebase to see how this approach works in a live app.

18:28 Here, the enum is called Navigation. There is a .tab case with an associated tab value, like in our demo app:

public enum Navigation {
    // ...
    case tab(Tab)
    case project(Param, Navigation.Project, refTag: RefTag?)
    case user(Param, Navigation.User)

    // ...
}

18:39 There are also some nested enums. This allows the app to deep link in multiple steps. In the Kickstarter app, you can link not only to a project page, but also to a subpage of the project. The .project case has a Param that identifies which project it is (either an ID integer or a hash); a refTag for analytics; and another sub-enum, Navigation.Product, describing the subroutes of a project:

public enum Navigation {
    // ...

    public enum Project {
        case root
        case comments
        case creatorBio
        case friends
        case messageCreator
        case pledge(Navigation.Project.Pledge)
        // ...
    }
}

19:50 Inside a project, we can link to the main root page, the comments section, and the message form for sending something to the project creator. Inside the pledge section, there are even deeper subroutes. It might look like a complex structure, but the code is still very readable.

20:33 Further on in the document, we can find all URL patterns that can be used to trigger a route, combined with a function that decodes the URL into a specific Navigation route. Those decoder functions use a library called Argo to interpret dynamic URL parts. This looks similar to what we did in an earlier episode about parser combinators. This style of programming could look very strange if you're not used to it, but you can still try to read it.

23:14 There's one decoding function for each type of URL, which adds up to a lot of code to support a lot of deep links. But the code stays simple and repetitive, which has its benefits.

23:42 This way of deep linking is very testable. Kickstarter uses a big test suite with a test for each supported route and URL. And the Route enum we created in the demo app is the biggest step we took toward creating testable code, since the delegate methods became very small and understandable enough to make us feel comfortable not testing that code.

24:31 Kickstarter's app delegate also uses view models in a way that's similar to what Chris and Brandon did in episode #47. Many app delegate methods forward calls to the view model:

func application(_ application: UIApplication,
                 open url: URL,
                 sourceApplication: String?,
                 annotation: Any) -> Bool {

    return self.viewModel.inputs.applicationOpenUrl(application: application,
                                                    url: url,
                                                    sourceApplication: sourceApplication,
                                                    annotation: annotation)
}

internal func application(_ application: UIApplication,
                          didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {

    self.viewModel.inputs.didReceive(remoteNotification: userInfo,
                                     applicationIsActive: application.applicationState == .active)
}

internal func application(_ application: UIApplication,
                          performActionFor shortcutItem: UIApplicationShortcutItem,
                          completionHandler: @escaping (Bool) -> Void) {

    self.viewModel.inputs.applicationPerformActionForShortcutItem(shortcutItem)
    completionHandler(true)
}

25:26 All the inputs — e.g. a URL, a shortcut item, or an [AnyHashable:Any] dictionary — are brought into the view model and decoded into a route, after which an output is sent back to the app delegate requesting a route to a specific place in the app.

26:05 By moving the routing logic into the view model, combining all the inputs, and exposing a navigation route output, the code can be used for complex tests.

26:52 The technique makes a complicated architecture look very understandable.