Handling deeplinks in iOS 14 with onOpenURL

Published by donnywals on

This article covers beta technology and it's up to date for Xcode 12 beta 3

Starting with iOS 14, we can write apps that are fully built using SwiftUI, dropping the need to have AppDelegate and SceneDelegate files entirely. For as long as I remember, I've handled deeplinks in my AppDelegate and for the past year in the SceneDelegate. So when Apple introduced developers to this new @main annotated App struct style of building apps, I'm sure we all had the same question on our mind. How does the new App struct work with deeplinks and other tasks that are normally performed in the AppDelegate?

Luckily, Apple engineers made sure that handling deeplinks in our apps is still possible with the new onOpenURL(perform:) view modifier.

Handling deeplinks with onOpenURL

The new onOpenURL(perform:) view modifier is new in iOS 14. It allows developers to register a URL handler on their views so they can respond to URLs by modifying state for their views as needed.

This is vastly different from how we're used to dealing with URLs in UIKit and the SceneDelegate flow.

The old way of handling deeplinks requires you to handle each link in the SceneDelegate (or AppDelegate). You would have to manipulate the selected tab in a UITabBarViewController, or present a UIViewController by inspecting the current view controller hierarchy and pushing the needed UIViewController from right inside of the SceneDelegate.

In SwiftUI, you can use the onOpenURL(perform:) on the root of your scene as follows:

@main
struct MyApplication: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
        .onOpenURL { url in
          // handle the URL that must be opened
        }
    }
  }
}

I will cover what it means exactly to handle the url in the next section of this article, but usually it will involve mutating some state to load and display the view associated with the URL that must be opened.

What's really neat is that you can specify multiple onOpenURL handlers throughout your app. This means that you can make multiple, smaller changes to your app state which means that you no longer have one place where all of your deeplink handling and view manipulation takes place.

Furthermore, onOpenURL is called when your app is in the foreground, background or not running at all. This means that there is now a single entry point for your app to handle URLs. Even if your app is relaunched after being force-closed.

In the next section, I will show you an example of how you can select a tab in a TabView depending on the URL that your app is requested to open. After that, I will show you how to navigate to a list item in a view that's embedded in a TabView by adding a second onOpenURL view modifier on a child View that contains a List.

Activating a tab in a TabView when opening a URL

In SwiftUI, views are a function of their state. This means that virtually everything in a SwiftUI application can be represented and manipulated as a data model. This means that we can represent the currently selected tab in a SwiftUI TabView as a property on an App struct.

The following code shows how:

struct MyApplication: App {
  @State var activeTab = 0

  var body: some Scene {
    WindowGroup {
      TabView(selection: $activeTab) {
        HomeView()
          .tabItem {
            VStack {
              Image(systemName: "house")
              Text("Home")
            }
          }
          .tag(0)

        SettingsView()
          .tabItem {
            VStack {
              Image(systemName: "gearshape")
              Text("Settings")
            }
          }
          .tag(1)
      }
      .onOpenURL { url in
        // determine which tab should be selected and update activeTab
      }
    }
  }
}

What's important to notice here is the activeTab property. This property is marked as @State and represents the selected tab in the TabView. When creating the TabView, I pass a binding to activeTab to the TabView's initializer. Setting the TabView up like this means that updating activeTab will cause the TabView to update its selected tab as well.

Notice that I set a tag on the views that are added to the TabView. This tag is used to identify the TabView's items. When activeTab matches one of the tags associated with your views, the TabView will activate the matching tab.

In this case that means setting activeTab to 1 would activate the tab that displays SettingsView.

Let's see how you can implement onOpenURL to figure out and activate the correct tab. To do this, I'm going to introduce an extension on URL, and a new type called TabIdentifier:

enum TabIdentifier: Hashable {
  case home, settings
}

extension URL {
  var isDeeplink: Bool {
    return scheme == "my-url-scheme" // matches my-url-scheme://<rest-of-the-url>
  }

  var tabIdentifier: TabIdentifier? {
    guard isDeeplink else { return nil }

    switch host {
    case "home": return .home // matches my-url-scheme://home/
    case "settings": return .settings // matches my-url-scheme://settings/
    default: return nil
    }
  }
}

The code above is just a convient way to figure out which tab belongs to a URL without having to duplicate logic all over the app. If you decide to implement a similar object, the isDeeplink computed property should be updated according to the URLs you want to support. If you're implementing Universal Links, you'll want to check whether the URL's host property matches your hostname. I've set up a very minimal check here for demonstration purposes where I only care about the URL scheme.

The tabIdentifier property is a computed property that uses the host property to determine which tab should be selected. For a Universal Link you'll probably want to use the pathComponents property and compare using the second entry in that array, depending on your mapping strategy. Again, I set this up to be very basic.

You can use this basic setup in the App struct as follows:

struct MyApplication: App {
  @State var activeTab = TabIdentifier.home

  var body: some Scene {
    WindowGroup {
      TabView(selection: $activeTab) {
        HomeView()
          .tabItem {
            VStack {
              Image(systemName: "house")
              Text("Home")
            }
          }
          .tag(TabIdentifier.home) // use enum case as tag

        SettingsView()
          .tabItem {
            VStack {
              Image(systemName: "gearshape")
              Text("Settings")
            }
          }
          .tag(TabIdentifier.settings) // use enum case as tag
      }
      .onOpenURL { url in
        guard let tabIdentifier = url.tabIdentifier else {
          return
        }

        activeTab = tabIdentifier
      }
    }
  }
}

Because I made TabIdentifier Hashable, it can be used as the activeTab identifier. Each tab in the TabView is associated with a TabIdentifier through their tags, and by reading the new tabIdentifier that I added to URL in my extension, I can easily extract the appropriate tab identifier associated with the URL that I need to open.

As soon as I assign the acquired tabIdentifier to activeTab, the TabView is updated marking the appropriate tab as selected along with displaying the appropriate View.

Of course this is only half of what you'll want to typically do when opening a deeplink. Let's take a look at activating a NavigationLink in a different view next.

Handling a URL by activating the correct NavigationLink in a List

You already know how to activate a tab in a TabView when your app needs to handle a URL. Often you'll also need to navigate to a specific detail page in the view that's shown for the selected tab item. The cleanest way I have found to do this, is by adding a second onOpenURL handler that's defined within the detail view that should activate your navigation link.

When you define multiple onOpenURL handlers, the system will call them all, allowing you to make small, local changes to your view's data model. Like selecting a tab in the App struct, and activating a NavigationLink in a child view. Before I show you how to do this, We'll need another extension on URL to extract the information we need to activate the appropriate NavigationLink in a List:

enum PageIdentifier: Hashable {
  case todoItem(id: UUID)
}

extension URL {
  var detailPage: PageIdentifier? {
    guard let tabIdentifier = tabIdentifier,
          pathComponents.count > 1,
          let uuid = UUID(uuidString: pathComponents[1]) else {
      return nil
    }

    switch tabIdentifier {
    case .home: return .todoItem(id: uuid) // matches my-url-scheme://home/<item-uuid-here>/
    default: return nil
    }
  }
}

I've added a new enum called PageIdentifier. This enum has a single case with an associated value. This associated value represents the identifier of the object that I want to deeplink to. My app is a to-do app, and each to-do item uses a UUID as its unique identifier. This is also the identifier that's used in the deeplink. The approach above is similar to what I've shown in the previous section and if you decide you like my URL parsing approach, you'll have to make some modifications to adapt it in your app.

The next step is to implement the HomeView, and select the appropriate item from its list of items:

struct HomeView: View {
  @StateObject var todoItems = TodoItem.defaultList // this is just a placeholder.  
  @State var activeUUID: UUID?

  var body: some View {
    NavigationView {
      List(todoItems) { todoItem in
        NavigationLink(destination: TodoDetailView(item: todoItem), tag: todoItem.id, selection: $activeUUID) {
          TaskListItem(task: todoItem)
        }
      }
      .navigationTitle(Text("Home"))
      .onOpenURL { url in
        if case .todoItem(let id) = url.detailPage {
          activeUUID = id
        }
      }
    }
  }
}

Notice that my HomeView has a property called activeUUID. This property serves the exact same purpose that activeTab fulfilled in the previous section. It represents the identifier for the item that should be selected.

When creating my NavigationLink, I pass a tag and a binding to activeUUID to each NavigationLink object. When SwiftUI notices that the tag for one of my navigation links matches the activeUUID property, that item is selected and pushed onto the NavigationView's navigation stack. If you already have a different item selected, SwitUI will first go back to the root of your NavigationView (deactivating that link) and then navigate to the selected page.

In onOpenURL I check whether url.detailPage points to a todoItem, and if it does I extract its UUID and set it as the active UUID to navigate to that item.

By definiing two onOpenURL handlers like I just did, I can make small changes to local state and SwiftUI takes care of the rest. Both of these onOpenURL handlers are called when the app is expected to handle a link. This means that it's important for each View to check whether it can (and should) handle a certain link, and to make small changes to the view it belongs to rather than making big app-wide state changes like you would in a UIKit app.

In Summary

This week, you saw how you can use iOS 14's new onOpenURL view modifier to handle open URL requests for your app. You learned that you can define more than one handler, and that onOpenURL is called for all scenarios where your app needs to open a URL. Even if your app is launched after being force closed.

First, I showed you how you can parse a URL to determine which tab in a TabView should be selected. Then I showed you how you can change the active tab in a TabView by tagging your views and passing a selection biding to the TabView's initializer. After that, you saw how you can navigate to a detail view by doing more URL parsing, and passing a tag and binding to your NavigationLink.

I was rather surprised when I learned that deeplink handling on iOS 14 with SwiftUI is this powerful and flexible. Since we can specify onOpenURL handlers wherever they are needed, it's much easier to decouple and compose your app using small parts. You no longer need a SceneDelegate that knows exactly how your entire app is structured because it needs to manipulate your app's entire navigation state from a single place. It feels much cleaner to handle URLs on a local level where the side-effects are limited to the view that's handling the URL.

If you have any comments, questions, or feedback about this post please reach out on Twitter. I love hearing from you.


Practical Combine

Learn everything you need to know about Combine and how you can use it in your projects with my new book Practical Combine. You'll get thirteen chapters, a Playground and a handful of sample projects to help you get up and running with Combine as soon as possible.

The book is available as a digital download for just $24.99!

Get Practical Combine

Receive weekly updates about my posts

Categories: SwiftUI