Determining the content size of a WKWebView


In one of my recent projects, I had to use a web view to display an HTML string with local CSS and custom font. The web view had to be embedded inside a scroll view along with a couple of other views. It also had to have a height adjusted to the content it was displaying. My goal was to create something like this:



WKWebView example

I had already done this in one of my previous projects so I figured I’d reuse the same code.
The problem was that the code was using a UIWebView instead of the newer WKWebView. Since iOS 8, UIWebView was replaced with a much more powerful and well structured WKWebView, and since iOS 12 UIWebView was officially deprecated. That meant that I had to reimplement the feature.
I’ve put all the code in an example project on GitHub so you can follow along.


Loading HTML string

For loading HTML strings into the web view, WKWebView has loadHTMLString(_:baseURL:) method. The HTML string that I was getting from backend did not have <HTML>, <HEAD> and <BODY> tags so I had to manually add them. The method for loading the HTML looked like this:

func loadHTMLContent(_ htmlContent: String) {
    let htmlStart = "<HTML><HEAD><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, shrink-to-fit=no\"></HEAD><BODY>"
    let htmlEnd = "</BODY></HTML>"
    let htmlString = "\(htmlStart)\(htmlContent)\(htmlEnd)"
    webView.loadHTMLString(htmlString, baseURL: Bundle.main.bundleURL)
}

Important notes:

  1. The <HEAD> tag has to have viewport metadata which is important for the correct calculation of the content height of the web view.
  2. The baseURL parameter in loadHTMLString has to be the main bundle URL because we will be loading our local custom font.


Loading local CSS

Loading local CSS file into HTML was pretty easy in UIWebView. You only had to add <link rel='stylesheet' href='/path/to/css' type='text/css'> in the <HEAD> tag. In WKWebView the process is a bit more complicated because it requires CSS to be injected after HTML loads. We can do that by utilizing the webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) method of the WKNavigationDelegate:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    guard
        let path = Bundle.main.path(forResource: "style", ofType: "css"),
        let cssString = try? String(contentsOfFile: path).components(separatedBy: .newlines).joined()
    else {
        return
    }

    let jsString = "var style = document.createElement('style'); style.innerHTML = '\(cssString)'; document.head.appendChild(style);"
    webView.evaluateJavaScript(jsString)
}

First, we load the contents of the style.css file into a string variable. After that, we use JavaScript to inject a <style> tag into web view’s HTML. The problem with this approach is that the web view loads HTML, displays it and after that applies CSS. That transition is visible by the user which is not good.

Fortunately, there is a better approach. We can use WKUserScript that represents a script that can be injected into a webpage, and WKUserContentController that allows us to inject that script into the web view. These objects will be used inside a WKWebViewConfiguration during WKWebView initialization:

lazy var webView: WKWebView = {
    guard
      let path = Bundle.main.path(forResource: "style", ofType: "css"),
      let cssString = try? String(contentsOfFile: path).components(separatedBy: .newlines).joined()
    else {
      return WKWebView()
    }

    let source = """
       var style = document.createElement('style');
       style.innerHTML = '\(cssString)';
       document.head.appendChild(style);
    """

    let userScript = WKUserScript(source: source,
                                  injectionTime: .atDocumentEnd,
                                  forMainFrameOnly: true)

    let userContentController = WKUserContentController()
    userContentController.addUserScript(userScript)

    let configuration = WKWebViewConfiguration()
    configuration.userContentController = userContentController

    let webView = WKWebView(frame: .zero,
                            configuration: configuration)
    return webView
}()

Using this approach, CSS will be loaded just after HTML loads and there won’t be a transition that can be noticed by the user.


Adding custom font in CSS

To add custom fonts in Xcode, I suggest you follow Apple’s tutorial. After adding the font, we need to load it into the web view using CSS. To use custom fonts in CSS we need to use the @font-face CSS rule:

@font-face {
    font-family: "Titillium Web";
    font-weight: normal;
    src: url("TitilliumWeb-Regular.ttf")
}

@font-face {
    font-family: "Titillium Web";
    font-style: italic;
    src: url("TitilliumWeb-Italic.ttf")
}

@font-face {
    font-family: "Titillium Web";
    font-weight: bold;
    src: url("TitilliumWeb-Bold.ttf")
}

This way, we have defined our custom font and it can now be used in CSS:

body {
    margin: 0;
    font-family: "Titillium Web";
    font-weight: normal;
    font-size: 13pt;
}


Calculating web view’s content height

After loading HTML and CSS in the web view we have to calculate it’s height. To calculate the height we’ll use JavaScript to find the scrollHeight of the document in the web view. We’ll do that in the delegate method to make sure that the web view finished loading before calculation:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (height, error) in
        self.webViewHeightConstraint?.constant = height as! CGFloat
    })
}


Conclusion

In this blog post, we’ve gone through one use case of WKWebView and analyzed the problems that can occur. We can see that the new API gives us much more flexibility and stability compared to the deprecated UIWebView.