Handling links with SwiftUI's openURL
Attributed strings and Text
have received significant upgrades this year. This upgrade has extended even further in the latest Xcode beta, thanks to the opportunity to set the openURL
environment value.
In this article, let's explore everything around openUrl
and handling links in SwiftUI views.
Introduction
Two views handle URLs in SwiftUI: Link
and, from this year, Text
.
Link
From iOS 14 (and equivalent in other platforms) SwiftUI apps can declare Link
s in views.
Link("Go to the main blog", destination: URL(string: "https://fivestars.blog/")!)
Link
s are buttons in disguise (we can apply button styles), specializing in opening URLs. Their primary purpose is to deep-link into apps from widgets, as no logic can run on widgets (as of iOS 15/macOS 12).
Their use is not limited to widgets. We can use Link
s within apps for various situations/needs:
// Opens URL in Safari
Link("Go to the main blog", destination: URL(string: "https://fivestars.blog/")!)
.buttonStyle(.borderedProminent)
// Opens Settings.app
Link("Go to settings", destination: URL(string: UIApplication.openSettingsURLString)!)
.buttonStyle(.bordered)
// Open third party app
Link("Go to app", destination: URL(string: "bangkok-metro://Sukhumvit%20Line%2FAsok")!)
Text
Text
is one of the SwiftUI views that has received most new functionalities this year, mainly thanks to the new markdown and AttributedString
support.
By using either of these new functionalities, we can now add links to Text
:
var body: some View {
VStack {
Text(attributedString)
Text("Check out [Five Stars](https://fivestars.blog)")
}
}
var attributedString: AttributedString {
var attributedText = AttributedString("Visit website")
attributedText.link = URL(string: "https://fivestars.blog")
return attributedText
}
Similar to Link
, Text
also supports non-http urls.
openURL
When the user interacts with a link (in either a Text
or Link
view), SwiftUI will reach for the view openURL
environment value:
extension EnvironmentValues {
public var openURL: OpenURLAction { get }
}
Where the OpenURLAction
is defined as following:
public struct OpenURLAction {
public func callAsFunction(_ url: URL)
public func callAsFunction(_ url: URL, completion: @escaping (_ accepted: Bool) -> Void)
}
The view will call openURL
with the relevant URL
. The system will then receive and handle the URL, which will either open the default device browser and load a web page, or deep-link into another app.
As openURL
is a public environment value, we can also use it ourselves, replacing legacy's UIApplication
calls:
struct FSView {
@Environment(\.openURL) var openURL // 👈🏻 New way
var body {
...
}
func onOpenURLTap(_ url: URL, completion: @escaping (_ accepted: Bool) -> Void) {
// Old way 👇🏻
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
completion(true)
} else {
completion(false)
}
// New way 👇🏻
openURL(url, completion: completion)
}
}
What's new
New in Xcode 13 beta 5, openURL
can not only be read, but also set:
extension EnvironmentValues {
// public var openURL: OpenURLAction { get } 👈🏻 Previous declaration
public var openURL: OpenURLAction // 👈🏻 New declaration
}
OpenURLAction
has also gained a public initializer:
public struct OpenURLAction {
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public init(handler: @escaping (URL) -> OpenURLAction.Result)
}
Which requires a closure returning a new OpenURLAction.Result
type, defined as following:
public struct Result {
public static let handled: OpenURLAction.Result
public static let discarded: OpenURLAction.Result
public static let systemAction: OpenURLAction.Result
public static func systemAction(_ url: URL) -> OpenURLAction.Result
}
The default behavior stays the same, but this small change enables third-party developers to control the URL handling entirely. Let's have a look at that next.
Handling URLs
The default behavior is equivalent to setting the following openURL
value:
Link("FIVE STARS", destination: URL(string: "https://fivestars.blog/")!)
.environment(\.openURL, OpenURLAction { url in
return .systemAction
})
OpenURLAction.Result
has three more options. Let's have a look at those next.
.handled
.handled
tells SwiftUI that our app logic successfully took care of the url:
Link("FIVE STARS", destination: URL(string: "https://fivestars.blog/")!)
.environment(\.openURL, OpenURLAction { url in
// ...
return .handled
})
Regardless of the URL value, by returning .handled
, the system won't open Safari
or trigger any deep link.
.discarded
.discarded
works exactly like .handled
, but tells SwiftUI that the url couldn't be handled.
Link("FIVE STARS", destination: URL(string: "https://fivestars.blog/")!)
.environment(\.openURL, OpenURLAction { url in
// ...
return .discarded
})
Going back to OpenURLAction
's definition, the difference between .discarded
and .handled
lays on the value passed within the openURL
completion block:
public struct OpenURLAction {
public func callAsFunction(_ url: URL, completion: @escaping (_ accepted: Bool) -> Void)
}
.handled
will callcompletion
withtrue
.discarded
will callcompletion
withfalse
.systemAction(_ url: URL)
The last option is similar to the default .systemAction
behavior, with the difference that we can now forward to a different URL:
Link("Go to Google", destination: URL(string: "https://google.com/")!)
.environment(\.openURL, OpenURLAction { url in
// Go to Bing instead 😈
return .systemAction(URL(string: "https://www.bing.com")!)
})
Custom actions
The hidden power of openURL
is within the .handled
case:
the most common use case has been forwarding users out of the app, but we can now handle different intents.
For example, imagine a welcome screen within an app, prompting the user to either sign in or sign up:
VStack {
Text("Welcome to Five Stars!")
.font(.largeTitle)
Text("Please [sign in](https://fivestars.blog/sign-in) or [sign up](https://fivestars.blog/create-account) to continue.")
.font(.headline)
.environment(\.openURL, OpenURLAction { url in
switch url.lastPathComponent {
case "sign-in":
// show sign in screen
return .handled
case "crate-account":
// show sign up screen
return .handled
default:
// Intent not recognized.
return .discarded
}
})
}
First, Text
has two distinct and interactive parts: this was not possible before the latest SwiftUI iteration.
Despite the new features, I still look forward the day we have a
.onTapGesture(_:)
Text
modifier, FB8917806.
Second, because the URL is now handled within the app, we don't need the whole http
declaration.
We can simplify the code above with the following:
Text("Please [sign in](sign-in) or [sign up](create-account) to continue.")
.font(.headline)
.environment(\.openURL, OpenURLAction { url in
switch url.absoluteString {
case "sign-in":
// show sign in page
return .handled
case "crate-account":
// show sign up page
return .handled
default:
// Intent not recognized.
return .discarded
}
})
Environment value
Unlike onSubmit
's onSubmitAction
environment value, which we covered previously, setting the openURL
environment value always replaces the previous one, which means that only the closest closure to our view will be called.
In the following example, only the closure returning .systemAction
will be triggered:
Link(...)
.environment(\.openURL, OpenURLAction { url in
return .systemAction
})
.environment(\.openURL, OpenURLAction { url in
return .systemAction(URL(string: "https://www.anotherURL.com")!)
})
.environment(\.openURL, OpenURLAction { url in
return .handled
})
.environment(\.openURL, OpenURLAction { url in
return .discarded
})
As of today, there's no way to tell SwiftUI to trigger the "next" closure instead of stopping after the first one.
View Modifier
In SwiftUI we already have a onOpenURL(perform:)
view modifier, used to handle deep-links in the app. This naming is unfortunate, as it would make sense to have a companion onOpenURL(handler:)
modifier for openURL
.
Despite this modifier not being part of the official API, we can still create it ourselves:
extension View {
func onOpenURL(handler: @escaping (URL) -> OpenURLAction.Result) -> some View {
environment(\.openURL, OpenURLAction(handler: handler))
}
}
This extension would, for example, reduce this:
Link("FIVE STARS", destination: URL(string: "https://fivestars.blog/")!)
.environment(\.openURL, OpenURLAction { url in
return .systemAction
})
Into this:
Link("FIVE STARS", destination: URL(string: "https://fivestars.blog/")!)
.onOpenURL(handler: { url in
return .systemAction
})
...which is less verbose and easier on the eye.
Conclusions
We're now at the third SwiftUI iteration, and the SwiftUI team is steadily patching all the essential missing features. Are you going to use openURL
in your apps? Please let me know via email or Twitter!
This article is part of a series exploring new SwiftUI features. We will cover many more during the rest of the summer: subscribe to Five Stars's feed RSS or follow @FiveStarsBlog on Twitter to never miss new content!