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

Viewed 2119

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}
4 Answers

I found that, at least for the content offset, I was able to set it in webView(_, didFinish:) without delay by setting it via JavaScript.

In other words, replace

self.webView.scrollView.setContentOffset(self.contentOffset, animated: false)

with

self.webView.evaluateJavaScript("window.scrollTo(\(self.contentOffset.minX), \(self.contentOffset.minY));)

I set two parameters minimumZoomScale and setZoomScale on didFinish

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        self.scrollView.minimumZoomScale = self.store.zoom
        self.scrollView.setZoomScale(self.store.zoom, animated: true)
    }
Related