How do I use SFSafariViewController with SwiftUI?

Viewed 15568

I'm trying to present a SFSafariViewController from a NavigationButton but I'm not sure how to do that with SwiftUI.

In UIKit, I would just do:

let vc = SFSafariViewController(url: URL(string: "https://google.com"), entersReaderIfAvailable: true)
    vc.delegate = self

    present(vc, animated: true)
7 Answers

Supplemental to Matteo Pacini post, .presentation(Modal()) was removed by iOS 13's release. This code should work (tested in Xcode 11.3, iOS 13.0 - 13.3):

import SwiftUI
import SafariServices

struct ContentView: View {
    // whether or not to show the Safari ViewController
    @State var showSafari = false
    // initial URL string
    @State var urlString = "https://duckduckgo.com"

    var body: some View {
        Button(action: {
            // update the URL if you'd like to
            self.urlString = "https://duckduckgo.com"
            // tell the app that we want to show the Safari VC
            self.showSafari = true
        }) {
            Text("Present Safari")
        }
        // summon the Safari sheet
        .sheet(isPresented: $showSafari) {
            SafariView(url:URL(string: self.urlString)!)
        }
    }
}

struct SafariView: UIViewControllerRepresentable {

    let url: URL

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

    func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext<SafariView>) {

    }

}

SFSafariViewController is a UIKit component, hence you need to make it UIViewControllerRepresentable.

See Integrating SwiftUI WWDC 19 video for more details on how to bridge UIKit components to SwiftUI.

struct SafariView: UIViewControllerRepresentable {

    let url: URL

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

    func updateUIViewController(_ uiViewController: SFSafariViewController,
                                context: UIViewControllerRepresentableContext<SafariView>) {

    }

}

A note of warning: SFSafariViewController is meant to be presented on top of another view controller, not pushed in a navigation stack.

It also has a navigation bar, meaning that if you push the view controller, you will see two navigation bars.

enter image description here

It seems to work - though it's glitchy - if presented modally.

struct ContentView : View {

    let url = URL(string: "https://www.google.com")!

    var body: some View {
        EmptyView()
        .presentation(Modal(SafariView(url:url)))
    }
}

It looks like this:

enter image description here

I suggest porting WKWebView to SwiftUI via the UIViewRepresentable protocol, and use it in its stead.

Using BetterSafariView, you can present SFSafariViewController easily in SwiftUI. It works well as Apple intended, without losing its original push transition and swipe-to-dismiss gesture.

Usage

.safariView(isPresented: $presentingSafariView) {
    SafariView(url: URL("https://github.com/")!)
}

Example

import SwiftUI
import BetterSafariView

struct ContentView: View {
    
    @State private var presentingSafariView = false
    
    var body: some View {
        Button("Present SafariView") {
            self.presentingSafariView = true
        }
        .safariView(isPresented: $presentingSafariView) {
            SafariView(
                url: URL(string: "https://github.com/stleamist/BetterSafariView")!,
                configuration: SafariView.Configuration(
                    entersReaderIfAvailable: false,
                    barCollapsingEnabled: true
                )
            )
        }
    }
}

Sometimes the answer is to just not use SwiftUI! This is so well supported in UIKit that I just make an easy bridge to UIKit so I can call the SafariController in a single line from SwiftUI like so:

HSHosting.openSafari(url:URL(string: "https://hobbyistsoftware.com")!)

I just replace UIHostingController at the top level of my app with HSHostingController

(note - this class also allows you to control the presentation style of modals)

//HSHostingController.swift
import Foundation
import SwiftUI
import SafariServices

class HSHosting {
    static var controller:UIViewController?
    static var nextModalPresentationStyle:UIModalPresentationStyle?

    static func openSafari(url:URL,tint:UIColor? = nil) {
        guard let controller = controller else {
            preconditionFailure("No controller present. Did you remember to use HSHostingController instead of UIHostingController in your SceneDelegate?")
        }

        let vc = SFSafariViewController(url: url)  

        vc.preferredBarTintColor = tint
        //vc.delegate = self

        controller.present(vc, animated: true)
    }
}

class HSHostingController<Content> : UIHostingController<Content> where Content : View {

    override init(rootView: Content) {
        super.init(rootView: rootView)

        HSHosting.controller = self
    }

    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {

        if let nextStyle = HSHosting.nextModalPresentationStyle {
            viewControllerToPresent.modalPresentationStyle = nextStyle
            HSHosting.nextModalPresentationStyle = nil
        }

        super.present(viewControllerToPresent, animated: flag, completion: completion)
    }

}

use HSHostingController instead of UIHostingController in your scene delegate like so:

    // Use a HSHostingController as window root view controller.
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)

        //This is the only change from the standard boilerplate
        window.rootViewController = HSHostingController(rootView: contentView)

        self.window = window
        window.makeKeyAndVisible()
    }

then when you want to open SFSafariViewController, just call:

HSHosting.openSafari(url:URL(string: "https://hobbyistsoftware.com")!)

for example

Button(action: {
    HSHosting.openSafari(url:URL(string: "https://hobbyistsoftware.com")!)
}) {
    Text("Open Web")
}

update: see this gist for extended solution with additional capabilities

Here is the answer if using a WKWebView, but again it still doesn't look right.

struct SafariView: UIViewRepresentable {

    let url: String

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView(frame: .zero)
    }

    func updateUIView(_ view: WKWebView, context: Context) {
        if let url = URL(string: url) {
            let request = URLRequest(url: url)
            view.load(request)
        }
    }
}

It's possible in SwiftUI, even preserving the default appearance, but you need to expose a UIViewController to work with. Start with defining a SwiftUI UIViewControllerRepresentable that is passed a boolean binding and an activation handler:

import SwiftUI

struct ViewControllerBridge: UIViewControllerRepresentable {
    @Binding var isActive: Bool
    let action: (UIViewController, Bool) -> Void

    func makeUIViewController(context: Context) -> UIViewController {
        return UIViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        action(uiViewController, isActive)
    }
}

Then, give the widget you plan to show the SafariVC from a state property determining if it should be shown, then add this bridge to show the VC when the state changes.

struct MyView: View {
    @State private var isSafariShown = false

    var body: some View {
        Button("Show Safari") {
            self.isSafariShown = true
        }
        .background(
            ViewControllerBridge(isActive: $isSafariShown) { vc, active in
                if active {
                    let safariVC = SFSafariViewController(url: URL(string: "https://google.com")!)
                    vc.present(safariVC, animated: true) {
                        // Set the variable to false when the user dismisses the safari VC
                        self.isSafariShown = false
                    }
                }
            }
            .frame(width: 0, height: 0)
        )
    }
}

Note that I give the ViewControllerBridge a fixed frame with a zero width and height, that means you can put this anywhere in your view hierarchy and it won't cause any significant change to your UI.

--Jakub

None of above is working for me because I was trying to present the SFSafariViewController in a list of buttons. I ended up using a binding.

Make the binding in your view controller:

class YourViewController: UIViewController {
    private lazy var guideHostingViewController = UIHostingController(rootView: UserGuideView(presentUrl: presentBinding))

    @objc private func showWebsite() {
        let navVC = UINavigationController(rootViewController: guideHostingViewController)
        present(navVC, animated: true, completion: nil)
    }
    
    private var presentBinding: Binding<URL> {
        return Binding<URL>(
            get: {
                return URL(string: "https://www.percento.app")!
            },
            set: {
                self.guideHostingViewController.present(SFSafariViewController(url: $0), animated: true, completion: nil)
            }
        )
    }
}

The SwiftUI list:

struct UserGuideView: View {
    private let guidePages: [SitePage] = [.multiCurrency, .stockSync, .dataSafety, .privacy]
    @Binding var presentUrl: URL

    var body: some View {
        VStack(alignment: .leading) {
            ForEach(guidePages) { page in
                Button(action: {
                    presentUrl = page.localiedContentUrl
                }) {
                    Text(page.description)
                        .foregroundColor(Color(UIColor.label))
                        .modifier(UserGuideRowModifier(icon: .init(systemName: page.systemIconName ?? "")))
                }
            }
            Spacer()
        }
        .padding()
        .navigationBarTitle(NSLocalizedString("User Guide", comment: "User Guide navigation bar"))
    }
}
Related