Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

SFSafariViewController in SwiftUI: Open webpages in-app

SFSafariViewController can be used to let your users open webpages in-app instead of in an external browser. While the view controller works great for UIKit, getting it to work in a SwiftUI app might be challenging.

Whenever you’re running into cases where a UIKit solution is available only, you want to know how to write a wrapper and make the UIKit class available to SwiftUI views. Ideally, it would be reusable so that you can reuse it later. Let’s dive in!

Creating a SwiftUI wrapper for SFSafariViewController

We start the implementation by creating a UIViewRepresentable implementation of SFSafariViewController. The protocol allows us to create a SwiftUI view which wraps the UIKit view controller:

struct SFSafariView: UIViewControllerRepresentable {
    let url: URL

    func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> SFSafariViewController {
        return SFSafariViewController(url: url)
    }

    func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext<SFSafariView>) {
        // No need to do anything here
    }
}

We have to implement two methods:

  • makeUIViewController which will be called to create the UIViewController instance
  • updateUIViewController which will be called to update the state of the UIViewController with new information from SwiftUI

In our case, we don’t have to do more than instantiating the SFSafariViewController with the given URL.

The same technique works for UIView instances, which I’ve explained in my article using UIViewRepresentable to host UIView instances in SwiftUI.

Stay updated with the latest in SwiftUI

The 2nd largest newsletter in the Apple development community with 18,598 developers. Don't miss out – Join today:


Creating a reusable view modifier

I always prefer to write reusable code from the start to allow my code to be reused. I even have a dedicated package for extensions, which I can easily reuse in different apps, allowing me to write apps faster whenever I run into problems I’ve seen before.

In this case, I want a view modifier that catches any links that would normally open in the external browser. These links could be generated as follows in SwiftUI:

struct SwiftUILinksView: View {
    var body: some View {
        VStack(spacing: 20) {
            /// Creating a link using the `Link` View:
            Link("SwiftUI Link Example", destination: URL(string: "https://www.rocketsim.app")!)

            /// Creating a link using markdown:
            Text("Markdown link example: [RocketSim](https://www.rocketsim.app)")
        }
    }
}

The trick is to use the openURL environment property inside an environment view modifier. The code for the view modifier looks as follows:

/// Monitors the `openURL` environment variable and handles them in-app instead of via
/// the external web browser.
private struct SafariViewControllerViewModifier: ViewModifier {
    @State private var urlToOpen: URL?

    func body(content: Content) -> some View {
        content
            .environment(\.openURL, OpenURLAction { url in
                /// Catch any URLs that are about to be opened in an external browser.
                /// Instead, handle them here and store the URL to reopen in our sheet.
                urlToOpen = url
                return .handled
            })
            .sheet(isPresented: $urlToOpen.mappedToBool(), onDismiss: {
                urlToOpen = nil
            }, content: {
                SFSafariView(url: urlToOpen!)
            })
    }
}

We’re using the view modifier to capture any outgoing URLs and use them as an input for our sheet. The sheet will use our earlier created SFSafariView to present the URL in-app using an SFSafariViewController.

Note that we’re making use of another extension that allows to map any optional binding into a boolean binding:

extension Binding where Value == Bool {
    init(binding: Binding<(some Any)?>) {
        self.init(
            get: {
                binding.wrappedValue != nil
            },
            set: { newValue in
                guard newValue == false else { return }

                // We only handle `false` booleans to set our optional to `nil`
                // as we can't handle `true` for restoring the previous value.
                binding.wrappedValue = nil
            }
        )
    }
}

extension Binding {
    /// Maps an optional binding to a `Binding<Bool>`.
    /// This can be used to, for example, use an `Error?` object to decide whether or not to show an
    /// alert, without needing to rely on a separately handled `Binding<Bool>`.
    func mappedToBool<Wrapped>() -> Binding<Bool> where Value == Wrapped? {
        Binding<Bool>(binding: self)
    }
}

It’s one of my favorite extensions I often reuse when writing SwiftUI solutions.

The final missing piece is a convenience view extension to access our logic more easily:

extension View {
    /// Monitor the `openURL` environment variable and handle them in-app instead of via
    /// the external web browser.
    /// Uses the `SafariViewWrapper` which will present the URL in a `SFSafariViewController`.
    func handleOpenURLInApp() -> some View {
        modifier(SafariViewControllerViewModifier())
    }
}

Presenting a SFSafariViewController in SwiftUI

Now that we have all the logic, we can start presenting any outgoing URLs in an SFSafariViewController in SwiftUI. We can do this by using the view extension method on our vertical stack:

struct SwiftUILinksView: View {
    var body: some View {
        VStack(spacing: 20) {
            /// Creating a link using the `Link` View:
            Link("SwiftUI Link Example", destination: URL(string: "https://www.rocketsim.app")!)

            /// Creating a link using markdown:
            Text("Markdown link example: [RocketSim](https://www.rocketsim.app)")
        }
            /// This catches any outgoing URLs.
            .handleOpenURLInApp()
    }
}

Please ensure you know how environment objects are passed through via child views. Altogether, this code allows us to catch any outgoing URLs and present them in-app instead:

You can use SFSafariViewController in SwiftUI after creating a wrapper.

The final result allows you to easily catch any outgoing URLs from anywhere in your code by reusing the view modifier.

Conclusion

You can integrate UIKit views and view controllers like SFSafariViewController by wrapping them using UIViewControllerRepresentable. It’s wise to write reusable solutions whenever you have to write a custom solution to solve a problem in SwiftUI. The final solution allows us to open any outgoing URLs in-app instead of in the external browser.

If you like to improve your SwiftUI knowledge even more, check out the SwiftUI category page. Feel free to contact me or tweet me on Twitter if you have any additional tips or feedback.

Thanks!