Create a web browser with WebKit and SwiftUI

Recently, I’ve been more and more curious about web experience through mobile apps. Most of web browser apps look alike, I was wondering how could I recreate one with WebKit and SwiftUI. Let’s dive in.

First Web View

If you’re familiar with UIKit, since Apple deprecated UIWebView, there is only one way to support a web view in iOS: using WKWebView from WebKit framework. We could use SFSafariViewController but it’s not made to be customized, so we’ll stick with WebKit for this time.

Unfortunately, SwiftUI doesn’t have any web view out of the box, even through WebKit import. Similar to what I have done to get a video player in SwiftUI (before Apple released a much simpler way), we’ll need to create a bridge to bring WKWebView to the SwiftUI world. For this, we’ll need to create a new representable view.

struct WebView: UIViewRepresentable {
    typealias UIViewType = WKWebView

    let webView: WKWebView
    
    func makeUIView(context: Context) -> WKWebView {
        return webView
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) { }
}

We might be tempted to build our struct with an URL parameter instead of a view and return a brand new WKWebView from makeUIView(..) function, but that’s not ideal. Any changes affecting the struct will recreate the whole object where we just want to reload its content only.

Then, how do we load a url?

We’ll need a class to handle the different state of our WebView.

class WebViewModel: ObservableObject {
    let webView: WKWebView
    let url: URL
    
    init() {
        webView = WKWebView(frame: .zero)
        url = URL(string: https://benoitpasquier.com)!

        loadUrl()
    }
    
    func loadUrl() {
        webView.load(URLRequest(url: url))
    }
}

So far so good, our WebViewModel is holding the logic of the web destination and how to load its content. We can now pack everything back to a container to test it.

struct ContentView: View {
    
    @StateObject var model = WebViewModel()
    
    var body: some View {
        WebView(webView: model.webView)
    }
}

That’s it! Our WKWebView is passed as parameter to be render, but it’s the actual model that controls its content.

Why does the model has to implement ObservableObject protocol?

It’s to make the model observable but at the point, it actually doesn’t require to be yet. We’re not done yet with this sample, let’s try to improve the experience.

WKWebView with Combine

First, like any web browsers, I want to leave mobile users the ability to feed their own url destinations. Then how about adding a back and forward buttons when possible like any good browsing experience? Let’s get to it.

For those 2 cases, we need some kind of binding system to be notified when the user set a new url destination. We also need to know when we can go back or forward based on the WKWebView. Nothing to worry, let’s use Combine for this.

Usually, WKWebView property changes like canGoBack or canGoForward are only observable through Key-Value Observing. However, WKWebView also support key paths which are observable through Combine framework which makes our life much easier.

class WebViewModel: ObservableObject {
    ...

    // outputs
    @Published var canGoBack: Bool = false
    @Published var canGoForward: Bool = false

    private func setupBindings() {
        webView.publisher(for: \.canGoBack)
            .assign(to: &$canGoBack)
        
        webView.publisher(for: \.canGoForward)
            .assign(to: &$canGoForward)
    }
}

Then we can expose couple more actions to load the url, navigate back as well as forward. We can get rid of our hardcoded url.

class WebViewModel: ObservableObject {
    ...
    
    // inputs
    @Published var urlString: String = ""

    // actions
    func loadUrl() {
        guard let url = URL(string: urlString) else {
            return 
        }
        
        webView.load(URLRequest(url: url))
    }
    
    func goForward() {
        webView.goForward()
    }
    
    func goBack() {
        webView.goBack()
    }
}

Almost there. We can connect it altogether in our container view. I’ll be using a TextField for the url destination and a ToolbarItemGroup with items to navigate back and forward.

struct ContentView: View {
    
    @StateObject var model = WebViewModel()
    
    var body: some View {
        ZStack(alignment: .bottom) {
            Color.black
                .ignoresSafeArea()
            
            VStack(spacing: 0) {
                HStack(spacing: 10) {
                    HStack {
                        TextField("Tap an url", 
                                  text: $model.urlString)
                            .keyboardType(.URL)
                            .autocapitalization(.none)
                            .padding(10)
                        Spacer()
                    }
                    .background(Color.white)
                    .cornerRadius(30)
                    
                    Button("Go", action: {
                        model.loadUrl()
                    })
                    .foregroundColor(.white)
                    .padding(10)
                    
                }.padding(10)
                
                WebView(webView: model.webView)
            }
        }
        .toolbar {
            ToolbarItemGroup(placement: .bottomBar) {
                Button(action: { 
                    model.goBack()
                }, label: {
                    Image(systemName: "arrowshape.turn.up.backward")
                })
                .disabled(!model.canGoBack)
                
                Button(action: { 
                    model.goForward()
                }, label: {
                    Image(systemName: "arrowshape.turn.up.right")
                })
                .disabled(!model.canGoForward)
                
                Spacer()
            }
        }
    }
}

Let’s see how it looks

webview-swiftui

Great! Our web view works as expected: we can load an url, navigates its content and go back or forward and possible.

Caveats

If this can be good enough for a small app, how easy can it be to create a full web browser with WebKit and SwiftUI only? Here is a list of things that I noticed and could be challenging moving forward.

The actual SwiftUI code is pretty simple since it only requires to layout the view but the bridge between SwiftUI and UIKit will become more and more complex over time.

For instance, WKWebView can require a navigation delegate for navigation policy or UI delegate for transition. Those protocols requires to conform to NSObjectProtocol as well. If we need to make this layer observable the View layer, it’s going to be extra effort from a delegate pattern to Combine.

class WebViewNavigationDelegate: NSObject, WKNavigationDelegate {
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        // TODO
        decisionHandler(.allow)
    }
    
    func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
        // TODO
        decisionHandler(.allow)
    }
}

class WebViewModel: ObservableObject {
    let webView: WKWebView
    
    private let navigationDelegate: WebViewNavigationDelegate
    
    ...
}

The other part worth mentioning is about JavaScript. Mobile developers tend to inject or observe Javascript content in WKWebView through evaluateJavaScript(..) like the content size of the web view to resize it’s container.

That could become quite challenging to resize a ContentView based on a WKWebView SwiftUI representation and brings some complexity to the View layer.

Finally, the sample only support one web view at a time, but as users we tend to open multiple tabs, swapping between those tabs could require more delegates or interchange the observers to make sure the bottom tab bar reflect latest changes.


In conclusion, if bringing WebKit to SwiftUI sounds a great idea, it can become quite a complex solution depending of the control we want to keep on the web experience. In some of those cases, it might be simpler to stick with UIKit and Auto Layout, or even just open a

That being said, it’s still interesting to see what can be done with SwiftUI and Combine. For instance, binding key path of a web view straight to the View layer sounds pretty great without diving into KVO.

This code is available on Github.

Happy Coding!

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer πŸ‡«πŸ‡·, writing about career development, mobile engineering and self-improvement

ShopBack πŸ’°

Singapore πŸ‡ΈπŸ‡¬