How to Present SFSafariViewController from a NavigationButton in SwiftUI: A Step-by-Step Guide
When building iOS apps, you often need to display web content to users. While SwiftUI offers a WebView (via WKWebView integration), Apple’s SFSafariViewController provides a more seamless, secure, and user-friendly experience. It inherits Safari’s features like password auto-fill, reader mode, sharing, and cookie management—all while keeping users within your app’s context.
In this guide, we’ll walk through presenting SFSafariViewController from a NavigationButton in SwiftUI. Since SwiftUI doesn’t natively support SFSafariViewController, we’ll use UIViewControllerRepresentable to bridge UIKit’s functionality into SwiftUI. By the end, you’ll have a working implementation where tapping a button in your app’s navigation bar opens a web page in SFSafariViewController, with full support for dismissal.
Table of Contents#
- Prerequisites
- Step 1: Set Up a New SwiftUI Project
- Step 2: Create a
UIViewControllerRepresentableWrapper forSFSafariViewController - Step 3: Add a
NavigationStackandNavigationButton - Step 4: Present
SFSafariViewControllerUsing a Sheet - Step 5: Handle Dismissal with
SFSafariViewControllerDelegate - Step 6: Test the Implementation
- Troubleshooting Common Issues
- Conclusion
- References
Prerequisites#
Before starting, ensure you have:
- Xcode 14 or later (compatible with iOS 16+).
- Basic familiarity with SwiftUI (e.g.,
@State,NavigationStack, and view presentation). - A valid URL to test (we’ll use
https://example.comfor this tutorial).
Step 1: Set Up a New SwiftUI Project#
First, create a new SwiftUI project in Xcode:
- Open Xcode → Click Create a new project.
- Select iOS → App → Click Next.
- Enter a product name (e.g.,
SafariDemo), choose SwiftUI for the interface, and click Create.
Step 2: Create a UIViewControllerRepresentable Wrapper for SFSafariViewController#
SFSafariViewController is a UIKit component, so we need to wrap it in a SwiftUI-compatible struct using UIViewControllerRepresentable. This protocol allows SwiftUI to use UIKit view controllers.
Step 2.1: Import SafariServices#
Add import SafariServices to your project to access SFSafariViewController.
Step 2.2: Define the Wrapper Struct#
Create a new struct SafariView that conforms to UIViewControllerRepresentable. This struct will:
- Accept a
URLto load. - Use a
Bindingto track when to dismiss the view.
Add this code to a new file (e.g., SafariView.swift) or directly in ContentView.swift:
import SwiftUI
import SafariServices
struct SafariView: UIViewControllerRepresentable {
// URL to load in SFSafariViewController
let url: URL
// Binding to control dismissal from SwiftUI
@Binding var isPresented: Bool
// Create the SFSafariViewController instance
func makeUIViewController(context: Context) -> SFSafariViewController {
let safariVC = SFSafariViewController(url: url)
// Set the delegate to handle dismissal (we’ll implement this next)
safariVC.delegate = context.coordinator
return safariVC
}
// Update the view controller (not needed here, but required by the protocol)
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
// MARK: - Coordinator for Delegate Methods
// SwiftUI uses coordinators to handle UIKit delegate callbacks
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, SFSafariViewControllerDelegate {
let parent: SafariView
init(parent: SafariView) {
self.parent = parent
}
// Called when the user taps "Done" in SFSafariViewController
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
parent.isPresented = false // Update the binding to dismiss
}
}
}Key Details:#
UIViewControllerRepresentableProtocol: RequiresmakeUIViewController(creates the UIKit view controller) andupdateUIViewController(updates it).- Coordinator: A helper class to handle
SFSafariViewControllerDelegatemethods (since SwiftUI structs can’t conform to delegates directly). - Dismiss Binding:
@Binding var isPresentedlinks SwiftUI’s state to the dismissal action.
Step 3: Add a NavigationStack and NavigationButton#
Next, we’ll add a NavigationStack to ContentView and a button in the navigation bar (a NavigationButton) to trigger the presentation of SFSafariViewController.
Update ContentView.swift as follows:
import SwiftUI
struct ContentView: View {
// State to control whether SafariView is presented
@State private var showingSafari = false
// URL to load (replace with your desired URL)
private let targetURL = URL(string: "https://example.com")! // Use guard let in production!
var body: some View {
NavigationStack {
Text("Tap the Safari icon to open the web page!")
.navigationTitle("Safari Demo")
.toolbar {
// Add a button to the navigation bar
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingSafari = true // Trigger presentation
}) {
Image(systemName: "safari") // Safari icon from SF Symbols
.font(.title)
}
}
}
}
}
}
// Preview provider (for Xcode canvas)
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}Key Details:#
NavigationStack: Wraps the view to enable navigation bar customization.ToolbarItem: Adds a button to the navigation bar’s trailing edge (right side).@State private var showingSafari: Tracks whetherSafariViewshould be presented.
Step 4: Present SFSafariViewController Using a Sheet#
To present SafariView when the button is tapped, use SwiftUI’s .sheet modifier. This modifier presents a view as a sheet (sliding up from the bottom).
Add the .sheet modifier to the NavigationStack in ContentView:
NavigationStack {
// ... existing content ...
}
.sheet(isPresented: $showingSafari) {
// Present SafariView when showingSafari is true
SafariView(url: targetURL, isPresented: $showingSafari)
}Full ContentView Now:#
struct ContentView: View {
@State private var showingSafari = false
private let targetURL = URL(string: "https://example.com")!
var body: some View {
NavigationStack {
Text("Tap the Safari icon to open the web page!")
.navigationTitle("Safari Demo")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingSafari = true
}) {
Image(systemName: "safari")
.font(.title)
}
}
}
}
.sheet(isPresented: $showingSafari) {
SafariView(url: targetURL, isPresented: $showingSafari)
}
}
}Step 5: Handle Dismissal with SFSafariViewControllerDelegate#
SFSafariViewController includes a built-in "Done" button, but we need to ensure tapping it updates SwiftUI’s showingSafari state to dismiss the sheet.
We already set up the Coordinator in SafariView to handle this via safariViewControllerDidFinish. Let’s verify the delegate is properly assigned:
In SafariView’s makeUIViewController, we set safariVC.delegate = context.coordinator. The Coordinator then calls parent.isPresented = false when the user taps "Done", which updates the showingSafari state in ContentView and dismisses the sheet.
Step 6: Test the Implementation#
Let’s test the app to ensure everything works:
- Run the App: Select a simulator (e.g., iPhone 14) and click the "Play" button in Xcode.
- Trigger Presentation: Tap the Safari icon in the navigation bar.
SFSafariViewControllershould slide up, displayinghttps://example.com. - Test Dismissal: Tap the "Done" button in
SFSafariViewController. The sheet should dismiss, returning toContentView.
Notes:#
- Test on a physical device for full Safari feature support (e.g., auto-fill).
- If the URL fails to load, check for typos (e.g., missing
https://).
Troubleshooting Common Issues#
Issue 1: App Crashes When Tapping the Button#
- Cause: Invalid URL (e.g.,
nil). - Fix: Use optional binding to handle invalid URLs. For example:
private let targetURL: URL? = URL(string: "https://example.com") // In the button action: Button(action: { if targetURL != nil { showingSafari = true } else { print("Invalid URL") } })
Issue 2: "Done" Button Doesn’t Dismiss the Sheet#
- Cause: Delegate not set or
safariViewControllerDidFinishnot implemented. - Fix: Ensure
safariVC.delegate = context.coordinatorinmakeUIViewControllerand theCoordinatorimplementssafariViewControllerDidFinish.
Issue 3: Blank Screen or "Cannot Connect to Server"#
- Cause: Missing
https://(Safari blockshttpby default due to App Transport Security). - Fix: Use
httpsURLs. Forhttp(not recommended), add an ATS exception inInfo.plist:<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict>
Conclusion#
You’ve successfully integrated SFSafariViewController into a SwiftUI app! By using UIViewControllerRepresentable, you bridged UIKit’s SFSafariViewController to SwiftUI, enabling secure, feature-rich web content presentation.
Next Steps:#
- Customize
SFSafariViewControllerwith options likeentersReaderIfAvailable(auto-enable reader mode) orpreferredControlTintColor(customize button colors). - Add error handling for invalid URLs.
- Use
SFSafariViewControllerto display terms of service, privacy policies, or other web-based content.