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 Links in views.

Link("Go to the main blog", destination: URL(string: "https://fivestars.blog/")!)

Links 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 Links 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 call completion with true
  • .discarded will call completion with false

.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!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all