Deep links are mandatory if you want users engaging with your app. Alongside push notifications, deep links form the technical cornerstone of your user retention strategy: a user gets a notification, taps it, then expects to be brought to the relevant screen in your app.
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.
Full subscribers unlock Quick Hacks, my advanced tips series, and enjoy my long-form articles 3 weeks before anyone else.
In protest of the Stripe crime family’s transaction fees, I’m running a huge (and real) promotion on annual plans—50% cheaper than the monthly price. Grab it this week to lock in savings forever!
you aren’t smashing out greenfield projects on the daily, you might never have set up a deep link handler. You’ll often add new routes at the whims of your product & marketing teams, however the handler itself is built once and injected across your app’s navigation architecture.
Async Algorithms is a bundle of algorithms built on top of Swift Concurrency. They handle sequences of values over time—making it the perfect fit for setting up a reactive, responsive, and ergonomic deep linking infrastructure throughout your app.
Let’s look at how we can leverage Async Algorithms to easily implement deep linking in your projects today.
Can’t wait? Check out my open-source project to see the whole thing in action now.
Async Algorithms
Let’s quickly get to grips with the basics of this package.
Async Algorithms, first released in 2022, brought much of the reactive ergonomics of Combine to the modern world of Swift Concurrency. There’s just one protocol you need to understand to really get to grips with Async Algorithms: AsyncSequence.
AsyncSequence works a lot like the regular Sequence protocol in the Swift Standard Library: it provides an asynchronous version of the Iterator
protocol that offers async iterated access to the next()
element. This iteration can be invoked using the for-await-in
syntax sugar.
For a (very) detailed primer, feel free to read my very detailed tutorial about migrating to the package from Combine.
Migrating Combine to AsyncAlgorithms
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.
Setting up Deep Links
If you haven’t set up deep linking in your apps before, it’s fairly straightforward, only requiring a few steps.
First, create a URL type in your Info.plist. This will tell iOS to map a simple customised URL scheme like linky://
to your full bundle identifier, com.jacob.Linky
and open your app.
In production, you will likely also set up universal links. These are more complex to set up, but work the same way in your app. The mapping to your app ID is provided by the apple-app-site-association file on your web domain.
This is dead simple to test using the Simulator. Just launch the app from Xcode and run this terminal command:
xcrun simctl openurl booted 'linky://'
Then your app should open up handily.
We will be doing a lot of debugging as we build this.
If you want to use breakpoints to debug your deep links, go to the scheme editor and check “wait for the executable to be launched”.
This allows your app to build without launching, so the deep linking terminal command opens the app in debug mode.
Our Architecture
This sample project uses my favoured architecture for mid-size SwiftUI projects, but the Async Algorithms approach for the deep link handler can be easily ported to any iOS app, so don’t worry if you use NavigationStack, TCA, Navigator, or you roll your own custom approach.
In short, I used good old-fashioned UIKit navigation with Coordinators, where my SwiftUI were wrapped in UIHostingControllers. For more on this architecture, check out this article piece based my NSLondon talk:
SwiftUI apps at scale
SwiftUI is fantastic, but not perfect. Some of these imperfections are expected from a nascent 4-year-old framework, but just one creates the roadblock to wider adoption.
The SceneDelegate
One last bit of setup before we go into the Async Algorithms.
Sorry for the long-winded intro! No dev left behind.
The SceneDelegate serves as our entry point for all deep links.
import UIKit
import Factory
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@Injected(\.deepLinkHandler) private var deepLink
var window: UIWindow?
var appCoordinator: AppCoordinator?
}
I’m opinionatedly using Factory to dependency-inject my deep link handler but you can roll whatever you want. You don’t need to understand it for this article.
When the app cold-launches, it will call willConnectTo
. This is where you perform initial app navigation setup and handle any deep links that caused launch inside connectionOptions.urlContexts
.
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
appCoordinator = AppCoordinator(window: window!)
appCoordinator?.start()
if let urlContext = connectionOptions.urlContexts.first {
Task { await deepLink.open(url: urlContext.url) }
}
}
We’ll look into this deepLink.open(url:)
method momentarily.
We also want to add entry-points for deep links using openURLContexts
when the app is already alive:
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let urlContext = URLContexts.first else { return }
Task {
await deepLink.open(url: urlContext.url)
}
}
And also the continue userActivity
flavour when using a universal link:
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard let url = userActivity.webpageURL else { return }
Task {
await deepLink.open(url: url)
}
}
In a pure-SwiftUI app, you may leverage an onOpenURL
modifier high up in your app navigation hierarchy, just inside the window group. Here, you can invoke the deep link handler from the URL in the exact same way:
@main
struct LinkyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
Task {
await deepLink.open(url: url)
}
}
}
}
}
While in active development, it’s often easiest to hardcode a deeplink
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
// ...
// if let urlContext = connectionOptions.urlContexts.first {
// Task { await deepLink.open(url: urlContext.url) }
// }
Task {
await deepLink.open(url: URL(string: "linky://addcontact")!)
}
}
Let’s get started with our deep link handler!
DeepLinks
As with most utilities in Swift, we start with a protocol.
import AsyncAlgorithms
import Foundation
public protocol DeepLinkHandler {
func open(url: URL) async
func stream(_ link: DeepLink) -> AsyncChannel<DeepLink>
}
This can be injected into our SceneDelegate and Coordinators.
We’ve already seen how we open URLs.
The stream(link:)
method is where the magic happens. We set up listeners in each of our coordinators, where each one listens for the relevant links.
DeepLink is an enumeration of every possible deep link we handle in the app.
public enum DeepLink: String, CaseIterable, Sendable {
case favourites = #"/favourites/?$"#
case recents = #"/recents/?$"#
case mostRecent = #"/mostrecent/?$"#
case contacts = #"/contacts/?$"#
case addContact = #"/addcontact/?$"#
fileprivate static func link(from url: URL) -> DeepLink? {
DeepLink.allCases.first(
where: { url.absoluteString ~= $0.rawValue }
)
}
}
We use a simple regex to map URL strings into the enum case.
infix operator ~= : ComparisonPrecedence
static func ~= (lhs: String, rhs: String) -> Bool {
do {
let regex = try NSRegularExpression(pattern: rhs, options: [])
let range = NSRange(lhs.startIndex..<lhs.endIndex, in: lhs)
return regex.firstMatch(in: lhs, options: [], range: range) != nil
} catch {
return false
}
}
Deep Link URL Handler
Now let’s implement our deep linking protocol.
import AsyncAlgorithms
public final class DeepLinkHandlerImpl: DeepLinkHandler {
private let _favouritesStream = AsyncChannel<DeepLink>()
private let _contactsStream = AsyncChannel<DeepLink>()
private let _addContactStream = AsyncChannel<DeepLink>()
private let _recentsStream = AsyncChannel<DeepLink>()
private let _mostRecentStream = AsyncChannel<DeepLink>()
}
AsyncChannel powers our deep linking navigation listeners.
This is an AsyncAlgorithm entity heavily inspired by Combine subjects. It’s a special AsyncSequence which applies back pressure — this means it’ll buffer values. It wait for a value to be consumed downstream before calling the next()
function on its internal iterator.
This makes it perfect for handling our deep link URLs. We can parse out the DeepLink enum case on open(url:)
and forward it to the relevant channel.
public func open(url: URL) async {
guard let link = DeepLink.link(from: url) else { return }
Task {
switch link {
case .favourites:
await _favouritesStream.send(link)
case .contacts:
await _contactsStream.send(link)
case .addContact:
await _addContactStream.send(link)
case .recents:
await _recentsStream.send(link)
case .mostRecent:
await _mostRecentStream.send(link)
}
}
}
Now, as soon as a valid deep link URL is opened, it passes the DeepLink case to the associated channel.
Deep Link Streaming
These channels serve as private implementation details, allowing the ergonomics of our handler to work entirely generically over the various DeepLink enum cases:
func stream(_ link: DeepLink) -> AsyncChannel<DeepLink>
Now we can implement this last function.
public func stream(_ link: DeepLink) -> AsyncChannel<DeepLink> {
switch link {
case .favourites:
return _favouritesStream
case .contacts:
return _contactsStream
case .addContact:
return _addContactStream
case .recents:
return _recentsStream
case .mostRecent:
return _mostRecentStream
}
}
This performs a simple mapping between the deep link to handle and the relevant async channel.
Why do we need a separate stream for each DeepLink? Why not have a single generic Channel?
This is due to a major weakness in Async Algorithms compared to Combine: it does not support broadcasting or multiplexing. This means that each AsyncChannel can only support a single listener for-await-in loop listening to its values. Their underlying AsyncIterator isn’t designed to have multiple entities calling next().
Fortunately, we can store these internal channels as implementation details and keep our DeepLinkHandler protocol gloriously generic.
The Coordinators
Each of our coordinators handles deep links asynchronously as part of their protcol.
protocol Coordinator: AnyObject {
var parent: Coordinator? { get }
var navigationController: UINavigationController { get set }
func start()
func navigate(to route: Route)
func handleDeepLinks() async
}
So we can handle any links at launch, handleDeepLinks()
is set up immediately on init:
init(navController: UINavigationController, parent: Coordinator?) {
self.navController = navController
self.parent = parent
Task { await handleDeepLinks() }
}
The rest of the navigation logic in our coordinators is pretty cookie-cutter.
final class ContactsCoordinator: Coordinator {
@Injected(\.deepLinkHandler) private var deepLink
enum ContactsRoute: Route {
case contacts
case addContact
}
func navigate(to route: any Route) {
guard let route = route as? ContactsRoute else { return }
navigate(to: route)
}
private func navigate(to route: ContactsRoute) {
parent?.navigate(to: AppCoordinator.AppRoute.contacts)
switch route {
case .contacts:
break
case .addContact:
let vc = SwiftUIViewController(with: AddContactView())
navigationController.present(vc, animated: true)
}
}
}
Setting up the Listeners
How can we handle these deep links? We need to create a listener for every individual route for that coordinator, and handle the relevant navigation logic.
Here’s a simple initial approach that uses a task group to simultaneously handle each listener:
func handleDeepLinks() async {
await withTaskGroup(of: Void.self) { [deepLink, weak self] in
$0.addTask { @MainActor in
for await _ in deepLink.stream(.contacts) {
self?.navigate(to: .contacts)
}
}
$0.addTask { @MainActor in
for await _ in deepLink.stream(.addContact) {
self?.navigate(to: .addContact)
}
}
}
}
This task group is vital—if we simply layered our for-await-in loops linearly, only the first one would ever be called!
I copied this listener logic across all 3 feature coordinators, and ran the terminal command to launch:
xcrun simctl openurl booted 'linky://addcontact'
And it works!
This is a great first step, however I couldn't help but think the ergonomics were hot garbage.
More Async Algorithms
We can make use of another async algorithm to reduce our dependence on task groups and make the code far more linear:
import AsyncAlgorithms
func handleDeepLinks() async {
for await link in merge(deepLink.stream(.recents),
deepLink.stream(.mostRecent)) {
switch link {
case .recents:
navigate(to: .recents)
case .mostRecent:
navigate(to: .mostRecent)
default:
break
}
}
}
Merge combines two or more async sequences (with the same element type) into a single sequence. This means we can use a single for-await-in loop and get all the streams we might require.
Since each stream (and the underlying AsyncChannel iterator) is only accessed in one place, we avoid the dangerous pitfall of broadcasting to multiple listeners. In the use case of deep linking, this actually helps us—we will only ever want to trigger a single navigation destination from each link.
These ergonomics are great, but now I’m accidentally running the deep link listener on a background thread!
There’s a nice simple fix: annotate our coordinator’s navigate(to:)
function with @MainActor.
Finally we change our routing to use await, so the navigation suspends until it can run on the UI thread.
func handleDeepLinks() async {
for await link in merge(deepLink.stream(.recents),
deepLink.stream(.mostRecent)) {
switch link {
case .recents:
await navigate(to: .recents)
case .mostRecent:
await navigate(to: .mostRecent)
default:
break
}
}
}
Trying to be Clever
This is neat, but I was a little sad that import AsyncAlgorithms
was leaked all over our coordinators. I really wanted this to act as an implementation detail.
I wanted to create a utility function that automatically merges streams for multiple deep links, however merge itself does not take a variadic set of arguments.
I created my own, based on the merge function signature:
import AsyncAlgorithms
extension DeepLinkHandler {
func mergeStreams(
_ link1: DeepLink, _ link2: DeepLink
) -> AsyncMerge2Sequence<AsyncChannel<DeepLink>,
AsyncChannel<DeepLink>> {
merge(stream(link1), stream(link2))
}
}
Due to the compounded function signature, a new function would be required for each number of streams to merge.
This could be used in the coordinators as before:
func handleDeepLinks() async {
for await link in deepLink.mergeStreams(.recents, .mostRecent) {
switch link {
case .recents:
await navigate(to: .recents)
case .mostRecent:
await navigate(to: .mostRecent)
default:
break
}
}
}
This refactor worked, but honestly the minor ergonomic gain was not worth the complexity. Merging the streams is clean enough.
Bug Bashing
When setting up this project, I couldn’t actually get the deep links to work for the longest time!
According to breakpoints, open(url:)
and stream(link:)
were being called as expected, but the listeners on the coordinators were never triggered!
Hold on, that wasn’t quite right—upon systematic inspection, the listener on the last tab would work, but none of the others.
My senior-dev spidey-senses began tingling and I checked the memory debugger graph. As expected, there was a unique instance per coordinator!
I told you we wouldn’t go into detail on Factory, but I was screwing up some basic dependency injection.
I was setting up the DeepLinkHandler container like this:
extension Container {
var deepLinkHandler: Factory<DeepLinkHandler> {
Factory(self) { DeepLinkHandlerImpl() }
}
}
This instantiated a unique instance whenever the deep link handler was used. Therefore, the instance in the SceneDelegate where we called open(url:)
was not the same instance where the stream(link:)
listener was setup in the coordinator!
Like all great bugfixes, this was resolved with half a line of code.
extension Container {
var deepLinkHandler: Factory<DeepLinkHandler> {
Factory(self) { DeepLinkHandlerImpl() }.scope(.singleton)
}
}
Once the handler was a singleton, it all worked perfectly.
The one that got away
I’m pretty annoyed we still don’t have broadcasting behaviour in the Async Algorithms package. What I really wanted to do was publish the relevant deep link to each listener while allowing all downstream coordinators to subscribe to the same data stream.
We could set up listeners like this:
for await link in deepLink.stream(.recents, .mostRecent) {
switch link {
case .recents:
navigate(to: .recents)
case .mostRecent:
navigate(to: .mostRecent)
default:
break
}
}
And then create a single private channel in the handler, which supported multiple calls to stream(links:)
.
public protocol DeepLinkHandler {
func open(url: URL)
func stream(_ links: DeepLink...) -> AsyncStream<DeepLink>
}
/// ...
private let deepLinkBroadcastChannel = BroadcastChannel<DeepLink>()
public func stream(_ links: DeepLink...) -> AsyncStream<DeepLink> {
AsyncStream { continuation in
Task {
for await link in deepLinkBroadcastChannel {
if links.contains(link) {
continuation.yield(link)
}
}
}
}
}
Critically, though, as a Combine fanboy, this is highly vindicating: something that would be trivial to build using a PassthroughSubject is unsupported.
Last Orders
Deep linking is mandatory if you want your users engaging with your app.
Once you’ve set up the URL types in your Info.plist, deep links enter your app via the SceneDelegate or an onOpenURL
SwiftUI modifier.
A deep link handler has two parts to its interface: accepting a deep link, and sending the route to the relevant part of your navigation logic.
Async Algorithms allows you to set up single-listener channels for each deep linking route, that can be accessed generically in your navigation code. It also providers a way to merge data streams for each coordinator route in your app to get pretty neat navigation ergonomics.
I quite like this format: “Here’s how to use Framework X to perform Use Case Y.”
I find it a lot more fun to write (and, hopefully, it’s more interesting to read!) than a generic tutorial that’s not pegged to a real-world usage. Let me know what you’d like to see more of in the comments!
Thanks for reading Jacob’s Tech Tavern 🍺
If you enjoyed this, please consider paying me. Full subscribers to Jacob’s Tech Tavern unlock Quick Hacks, my advanced tips series, and enjoy my long-form articles 3 weeks before anyone else.
If you want to see more on Async Algorithms, check out this piece from last Autumn where I migrate a simple app over from Combine.
Migrating Combine to AsyncAlgorithms
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.