David Yang
Tips and posts for iOS developers from an iOS developer.
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
isnil
, nothing is displayed byList
. - when it gets a new value,
List
will display a sheet. Here it will be aSafariView
using thelinkPage
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
inupdateUIViewController(_::)
And now everything should work
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.