Search code examples
ioswkwebview

WKWebView requires delay to set its scrollView's contentOffset and zoomScale after load()


I wrote some code to save off a WKWebView's scroll view's contentOffset and zoomScale, so they could be restored after the webView loads. I've found that setting those scrollView properties only works using a delay (e.g. with DispatchQueue.main.asyncAfter(). Why is this necessary? Is there a better way to achieve this?

import UIKit
import WebKit

class ViewController: UIViewController, WKNavigationDelegate {
    var contentOffset = CGPoint.zero
    var zoomScale: CGFloat = 1.0
    lazy var webView: WKWebView = {
        let wv = WKWebView(frame: CGRect.zero)
        wv.translatesAutoresizingMaskIntoConstraints = false
        wv.allowsBackForwardNavigationGestures = true
        wv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        wv.navigationDelegate = self
        return wv
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(webView)
        webView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        webView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        webView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        webView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        reload()
    }

    @IBAction func refreshTapped(_ sender: UIBarButtonItem) {
        reload()
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        // Without some delay, the restoration doesn't happen!
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: {
            self.webView.scrollView.setZoomScale(self.zoomScale, animated: false)
            self.webView.scrollView.setContentOffset(self.contentOffset, animated: false)
        })
    }

    private func reload() {
        contentOffset = webView.scrollView.contentOffset
        zoomScale = webView.scrollView.zoomScale;
        webView.load(URLRequest(url: URL(string: "https://www.google.com")!))
    }
}

Update with AJ B suggestion (Sorry for the repetition)

import UIKit
import WebKit

class ViewController: UIViewController {
    private static let keyPath = "webView.scrollView.contentSize"
    private static var kvoContext = 0

    private var contentOffset: CGPoint?
    private var zoomScale: CGFloat?
    private var lastContentSize: CGSize?
    private var repeatedSizeCount = 0

    lazy var webView: WKWebView = {
        let wv = WKWebView(frame: CGRect.zero)
        wv.translatesAutoresizingMaskIntoConstraints = false
        wv.allowsBackForwardNavigationGestures = true
        wv.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        return wv
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(webView)
        webView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        webView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        webView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
        webView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        addObserver(self, forKeyPath: ViewController.keyPath, options: .new, context: &ViewController.kvoContext)
        reload()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        removeObserver(self, forKeyPath: ViewController.keyPath)
    }

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        guard let newSize = change?[.newKey] as? CGSize, context == &ViewController.kvoContext else {
            super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
            return
        }

        print("Observed: \(NSStringFromCGSize(newSize))")

        // Nothing to restore if these are nil
        guard let offset = contentOffset, let zoom = zoomScale else { return }

        guard let lastSize = lastContentSize, lastSize == newSize else {
            print("Waiting for size to settle")
            lastContentSize = newSize
            return
        }

        repeatedSizeCount += 1

        guard repeatedSizeCount >= 4 else {
            print("Size repeated \(repeatedSizeCount) time(s)")
            return
        }

        print("Settled - set saved zoom and offset")
        contentOffset = nil
        zoomScale = nil
        lastContentSize = nil
        repeatedSizeCount = 0
        webView.scrollView.setZoomScale(zoom, animated: false)
        webView.scrollView.setContentOffset(offset, animated: false)
    }

    @IBAction func refreshTapped(_ sender: UIBarButtonItem) {
        contentOffset = webView.scrollView.contentOffset
        zoomScale = webView.scrollView.zoomScale;
        reload()
    }

    private func reload() {
        print("Reload: \(NSStringFromCGSize(webView.scrollView.contentSize))")
        webView.load(URLRequest(url: URL(string: "https://www.google.com")!))
    }
}

Prints the following:

Reload: {781, 1453}
Observed: {781, 1453}
Waiting for size to settle
Observed: {320, 595}
Waiting for size to settle
Observed: {320, 595}
Size repeated 1 time(s)
Observed: {320, 595}
Size repeated 2 time(s)
Observed: {320, 595}
Size repeated 3 time(s)
Observed: {320, 595}
Settled - set saved zoom and offset
Observed: {781, 1453}
Observed: {781, 1453}
Observed: {781, 1453}

Solution

  • I was doing something similar trying to get the content height after load and then sizing the webview's container to be that height. I struggled with it for awhile, the best approach I found was to observe the WKWebView's ScrollView content height, and when the content height repeats itself at the same size then you know it is completely loaded. This is a bit hacky but it worked consistently for me. I'd like to know if anyone knows a better solution as well.

    //This was an example html string that would demonstrate the issue
    
    var html = "<html><body><p>We're no strangers to love  You know the rules and so do I  A full commitment's what I'm thinking of  You wouldn't get this from any other guy    I just want to tell you how I'm feeling  Gotta make you understand    Never gonna give you up, never gonna let you down  Never gonna run around and desert you  Never gonna make you cry, never gonna say goodbye  Never gonna tell a lie and hurt you    We've known each other for so long  Your heart's been aching but you're too shy to say it  Inside we both know what's been going on  We know the game and we're gonna play it    And if you ask me how I'm feeling  Don't tell me you're too blind to see    Never gonna give you up, never gonna let you down  Never gonna run around and desert you  Never gonna make you cry, never gonna say goodbye  Never gonna tell a lie and hurt you    Never gonna give you up, never gonna let you down  Never gonna run around and desert you  Never gonna make you cry, never gonna say goodbye  Never gonna tell a lie and hurt you    We've known each other for so long  Your heart's been aching but you're too shy to say it  Inside we both know what's been going on  We know the game and we're gonna play it    I just want to tell you how I'm feeling  Gotta make you understand    Never gonna give you up, never gonna let you down  Never gonna run around and desert you  Never gonna make you cry, never gonna say goodbye  Never gonna tell a lie and hurt you</p><p><img src=\"https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg\" width=\"\" height=\"\" style=\"display:block;height:auto;max-width:100%;width:100%;\"></p></body></html>"
    

    I Injected some javascript to send events for the ready state and dom loading, and printed the size at those times as well as the didFinish navigation function. This was what printed:

    // Adjusting the container height in didFinish navigation function 
    
    started navigation
    committed navigation
    content height = 0.0
    Javascript: Ready state change interactive | content height = 0.0
    Javascript: DOM content loaded | content height = 0.0
    Javascript: Ready state change complete | content height = 0.0
    ended navigation content height = 0.0 (didFinish navigation)
    content size observed = 1638.0
    height constraint = Optional(0.0)
    content size observed = 691.666666666667
    height constraint = Optional(0.0)
    content size observed = 1171.0
    height constraint = Optional(0.0)
    content size observed = 2772.0
    height constraint = Optional(0.0)
    content size observed = 2772.0
    height constraint = Optional(0.0)
    content size observed = 2772.0
    height constraint = Optional(0.0)
    content size observed = 2772.0
    height constraint = Optional(0.0)
    content size observed = 2772.0
    height constraint = Optional(0.0)
    content size observed = 2772.0
    height constraint = Optional(0.0)
    content size observed = 2772.0
    height constraint = Optional(0.0)
    
    // Adjusting the container height after content size repeats itself KVO
    
    started navigation
    committed navigation
    content height = 0.0
    Javascript: Ready state change interactive | content height = 0.0
    Javascript: DOM content loaded | content height = 0.0
    Javascript: Ready state change complete | content height = 0.0
    ended navigation content height = 0.0 (didFinish navigation)
    content size observed = 1638.0
    height constraint = Optional(1.0)
    content size observed = 691.666666666667
    height constraint = Optional(1.0)
    content size observed = 691.666666666667
    height constraint = Optional(1.0)
    content size observed = 691.666666666667
    height constraint = Optional(691.666666666667)
    content size observed = 691.666666666667
    height constraint = Optional(691.666666666667)
    content size observed = 691.666666666667
    height constraint = Optional(691.666666666667)
    content size observed = 691.666666666667
    height constraint = Optional(691.666666666667)
    content size observed = 691.666666666667
    height constraint = Optional(691.666666666667)
    content size observed = 691.666666666667
    height constraint = Optional(691.666666666667)
    content size observed = 691.666666666667
    height constraint = Optional(691.666666666667)