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
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!