/ swiftui

SwiftUI and SFSafariViewController

This week I started playing with SwiftUI and tried implementing some basic things: building a List with Sections, creating Custom cells etc. And I wanted then to simply display a SFSafariViewController when selecting an element of my List.

So here's the story.

UIKit and SwiftUI

UIKit is not SwiftUI and SwiftUI is not UIKit.

For now, all the things we were able to do in UIKit are not available directly in SwiftUI. SFSafariViewController is a good example.

In order to re-use some UIKit components in SwiftUI (in this case a UIViewController), we have to wrap it in a UIViewControllerRepresentable SwiftUI View.

Writing our SwiftUI SafariView

So we will create a SwiftUI adaptation of SFSafariViewController and we'll simply name it SafariView.

Here's where to start:

import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    typealias UIViewControllerType = SFSafariViewController

    var url: URL?

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

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

#if DEBUG
struct SafariView_Previews: PreviewProvider {
    static var previews: some View {
        SafariView(url: URL(string: "https://david.y4ng.fr")!)
    }
}
#endif

We have a url property that will be used when instantiated and will be passed to the SFSafariViewController.

So that's a start, now let's see how we can use it.

The web pages I want to display in my SafariView are static, so those URL will be hard-coded in my app, and I only want to display 2 web views: my twitter account and my blog.

To make it clean though, I'll put those values in an enum LinkPage.

import Foundation

enum LinkPage: CaseIterable, Identifiable {
    case twitter
    case blog

    var id: String { url.absoluteString }

    var url: URL {
        switch self {
        case .twitter:
            return URL(string: "https://twitter.com/davidy4ng")!
        case .blog:
            return URL(string: "https://david.y4ng.fr")!
        }
    }

    var title: String {
        switch self {
        case .twitter:
            return "Twitter"
        case .blog:
            return "Blog"
        }
    }

    var value: String {
        switch self {
        case .twitter:
            return "@davidy4ng"
        case .blog:
            return "https://david.y4ng.fr"
        }
    }
}

Note: I'm conforming to Identifiable protocol. This is required for the next step: my LinkPage values will be displayed as a content of a List and needs to be uniquely identified. This is done by the id property implementation here.

I'm also conforming to CaseIterable in order to be able to get the list of all cases using LinkPage.allCases.

import SwiftUI

struct AboutView: View {

    // The LinkPage being displayed through sheet presentation.
    @State var linkPage: LinkPage? = nil

    var body: some View {
        List {
            Section {
                CellValue(title: "Version", value: Bundle.main.appVersion)
                CellValue(title: "Build", value: Bundle.main.buildNumber)
            }
            Section(header: Text("Author")) {
                ForEach(LinkPage.allCases) { linkPage in
                    Button(action: {
                        // setting a new value to self.linkPage
                        // this is used for triggering the SafariView presentation through .sheet()
                        self.linkPage = linkPage
                    }) {
                        CellValue(title: linkPage.title, value: linkPage.value)
                    }
                    .buttonStyle(PlainButtonStyle())
                }
            }
        }
        // Present the SafariView when linkPage gets a new value
        .sheet(item: $linkPage, content: { linkPage in
            SafariView(url: linkPage.url)
        })
        .listStyle(GroupedListStyle())
        .navigationBarTitle("About")
    }
}

How displaying a sheet works

.sheet(::) is used on the List view to display the SafariView. It actually works by using @State variable.

Here, my linkPage property acts as a "state" variable. As soon as its value changes, the state of my content changes:

  • when linkPage is nil, nothing is displayed by List.
  • when it gets a new value, List will display a sheet. Here it will be a SafariView using the linkPage value.
  • as soon as the sheet view is dismissed, linkPage will be reset to nil. This is handled automatically.

But something's not working well here...

If you try this implementation, something is wrong with our implemetation of the SafariView.

Here are the steps to reproduce it:

  • open a first web page
  • dismiss it
  • open the other one

Issue: the new web page is not displayed but the previous one is still.

The thing is, SwiftUI does not actually re-instantiate a new SafariView when we call SafariView(url: url). It re-uses a previous instance if it can.

This is what the following method (which was left empty) was here for in UIViewControllerRepresentable:

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

The fix

What we should do to fix it is to update the URL displayed by our SFSafariViewController.

But... it's actually impossible to update the URL of an existing SFSafariViewController because this parameter can only be passed at initialization.

It would have been great though if we could have just called something like:

safariViewController.url = url

Since we can't and SFSafariViewController is something I absolutely want to use (this is me not wanting to implement a custom web view with all the navigation, sharing and accessibility features, all of that in UIKit because WKWebView is not available in SwiftUI too), a quick fix will be to wrap it under a custom view controller using UIKit. (I know... 😭)

Wrapping SFSafariViewController into a custom one

This is pretty straighforward. Here is my CustomSafariViewController, using a SFSafariViewController as a child view controller.

import UIKit
import SafariServices

final class CustomSafariViewController: UIViewController {
    var url: URL? {
        didSet {
            // when url changes, reset the safari child view controller
            configureChildViewController()
        }
    }

    private var safariViewController: SFSafariViewController?

    override func viewDidLoad() {
        super.viewDidLoad()

        configureChildViewController()
    }

    private func configureChildViewController() {
        // Remove the previous safari child view controller if not nil
        if let safariViewController = safariViewController {
            safariViewController.willMove(toParent: self)
            safariViewController.view.removeFromSuperview()
            safariViewController.removeFromParent()
            self.safariViewController = nil
        }

        guard let url = url else { return }

        // Create a new safari child view controller with the url
        let newSafariViewController = SFSafariViewController(url: url)
        addChild(newSafariViewController)
        newSafariViewController.view.frame = view.frame
        view.addSubview(newSafariViewController.view)
        newSafariViewController.didMove(toParent: self)
        self.safariViewController = newSafariViewController
    }
}

With this custom class, we will instantiate a new SFSafariViewController and add it as child view controller as soon as the url property changes.

Let's now rework our SwiftUI SafariView.

import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    typealias UIViewControllerType = CustomSafariViewController

    var url: URL?

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

    func updateUIViewController(_ safariViewController: CustomSafariViewController, context: UIViewControllerRepresentableContext<SafariView>) {
        safariViewController.url = url
    }
}

Notice the changes:

  • our View now represents a CustomSafariViewController
  • we simply instantiate our view controller in makeUIViewController(:)
  • we set its url in updateUIViewController(_::)

And now everything should work

Demonstration

Conclusion

SwiftUI is still young though and lack features, but it's already evolving a lot. Some UIKit components clearly need some rework to be fully compatible with SwiftUI, but nothing impossible as of now. And... we can always write UIKit code in our apps.

I guess my deception was more in wanting to write full SwiftUI feature but ending up having to write UIKit code to achieve something which should be simple.

But there's no doubt all of these will be improved with time, and hopefully we will get a SwiftUI version of SFSafariViewController. 🤞

Now I'll go back to playing with SwiftUI.

SwiftUI and SFSafariViewController
Share this

Subscribe to David Yang