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.
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.
UIKit deep link
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.
Deeplink handler implementations
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 journeydeeplink://videos
– lands on videos listing tab screendeeplink://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"
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 🎉