Best Practices for Detecting and Opening URLs in SwiftUI

fatbobman ( 东坡肘子)
9 min readMar 19, 2023

--

Photo by Steve Johnson on Unsplash

In this article, we will introduce several ways to open URLs in SwiftUI views. Other contents include how to automatically recognize and convert clickable links in text, and how to customize behaviors before and after opening URLs.

The original article was written in Chinese and published on my blog Fatbobman’s Blog.

The sample code in this article was completed in Swift Playgrounds 4.1 (macOS version) and can be downloaded from here. For more information about Swift Playgrounds, please refer to the article Swift Playgrounds 4: Entertainment or Productivity.

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

SwiftUI 1.0 (iOS 13, Catalina)

In the view, developers usually need to handle two different cases of opening URLs:

  • Click a button (or similar widget) to open a specified URL
  • Convert part of the content in text into a clickable area and open a specified URL when clicked

Unfortunately, in the 1.0 era, SwiftUI was still quite immature and did not provide any native methods to deal with the above two scenarios.

For the first scenario, the common practice is:

// iOS
Button("Wikipedia"){
UIApplication.shared.open(URL(string:"https://www.wikipedia.org")!)
}

// macOS
Button("Wikipedia"){
NSWorkspace.shared.open(URL(string:"https://www.wikipedia.org")!)
}

As for the second scenario, it is quite troublesome to implement and requires wrapping UITextView (or UILabel) and working with NSAttributedString. At this time, SwiftUI is only treated as a layout tool.

SwiftUI 2.0( iOS 14、Big sur )

SwiftUI 2.0 has brought us a native solution for the first scenario, but unfortunately, the second scenario still can’t be handled natively.

openURL

openURL is a new EnvironmentValue in SwiftUI 2.0 that serves two purposes:

  • It allows us to open a URL by calling its callFunction method.

Now, we can use openURL directly in a Button, which was only possible in SwiftUI 1.0 by calling APIs from other frameworks.

struct Demo: View {
@Environment(\.openURL) private var openURL // Import the environment value.

var body: some View {
Button {
if let url = URL(string: "https://www.example.com") {
openURL(url) { accepted in // By setting a completion closure, we can check if the URL has been opened or not. The state is provided by OpenURLAction.
print(accepted ? "Success" : "Failure")
}
}
} label: {
Label("Get Help", systemImage: "person.fill.questionmark")
}
}
}
  • It allows us to customize the behavior of opening a link through openURL by providing an OpenURLAction (details below).

Link

SwiftUI 2.0 provides a Link control that combines Button and openURL, further simplifying our code:

Link(destination: URL(string: "//feedback@fatbobman.com")!, label: {
Image(systemName: "envelope.fill")
Text("Send Email")
})

SwiftUI 3.0 (iOS 15, Monterey)

In the era of 3.0, with the enhanced capabilities of Text and the introduction of AttributedString, SwiftUI has finally addressed another shortcoming - making parts of text clickable to open specific URLs.

Automatically Recognize URLs in LocalizedStringKey

When creating a Text using the LocalizedStringKey initializer, URLs in the text (webpage or email addresses, etc.) are automatically recognized and can be clicked to open the corresponding URL, without any extra configuration needed.

Text("www.wikipedia.org 13900000000 feedback@fatbobman.com") // using the initializer with LocalizedStringKey parameter type

This method can only recognize network addresses, so phone numbers in the code cannot be automatically recognized.

Note that in the following code, using the initializer with a String parameter type, Text cannot automatically recognize URLs in the content:

let text = "www.wikipedia.org 13900000000 feedback@fatbobman.com" // type is String
Text(text) // initializer with String parameter type does not support automatic URL recognition

Recognize URL tags in Markdown syntax

With SwiftUI 3.0's Text, when the content type is LocalizedStringKey, Text can parse some Markdown syntax tags:

Text("[Wikipedia](https://www.wikipedia.org) ~~Hi~~ [13900000000](tel://13900000000)")

In this way, we can use any type of URI (not limited to network URLs), such as the phone number in the code.

AttributedString with link information

At WWDC 2021, Apple introduced a value type version of NSAttributedString called AttributedString, which can be directly used in Text. By setting different attributes for the text at different positions in the AttributedString, the function of opening URLs in Text can be achieved.

let attributedString:AttributedString = {
var fatbobman = AttributedString("肘子的 Swift 记事本")
fatbobman.link = URL(string: "https://www.fatbobman.com")!
fatbobman.font = .title
fatbobman.foregroundColor = .green // link 不为 nil 的 Run,将自动屏蔽自定义的前景色和下划线
var tel = AttributedString("电话号码")
tel.link = URL(string:"tel://13900000000")
tel.backgroundColor = .yellow
var and = AttributedString(" and ")
and.foregroundColor = .red
return fatbobman + and + tel
}()

Text(attributedString)

For more information about AttributedString, please refer to AttributedString — not just for prettier text.

Recognize URL information in strings and convert them to AttributedString

Among the three use cases mentioned above, only Use Case 1 can automatically recognize web addresses in text, while the other two use cases require developers to explicitly add URL information in some way.

Developers can use the combination of NSDataDetector and AttributedString to automatically recognize different types of content in text, and set corresponding URLs, just like the system information, email, and WeChat apps.

NSDataDetector is a subclass of NSRegularExpression that can detect semi-structured information in natural language text, such as dates, addresses, links, phone numbers, transportation information, and other content. It is widely used in various system applications provided by Apple.

let text = "https://www.wikipedia.org 13900000000 feedback@fatbobman.com"
// Set the types to be recognized
let types = NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.phoneNumber.rawValue
// Create the detector
let detector = try! NSDataDetector(types: types)
// Get the recognition results
let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.count))
// Process the check results one by one
for match in matches {
if match.resultType == .date {
...
}
}

You can think of NSDataDetector as a highly complex regular expression wrapper suite.

The complete code is as follows:

extension String {
func toDetectedAttributedString() -> AttributedString {

var attributedString = AttributedString(self)

let types = NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.phoneNumber.rawValue

guard let detector = try? NSDataDetector(types: types) else {
return attributedString
}

let matches = detector.matches(in: self, options: [], range: NSRange(location: 0, length: count))

for match in matches {
let range = match.range
let startIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: range.lowerBound)
let endIndex = attributedString.index(startIndex, offsetByCharacters: range.length)
// Set the url for links
if match.resultType == .link, let url = match.url {
attributedString[startIndex..<endIndex].link = url
// If it's an email, set the background color
if url.scheme == "mailto" {
attributedString[startIndex..<endIndex].backgroundColor = .red.opacity(0.3)
}
}
// Set the url for phone numbers
if match.resultType == .phoneNumber, let phoneNumber = match.phoneNumber {
let url = URL(string: "tel:\(phoneNumber)")
attributedString[startIndex..<endIndex].link = url
}
}
return attributedString
}
}

Text("https://www.wikipedia.org 13900000000 feedback@fatbobman.com".toDetectedAttributedString())

Customizing the color of links in Text

Unfortunately, even if we have set the foreground color for AttributedString, when the link attribute of a piece of text is non-nil, Text will automatically ignore its foreground color and underline settings, and use the system’s default link rendering settings to display it.

Currently, you can change the color of all links in Text by setting the tint:

Text("www.wikipedia.org 13900000000 feedback@fatbobman.com")
.tint(.green)

Link("Wikipedia", destination: URL(string: "https://www.wikipedia.org")!)
.tint(.pink)

Compared to the fixed style of links in Text, you can create link buttons with customizable appearance using Button or Link:

Button(action: {
openURL(URL(string: "https://www.wikipedia.org")!)
}, label: {
Circle().fill(.angularGradient(.init(colors: [.red,.orange,.pink]), center: .center, startAngle: .degrees(0), endAngle: .degrees(360)))
})

Customizing the behavior of openURL

In Button, we can add logical code in the closure to customize the behavior before and after opening the URL.

Button("Open webpage") {
if let url = URL(string: "https://www.example.com") {
// Behavior before opening URL
print(url)
openURL(url) { accepted in // Define the behavior after clicking the URL by setting the completion closure
print(accepted ? "Open success" : "Open failure")
}
}
}

However, in Link and Text, we need to provide a way to handle OpenURLAction code by providing the openURL environment value to customize the behavior of opening links.

Text("Visit [Example Company](https://www.example.com) for details.")
.environment(\.openURL, OpenURLAction { url in
handleURL(url)
return .handled
})

The structure of OpenURLAction is as follows:

public struct OpenURLAction {
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public init(handler: @escaping (URL) -> OpenURLAction.Result)

public struct Result {
public static let handled: OpenURLAction.Result // The URL has been handled by the current code, and the call behavior will not be passed down
public static let discarded: OpenURLAction.Result // The current handling code will discard the URL, and the call behavior will not be passed down
public static let systemAction: OpenURLAction.Result // The current code does not handle it, and the call behavior will be passed down (if there is no user-defined OpenURLAction on the outer layer, the system's default implementation will be used)
public static func systemAction(_ url: URL) -> OpenURLAction.Result // The current code does not handle it, and the new URL will be passed down (if there is no user-defined OpenURLAction on the outer layer, the system's default implementation will be used)
}
}

For example:

Text("www.fatbobman.com feedback@fatbobman.com 13900000000".toDetectedAttributedString()) // create three links: https, mailto, tel
.environment(\.openURL, OpenURLAction { url in
switch url.scheme {
case "mailto":
return .discarded // the email will be directly discarded without further processing
default:
return .systemAction // other types of URI will be passed to the next layer (outer layer)
}
})
.environment(\.openURL, OpenURLAction { url in
switch url.scheme {
case "tel":
print("call number \(url.absoluteString)") // print the phone number
return .handled // indicate that it has been handled and will not be passed to the next layer
default:
return .systemAction // other types of URI will not be processed by the current code and will be directly passed to the next layer
}
})
.environment(\.openURL, OpenURLAction { _ in
.systemAction(URL(string: "https://www.apple.com")!) // since we have not set OpenURLAction in the next layer, the system implementation will be called to open the Apple website
})

This processing method using environment values provides developers with great flexibility. In SwiftUI, a similar logic is used for onSubmit. For information about onSubmit, please see SwiftUI TextField Advanced — Events, Focus, Keyboard.

The return results handled and discarded of the handler will both prevent the URL from being passed down, and the difference between them will only be reflected when openURL is explicitly called.

// Definition of callAsFunction
public struct OpenURLAction {
public func callAsFunction(_ url: URL, completion: @escaping (_ accepted: Bool) -> Void)
}

// When `handled`, `accepted` is `true`; when `discarded`, `accepted` is `false`.
openURL(url) { accepted in
print(accepted ? "Success" : "Failure")
}

Combining the above introduction, the following code will implement that after clicking a link, the user can choose to either open the link or copy the link to the clipboard:

struct ContentView: View {
@Environment(\.openURL) var openURL
@State var url: URL?
var show: Binding<Bool> {
Binding<Bool>(get: { url != nil }, set: { _ in url = nil })
}

let attributedString: AttributedString = {
var fatbobman = AttributedString("肘子的 Swift 记事本")
fatbobman.link = URL(string: "https://www.fatbobman.com")!
fatbobman.font = .title
var tel = AttributedString("电话号码")
tel.link = URL(string: "tel://13900000000")
tel.backgroundColor = .yellow
var and = AttributedString(" and ")
and.foregroundColor = .red
return fatbobman + and + tel
}()

var body: some View {
Form {
Section("NSDataDetector + AttributedString") {
// Use NSDataDetector for conversion
Text("https://www.fatbobman.com 13900000000 feedback@fatbobman.com".toDetectedAttributedString())
}
}
.environment(\.openURL, .init(handler: { url in
switch url.scheme {
case "tel", "http", "https", "mailto":
self.url = url
return .handled
default:
return .systemAction
}
}))
.confirmationDialog("", isPresented: show) {
if let url = url {
Button("复制到剪贴板") {
let str: String
switch url.scheme {
case "tel":
str = url.absoluteString.replacingOccurrences(of: "tel://", with: "")
default:
str = url.absoluteString
}
UIPasteboard.general.string = str
}
Button("打开 URL") { openURL(url) }
}
}
.tint(.cyan)
}
}

Summary

Although the main purpose of this article is to introduce several methods to open URLs in SwiftUI views, readers should also feel the continuous progress of SwiftUI in the past three years. It is believed that WWDC 2022 will bring more surprises to developers soon.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--

fatbobman ( 东坡肘子)

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com