How to determine the content size of a WKWebView?

I am experimenting with replacing a dynamically allocated instance of UIWebView with a WKWebView instance when running under iOS 8 and newer, and I cannot find a way to determine the content size of a WKWebView.

My web view is embedded within a larger UIScrollView container, and therefore I need to determine the ideal size for the web view. This will allow me to modify its frame to show all of its HTML content without the need to scroll within the web view, and I will be able to set the correct height for the scroll view container (by setting scrollview.contentSize).

I have tried sizeToFit and sizeThatFits without success. Here is my code that creates a WKWebView instance and adds it to the container scrollview:

// self.view is a UIScrollView sized to something like 320.0 x 400.0.
CGRect wvFrame = CGRectMake(0, 0, self.view.frame.size.width, 100.0);
self.mWebView = [[[WKWebView alloc] initWithFrame:wvFrame] autorelease];
self.mWebView.navigationDelegate = self;
self.mWebView.scrollView.bounces = NO;
self.mWebView.scrollView.scrollEnabled = NO;

NSString *s = ... // Load s from a Core Data field.
[self.mWebView loadHTMLString:s baseURL:nil];

[self.view addSubview:self.mWebView];

Here is an experimental didFinishNavigation method:

- (void)webView:(WKWebView *)aWebView
                             didFinishNavigation:(WKNavigation *)aNavigation
{
    CGRect wvFrame = aWebView.frame;
    NSLog(@"original wvFrame: %@\n", NSStringFromCGRect(wvFrame));
    [aWebView sizeToFit];
    NSLog(@"wvFrame after sizeToFit: %@\n", NSStringFromCGRect(wvFrame));
    wvFrame.size.height = 1.0;
    aWebView.frame = wvFrame;
    CGSize sz = [aWebView sizeThatFits:CGSizeZero];
    NSLog(@"sizeThatFits A: %@\n", NSStringFromCGSize(sz));
    sz = CGSizeMake(wvFrame.size.width, 0.0);
    sz = [aWebView sizeThatFits:sz];
    NSLog(@"sizeThatFits B: %@\n", NSStringFromCGSize(sz));
}

And here is the output that is generated:

2014-12-16 17:29:38.055 App[...] original wvFrame: {{0, 0}, {320, 100}}
2014-12-16 17:29:38.055 App[...] wvFrame after sizeToFit: {{0, 0}, {320, 100}}
2014-12-16 17:29:38.056 App[...] wvFrame after sizeThatFits A: {320, 1}
2014-12-16 17:29:38.056 App[...] wvFrame after sizeThatFits B: {320, 1}

The sizeToFit call has no effect and sizeThatFits always returns a height of 1.


I think I read every answer on this subject and all I had was part of the solution. Most of the time I spent trying to implement KVO method as described by @davew, which occasionally worked, but most of the time left a white space under the content of a WKWebView container. I also implemented @David Beck suggestion and made the container height to be 0 thus avoiding the possibility that the problem occurs if the container height is larger that that of the content. In spite of that I had that occasional blank space. So, for me, "contentSize" observer had a lot of flaws. I do not have a lot of experience with web technologies so I cannot answer what was the problem with this solution, but i saw that if I only print height in the console but do not do anything with it (eg. resize the constraints), it jumps to some number (e.g. 5000) and than goes to the number before that highest one (e.g. 2500 - which turns out to be the correct one). If I do set the height constraint to the height which I get from "contentSize" it sets itself to the highest number it gets and never gets resized to the correct one - which is, again, mentioned by @David Beck comment.

After lots of experiments I've managed to find a solution that works for me:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    self.webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
        if complete != nil {
            self.webView.evaluateJavaScript("document.body.scrollHeight", completionHandler: { (height, error) in
                self.containerHeight.constant = height as! CGFloat
            })
        }

        })
}

Of course, it is important to set the constraints correctly so that scrollView resizes according to the containerHeight constraint.

As it turns out didFinish navigation method never gets called when I wanted, but having set document.readyState step, the next one (document.body.offsetHeight) gets called at the right moment, returning me the right number for height.


You could use Key-Value Observing (KVO)...

In your ViewController:

- (void)viewDidLoad {
    ...
    [self.webView.scrollView addObserver:self forKeyPath:@"contentSize" options:NSKeyValueObservingOptionNew context:nil];
}


- (void)dealloc
{
    [self.webView.scrollView removeObserver:self forKeyPath:@"contentSize" context:nil];
}


- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if (object == self.webView.scrollView && [keyPath isEqual:@"contentSize"]) {
        // we are here because the contentSize of the WebView's scrollview changed.

        UIScrollView *scrollView = self.webView.scrollView;
        NSLog(@"New contentSize: %f x %f", scrollView.contentSize.width, scrollView.contentSize.height);
    }
}

This would save the use of JavaScript and keep you in the loop on all changes.


I had to deal with this issue myself recently. In the end, I was using a modification of the solution proposed by Chris McClenaghan.

Actually, his original solution is pretty good and it works in most simple cases. However, it only worked for me on pages with text. It probably also works on pages with images that have a static height. However, it definitely doesn't work when you have images whose size is defined with max-height and max-width attributes.

And this is because those elements can get resized after the page is loaded. So, actually, the height returned in onLoad will always be correct. But it will only be correct for that particular instance. The workaround is to monitor the change of the body height and respond to it.

Monitor resizing of the document.body

var shouldListenToResizeNotification = false
lazy var webView:WKWebView = {
    //Javascript string
    let source = "window.onload=function () {window.webkit.messageHandlers.sizeNotification.postMessage({justLoaded:true,height: document.body.scrollHeight});};"
    let source2 = "document.body.addEventListener( 'resize', incrementCounter); function incrementCounter() {window.webkit.messageHandlers.sizeNotification.postMessage({height: document.body.scrollHeight});};"
    
    //UserScript object
    let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
    
    let script2 = WKUserScript(source: source2, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
    
    //Content Controller object
    let controller = WKUserContentController()
    
    //Add script to controller
    controller.addUserScript(script)
    controller.addUserScript(script2)
    
    //Add message handler reference
    controller.add(self, name: "sizeNotification")
    
    //Create configuration
    let configuration = WKWebViewConfiguration()
    configuration.userContentController = controller
    
    return WKWebView(frame: CGRect.zero, configuration: configuration)
}()

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    guard let responseDict = message.body as? [String:Any],
    let height = responseDict["height"] as? Float else {return}
    if self.webViewHeightConstraint.constant != CGFloat(height) {
        if let _ = responseDict["justLoaded"] {
            print("just loaded")
            shouldListenToResizeNotification = true
            self.webViewHeightConstraint.constant = CGFloat(height)
        }
        else if shouldListenToResizeNotification {
            print("height is \(height)")
            self.webViewHeightConstraint.constant = CGFloat(height)
        }
        
    }
}

This solution is by far the most elegant that I could come up with. There are, however, two things you should be aware of.

Firstly, before loading your URL you should set shouldListenToResizeNotification to false. This extra logic is needed for cases when the loaded URL can change rapidly. When this occurs, notifications from old content for some reason can overlap with those from the new content. To prevent such behaviour, I created this variable. It ensures that once we start loading new content we no longer process notification from the old one and we only resume processing of resize notifications after new content is loaded.

Most importantly, however, you need to be aware about this:

If you adopt this solution you need to take into account that if you change the size of your WKWebView to anything other than the size reported by the notification - the notification will be triggered again.

Be careful with this as it is easy to enter an infinite loop. For example, if you decide to handle the notification by making your height equal to reported height + some extra padding:

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        guard let responseDict = message.body as? [String:Float],
        let height = responseDict["height"] else {return}
        self.webViewHeightConstraint.constant = CGFloat(height+8)
    }

As you can see, because I am adding 8 to the reported height, after this is done the size of my body will change and the notification will be posted again.

Be alert to such situations and otherwise you should be fine.

And please let me know if you discover any problems with this solution - I am relying on it myself so it is best to know if there are some faults which I haven't spotted!