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 {
}
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": case "\(scheme)://tab2": case "\(scheme)://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.