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:

Preview iOS

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:

  1. 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.
  2. 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)
      }
    }
  }
}

Next Post Previous Post