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 theUIViewController
instanceupdateUIViewController
which will be called to update the state of theUIViewController
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.
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:
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!