Deep linking and URL scheme in iOS

Opening an app from an URL is such a powerful iOS feature. Its drives users to your app, and can create shortcuts to specific features. This week, we’ll dive into deep linking on iOS and how to create an URL scheme for your app.

When we talk about deep linking for mobile app, it means creating a specific URL to open a mobile application. It separate into two formats:

  • a custom URL scheme that your app registers: scheme://videos
  • a universal link that opens your app from a registered domain: mydomain.com/videos

Today, we’ll focus on the former one.

I’ll mostly focus on the code for an UIKit implementation but I’ll also briefly cover SwiftUI one if that’s what you’re looking for too.

Setting up URL Scheme

Setting up an custom URL scheme for iOS is same regardless you are using SwiftUI or UIKit. In Xcode, under your project configuration, select your target and navigates to Info tab. You’ll see an URL Types section at the bottom.

deep-linking-url-scheme

Clicking +, I can create a new type. For the identifier, I often reuse the app bundle. For the URL Schemes, I would suggest to use the app name (or shortened) to be as short as possible. It shouldn’t include any custom character. For the example, I’ll use deeplink.

That’s it. The app is ready to recognize the new URL, now we need to handle it when we receive one.

SwiftUI deep linking.

If you don’t have any AppDelegate and SceneDelegate files, which is most of the case for SwiftUI implementation, we don’t have much work to do.

In the App implementation, we can capture the url open from onOpenURL(perform:) action.

import SwiftUI

@main
struct DeeplinkSampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    print(url.absoluteString)
                }
        }
    }
}

To test it, I can install the app on a simulator and launch the given url from the Terminal app

xcrun simctl openurl booted "deeplink://test"

Pretty cool! Let’s look how UIKit implementation is different.

On paper, UIKit or SwiftUI shouldn’t make a difference in the way we handle deep linking. However, it mostly falls down to having an AppDelegate or SceneDelegate which are more common for UIKit apps.

For older apps that only have AppDelegate, the app captures the deeplink opening from the following method.

extension AppDelegate {

    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool {

        print(url.absolueString)
        return true
    }
}

The function return a Boolean if the app can handle that given url.

For newer apps that includes SceneDelegate, the callback will be there. It’s important to note that the AppDelegate won’t get called, even if you implement it.

extension SceneDelegate {
    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let firstUrl = URLContexts.first?.url else {
            return
        }

        print(firstUrl.absoluteString)
    }
}

In this implementation, we can notice we don’t need anymore to return any result. However, the parameter passed is now a Set<> and not just a URL anymore, it’s to open one or more URLs. I don’t have a use-case where we would have more than URL so I’ll just keep one for the moment.

The same way as earlier, we can install the app on our simulator and try to see if all is setup correctly. We should see print our deeplink URL.

xcrun simctl openurl booted "deeplink://test"

Once it’s setup, the idea is to create routes to identify and open the right screen. Let’s dive in.

The idea is pretty simple, for a given link, we need to identify what user journey or screen we should open. Since they can be many features across the app, and because we want to avoid a massive switch case to handle it, we’ll be smarter and divide to conquer.

For this example, let’s image we have a video editing app. They are 3 main tabs, to edit a new video, to list the videos edited, then an account page with different app and user information.

We can think of three main paths

  • deeplink://videos/new - start a new video edition journey
  • deeplink://videos - lands on videos listing tab screen
  • deeplink://account - lands on account screen

First, I’ll create a protocol of deeplink handler to define the minimum requirements of any new handlers.

protocol DeeplinkHandlerProtocol {
    func canOpenURL(_ url: URL) -> Bool
    func openURL(_ url: URL)
}

I will also define a DeeplinkCoordinator that will holds on the handlers and find the right one to use. It also returns a Boolean like the AppDelegate has, so we can use in different implementations.


protocol DeeplinkCoordinatorProtocol {
    @discardableResult
    func handleURL(_ url: URL) -> Bool
}

final class DeeplinkCoordinator {
    
    let handlers: [DeeplinkHandlerProtocol]
    
    init(handlers: [DeeplinkHandlerProtocol]) {
        self.handlers = handlers
    }
}

extension DeeplinkCoordinator: DeeplinkCoordinatorProtocol {
    
    @discardableResult
    func handleURL(_ url: URL) -> Bool{
        guard let handler = handlers.first(where: { $0.canOpenURL(url) }) else {
            return false
        }
              
        handler.openURL(url)
        return true
    }
}

Now we can define separate handlers, one for each different path. Let’s start first with the Account journey, the simplest one.

final class AccountDeeplinkHandler: DeeplinkHandlerProtocol {
    
    private weak var rootViewController: UIViewController?
    init(rootViewController: UIViewController?) {
        self.rootViewController = rootViewController
    }
    
    // MARK: - DeeplinkHandlerProtocol
    
    func canOpenURL(_ url: URL) -> Bool {
        return url.absoluteString == "deeplink://account"
    }
    
    func openURL(_ url: URL) {
        guard canOpenURL(url) else {
            return
        }
        
        // mock the navigation
        let viewController = UIViewController()
        viewController.title = "Account"
        viewController.view.backgroundColor = .yellow
        rootViewController?.present(viewController, animated: true)
    }
}

To keep it simple, I only test for the matching url and navigate to the right screen. I also set a background color to see what is my landing. In your case, we can just set the right UIViewController rather than an empty one.

I will do the same for the different video journeys.

final class VideoDeeplinkHandler: DeeplinkHandlerProtocol {
    
    private weak var rootViewController: UIViewController?
    init(rootViewController: UIViewController?) {
        self.rootViewController = rootViewController
    }
    
    // MARK: - DeeplinkHandlerProtocol
    
    func canOpenURL(_ url: URL) -> Bool {
        return url.absoluteString.hasPrefix("deeplink://videos")
    }
    func openURL(_ url: URL) {
        guard canOpenURL(url) else {
            return
        }
        
        // mock the navigation
        let viewController = UIViewController()
        switch url.path {
        case "/new":
            viewController.title = "Video Editing"
            viewController.view.backgroundColor = .orange
        default:
            viewController.title = "Video Listing"
            viewController.view.backgroundColor = .cyan
        }
        
        rootViewController?.present(viewController, animated: true)
    }
}

Now we can inject them into the DeeplinkCoordinator and let it handle the right route. We’ll have two variations, the first one for AppDelegate.

class AppDelegate: UIResponder, UIApplicationDelegate {

    lazy var deeplinkCoordinator: DeeplinkCoordinatorProtocol = {
        return DeeplinkCoordinator(handlers: [
            AccountDeeplinkHandler(rootViewController: self.rootViewController),
            VideoDeeplinkHandler(rootViewController: self.rootViewController)
        ])
    }

    var rootViewController: UIViewController? {
        return window?.rootViewController
    }

    // ...

    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool {
        return deeplinkCoordinator.handleURL(url)
    }
}

And the second one for the SceneDelegate

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    lazy var deeplinkCoordinator: DeeplinkCoordinatorProtocol = {
        return DeeplinkCoordinator(handlers: [
            AccountDeeplinkHandler(rootViewController: self.rootViewController),
            VideoDeeplinkHandler(rootViewController: self.rootViewController)
        ])
    }()

    var rootViewController: UIViewController? {
        return window?.rootViewController
    }

    // ...

    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let firstUrl = URLContexts.first?.url else {
            return
        }

        deeplinkCoordinator.handleURL(firstUrl)
    }

We can test it again the same way we did so far, hoping to land on the right screen (expecting orange background).

xcrun simctl openurl booted "deeplink://videos/new"

deep-linking-ios


To summarize, once the URL scheme was setup, we defined a funnel to capture all the deep links used to open the app and leveraged protocol oriented programming to create multiple implementations of handlers, one for each specific path.

This implementation is extensible for newer path and can easily be unit tested to make sure each parts behaves as expected.

That being said, there could be few improvements, like verifying full path rather than relative one, for safer behavior. The navigation only present but it’s to focus on the handler and not the transition itself.

On a security note, if you also pass parameters within your deeplink, make sure to verify the type and values expected. It could expose different injection vulnerabilities if we’re not careful.

From there, you should have a good understanding of how to use and handle deeplink to open your app and jump to a specific screen. This code is available on Github.

Happy coding πŸŽ‰

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer πŸ‡«πŸ‡·, writing about career development, mobile engineering and self-improvement

ShopBack πŸ’°

Singapore πŸ‡ΈπŸ‡¬