SwiftUI: Handle Universal Link (Show Specific View and Pass Data)



This content originally appeared on Level Up Coding – Medium and was authored by Itsuki

In my previous article Swift/iOS: Support Universal Link (Host/Test Locally & on AWS), we have added support for universal link to our app. If all you need is to have your user tap on the link and open the app, you can call it today!

I want a little more.

In this article, we will see how we can

  • navigate to a specific view on universal link tapped, and
  • pass simple data from the link to the view!

I have uploaded the demo project to GitHub! Feel free to grab it!

Jumping right onto it!

Overview

First of all, let me give you an overview of the example we will be doing here so that you get a grasp of the general idea!

We will be doing a simple example where we will have two routes in our web application.

  • /: This will be the root page corresponding to the IndexView within our App.
  • /items. We will take our user to the ItemsView if the link is tapped here. In addition, we will also have an option of specifying the item id using a query parameter, for example, /items?id=1. If specified, we will pass that id (our simple data) to our ItemsView also!

If the user tap the link on any pages, we will also take them to our IndexView.

Prerequisite

This article assumes that you have already set up your app to support universal link. That is you should already be able to open the link in a web page and see that open in xxx App option showing up.

If not, please feel free check out my previous article Swift/iOS: Support Universal Link for more details!

I have also uploaded the code on GitHub for local hosting and testing. Follow the README to set it up. (Can literally be done in 3 seconds!)

You can confirm that the link is indeed working by simply entering it into Safari to test it out.

Basic Idea of Handling Universal Link

First of all, let’s take a look at what Apple says

If your app has opted into Scenes, and your app is not running, the system delivers the universal link to the scene(_:willConnectTo:options:) delegate method after launch, and to scene(_:continue:) when the universal link is tapped while your app is running or suspended in memory.

And this not true! For a SwiftUI App!

To handle universal link in SwiftUI app, there are two cases we need to consider

  • App is killed (that is in background app state)
  • App is inactive

Let’s check it out 1 by 1!

Add SceneDelegate

To handle universal links tapped when our app is killed, we will be doing it through Scene Delegate.

And Obviously, we don’t have it built-in for a SwiftUI App.

I will be adding my SceneDelegate by adding an additional AppDelegate, but you can also do it without AppDelegate by adding an entry to your info.plist. I have shared more detail about SwiftUI: Add App Delegate/Scene Delegate and Pass Data to Views previously, so please feel free to check it out!

Create SceneDelegate

Let’s first create our custom SceneDelegate class inhering from UIResponder and adopting the UIWindowSceneDelegate protocol. I have also had it conform to ObservableObject in addition so that we can pass data from it to our view with ease!


import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate, ObservableObject {
//...
}

We will be adding more implementations to it later!

Add AppDelegate

All we are doing here in our custom AppDelegate is to assign our SceneDelegate to the UIScene.

import SwiftUI

class AppDelegate: NSObject, UIApplicationDelegate {

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let config = UISceneConfiguration(name: nil,
sessionRole: connectingSceneSession.role)
config.delegateClass = SceneDelegate.self
return config
}
}

We can then make our App use our custom AppDelegate by using the UIApplicationDelegateAdaptor, a property wrapper type to use to create a UIKit app delegate.

import SwiftUI

@main
struct universalLinkDemoApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
WindowGroup {
ContentView()
}
}
}

Note! We will not need to manually injected our SceneDelegate using .environmentObject. This is done for us (on the back) and we will be able to access it just like we would for any other EnvironmentObject.

Handle Link Tapped in Background (App Task killed)

When user tapped on our link when the app is killed, the system delivers the universal link to the scene(_:willConnectTo:options:) delegate method. Let’s add that to our SceneDelegate.


class SceneDelegate: UIResponder, UIWindowSceneDelegate, ObservableObject {

func scene(_ scene: UIScene, willConnectTo
session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {

//.. handling
}
}

We can then extract the incomingURL from connectionOptions.userActivities like following.

guard let userActivity = connectionOptions.userActivities.first,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL else {
return
}

UniversalLinkManager for URL Handling

Now, let’s create a UniversalLinkManager Class to help us process our URL.

import SwiftUI

class UniversalLinkManager {

enum Route: Equatable {

case index
case items(_ id: Int?)

var path: String {
switch self {
case .index:
return "/index"
case .items(id: _):
return "/items"
}
}

static func == (lhs: Route, rhs: Route) -> Bool{
return lhs.path == rhs.path
}
}


static func handleUniversalLink(url: URL) -> Route {
guard let components = NSURLComponents(url: url, resolvingAgainstBaseURL: true), let path = components.path else {
return .index
}

var itemsId: Int? = nil
if let queryItems = components.queryItems, let id = queryItems.first(where: { $0.name == "id" })?.value, let idInt = Int(id) {
itemsId = idInt
}

guard path == Route.items(itemsId).path else { return .index }

if path == Route.items(itemsId).path {
return .items(itemsId)
} else {
return .index
}
}
}

Pretty straight forward.

  • Extracting the path and query from our URL.
  • Trying to match it to one of the Route defined.
  • If no match found, returning the default .index.

Publishing from SceneDelegate

Let’s head back to our SceneDelegate to use the function we have created above.

We will be adding a Published var route here so that we can show view based on the value of it.


class SceneDelegate: UIResponder, UIWindowSceneDelegate, ObservableObject {
@Published var route: UniversalLinkManager.Route = .index

func scene(_ scene: UIScene, willConnectTo
session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {

print("connectionOptions.userActivities: \(connectionOptions.userActivities)")
guard let userActivity = connectionOptions.userActivities.first,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL else {
return
}

DispatchQueue.main.async {
self.route = UniversalLinkManager.handleUniversalLink(url: incomingURL)
}
}

}

Views

Let’s add our IndexView and ItemsView so that we can test our logic above really quick.


import SwiftUI

struct IndexView: View {
var body: some View {
(Text("Index View! ") + Text(Image(systemName: "fireworks")))
.font(.system(size: 24))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineSpacing(10)
.padding()
.background(RoundedRectangle(cornerRadius: 16).fill(.black))
.padding(.all, 30)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.background(.gray.opacity(0.3))
}
}


struct ItemsView: View {
var itemsId: Int?

var body: some View {
VStack(spacing: 40) {
(Text("Items View! ") + Text(Image(systemName: "fireworks")))
.font(.system(size: 24))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineSpacing(10)
.padding()
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 16).fill(.black))

if let itemsId = itemsId {
Text("ID: \(itemsId)")
.font(.system(size: 24))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineSpacing(10)
.padding()
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 16).fill(.black))
}

}
.fixedSize()
.padding(.all, 30)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.background(.gray.opacity(0.3))
}
}

And in our ContentView, we will be checking which route we are at and showing views accordingly.


import SwiftUI

struct ContentView: View {
@EnvironmentObject var sceneDelegate: SceneDelegate

var body: some View {
ZStack {
if case let .items(id) = sceneDelegate.route {
ItemsView(itemsId: id)
} else {
IndexView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.background(.gray.opacity(0.3))
}
}

Time to Run the App (And Kill it so that it is in background state when we tap on the link)!

Handle Link Tapped in Inactive State

To handle universal link tapped when our app is in inactive state, we will be using the onOpenURL view modifier.

Now where should we add this modifier?

We are showing our views based on the value of SceneDelegate.route for background link handling so it will be a good idea to just reuse that. To do so, we will need to access our sceneDelegate which is not available until within our ContentView. That is we do NOT have access to it yet (at least I didn’t find a way to) in our App struct.

struct ContentView: View {
@EnvironmentObject var sceneDelegate: SceneDelegate

var body: some View {
ZStack {
// ...
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.background(.gray.opacity(0.3))
.onOpenURL(perform: { (universalLink: URL) in
sceneDelegate.onContinueBrowsingWebUserActivity(url: universalLink)
})
}
}

And let’s add the following function to our SceneDelegate for handling the URL we got from our onOpenURL closure.

func onContinueBrowsingWebUserActivity(url: URL) {
DispatchQueue.main.async {
self.route = UniversalLinkManager.handleUniversalLink(url: url)
}
}

That’s it! That’s all we have to add to handle universal link tapped when our app is in inactive state.

Let’s test it out! Don’t have to Kill our App anymore! Yeah!

Quick Note

One thing I would like to point out here.

You might want to use onContinueUserActivity instead to handle Link tapped in Inactive State but unfortunately, this function will not be called for NSUserActivityTypeBrowsingWeb (that is for universal link tapped) due to SwiftUI passes a Universal Link to the app directly as a URL.

Thank you for reading!

That’s all I have for today! Again, please feel free to grab the demo project from GitHub!

Happy tapping!


SwiftUI: Handle Universal Link (Show Specific View and Pass Data) was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.


This content originally appeared on Level Up Coding – Medium and was authored by Itsuki