WebView in SwiftUI
Reading time: 4 min
This recipe shows how to embed a WKWebView
into SwiftUI and have a (somewhat) functional browser in your app!
The end result looks like this:
This component is available as a Swift package in this repo.
Modelling the behaviour
WKWebView
is a powerful and intricate component that does a lot of things for you besides just rendering web pages - it also tracks when did a loading start and when did it end, have any errors occurred while loading, can you move forwards or backwards through browsing history, etc. Similarly, it can do more than one thing besides loading, since it supports reloads and moving through browsing history as well.
Such complex behaviour is best captured in SwiftUI with action and state binding model. The idea is to have two @Binding
parameters in your view:
- action that is changed externally and informs the view to update itself: e.g, you'd set its value to
.reload
to have the WebView, well, reload itself. - state that is changed internally and tells the rest of the app to update accordingly: e.g, web view can inform the rest of the app that it began or ended loading a page. This is easier to code and maintain than having a larger number of bindings for each individual state property.
Here are the action and state models for our web view. WebViewAction
captures all the commands you can issue to the web view, while WebViewState
contains all the information that web view publishes about its internal state.
public enum WebViewAction {
case idle, // idle is always needed as actions need an empty state
load(URLRequest),
reload,
goBack,
goForward
}
public struct WebViewState {
public internal(set) var isLoading: Bool
public internal(set) var pageTitle: String?
public internal(set) var error: Error?
public internal(set) var canGoBack: Bool
public internal(set) var canGoForward: Bool
public static let empty = WebViewState(isLoading: false,
pageTitle: nil,
error: nil,
canGoBack: false,
canGoForward: false)
}
Publishing state updates
As usual, our web view will wrap WKWebView
via UIViewRepresentable
pattern. It, in turn ,makes use of a Coordinator
to implement WKNavigationDelegate
, which will capture navigation state changes and assign them to your WebViewState
binding:
public class WebViewCoordinator: NSObject {
private let webView: WebView
init(webView: WebView) {
self.webView = webView
}
// Convenience method, used later
func setLoading(_ isLoading: Bool, error: Error? = nil) {
var newState = webView.state
newState.isLoading = isLoading
if let error = error {
newState.error = error
}
webView.state = newState
}
}
extension WebViewCoordinator: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
setLoading(false)
webView.evaluateJavaScript("document.title") { (response, error) in
var newState = self.webView.state
newState.pageTitle = response as? String
self.webView.state = newState
}
}
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
setLoading(false)
}
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
setLoading(false, error: error)
}
public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
setLoading(true)
}
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
var newState = self.webView.state
newState.isLoading = true
newState.canGoBack = webView.canGoBack
newState.canGoForward = webView.canGoForward
self.webView.state = newState
}
}
Handing user actions
With state taken care of, you can proceed to implement the rest of UIViewRepresentable
. As expected, makeCoordinator
will provide your WebViewCoordinator
instance, while makeUIView
will create a WKWebView
. The interesting part is in updateUIView
: it'll trigger whenever there's a change in the action
binding, allowing you to invoke corresponding methods of the wrapped WKWebView
:
public struct WebView: UIViewRepresentable {
@Binding var action: WebViewAction
@Binding var state: WebViewState
public init(action: Binding<WebViewAction>,
state: Binding<WebViewState>) {
_action = action
_state = state
}
public func makeCoordinator() -> WebViewCoordinator {
WebViewCoordinator(webView: self)
}
public func makeUIView(context: Context) -> WKWebView {
let preferences = WKPreferences()
preferences.javaScriptEnabled = true
let configuration = WKWebViewConfiguration()
configuration.preferences = preferences
let webView = WKWebView(frame: CGRect.zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
webView.scrollView.isScrollEnabled = true
return webView
}
public func updateUIView(_ uiView: WKWebView, context: Context) {
switch action {
case .idle:
break
case .load(let request):
uiView.load(request)
case .reload:
uiView.reload()
case .goBack:
uiView.goBack()
case .goForward:
uiView.goForward()
}
action = .idle // this is important to prevent never-ending refreshes
}
}
A small browser app
Great! Now that the WebView
component is fully functional, let's put it to use in this small test view that illustrates most of its functionality:
struct WebViewTest: View {
@State private var action = WebViewAction.idle
@State private var state = WebViewState.empty
@State private var address = "https://www.google.com"
var body: some View {
NavigationView {
VStack {
navigationToolbar
errorView
Divider()
WebView(action: $action,
state: $state)
Spacer()
}
.navigationBarTitle(state.pageTitle ?? "Load a page", displayMode: .inline)
.navigationBarItems(
leading: Button(action: {
action = .goBack
}) {
if state.canGoBack {
Image(systemName: "chevron.left")
.imageScale(.large)
}
},
trailing: Button(action: {
action = .goForward
}) {
if state.canGoForward {
Image(systemName: "chevron.right")
.imageScale(.large)
}
})
}
}
private var navigationToolbar: some View {
HStack(spacing: 10) {
TextField("Address", text: $address)
if state.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
Spacer()
Button("Go") {
if let url = URL(string: address) {
action = .load(URLRequest(url: url))
}
}
Button(action: {
action = .reload
}) {
Image(systemName: "arrow.counterclockwise")
.imageScale(.large)
}
}.padding()
}
private var errorView: some View {
Group {
if let error = state.error {
Text(error.localizedDescription)
.foregroundColor(.red)
}
}
}
}