From ee9c14783f99760ed462bb178046f3476ba0d4a1 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Thu, 8 May 2025 21:29:58 -0700 Subject: [PATCH] Revert webview changes that might be the cause of the no-scroll-on-coming-back-from-sleep bug. --- iOS/Article/PreloadedWebView.swift | 75 ++++++++++++ iOS/Article/WebViewController.swift | 114 +++++++++--------- iOS/Article/WebViewProvider.swift | 103 ++++++++++++++++ iOS/MainFeed/MainFeedViewController.swift | 4 +- .../MainTimelineViewController.swift | 6 +- iOS/SceneCoordinator.swift | 10 +- 6 files changed, 246 insertions(+), 66 deletions(-) create mode 100644 iOS/Article/PreloadedWebView.swift create mode 100644 iOS/Article/WebViewProvider.swift diff --git a/iOS/Article/PreloadedWebView.swift b/iOS/Article/PreloadedWebView.swift new file mode 100644 index 000000000..fd407ca0b --- /dev/null +++ b/iOS/Article/PreloadedWebView.swift @@ -0,0 +1,75 @@ +// +// PreloadedWebView.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 2/25/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import WebKit + +class PreloadedWebView: WKWebView { + + private var isReady: Bool = false + private var readyCompletion: (() -> Void)? + + init(articleIconSchemeHandler: ArticleIconSchemeHandler) { + let preferences = WKPreferences() + preferences.javaScriptCanOpenWindowsAutomatically = false + + let configuration = WKWebViewConfiguration() + configuration.preferences = preferences + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs") + configuration.allowsInlineMediaPlayback = true + configuration.mediaTypesRequiringUserActionForPlayback = .audio + configuration.setURLSchemeHandler(articleIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme) + + super.init(frame: .zero, configuration: configuration) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + func preload() { + navigationDelegate = self + loadFileURL(ArticleRenderer.blank.url, allowingReadAccessTo: ArticleRenderer.blank.baseURL) + } + + func ready(completion: @escaping () -> Void) { + if isReady { + completeRequest(completion: completion) + } else { + readyCompletion = completion + } + } + +} + +// MARK: WKScriptMessageHandler + +extension PreloadedWebView: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isReady = true + if let completion = readyCompletion { + completeRequest(completion: completion) + readyCompletion = nil + } + } + +} + +// MARK: Private + +private extension PreloadedWebView { + + func completeRequest(completion: @escaping () -> Void) { + isReady = false + navigationDelegate = nil + completion() + } + +} diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index 45b6b4a78..5f6666edd 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -31,8 +31,8 @@ class WebViewController: UIViewController { private var topShowBarsViewConstraint: NSLayoutConstraint! private var bottomShowBarsViewConstraint: NSLayoutConstraint! - var webView: WKWebView? { - return view.subviews[0] as? WKWebView + private var webView: PreloadedWebView? { + return view.subviews[0] as? PreloadedWebView } private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self) @@ -352,14 +352,22 @@ extension WebViewController: UIContextMenuInteractionDelegate { extension WebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + for (index, view) in view.subviews.enumerated() { + if index != 0, let oldWebView = view as? PreloadedWebView { + oldWebView.removeFromSuperview() + } + } + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - + if navigationAction.navigationType == .linkActivated { guard let url = navigationAction.request.url else { decisionHandler(.allow) return } - + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) if components?.scheme == "http" || components?.scheme == "https" { decisionHandler(.cancel) @@ -373,14 +381,14 @@ extension WebViewController: WKNavigationDelegate { self.openURLInSafariViewController(url) } } - + } else if components?.scheme == "mailto" { decisionHandler(.cancel) - + guard let emailAddress = url.percentEncodedEmailAddress else { return } - + if UIApplication.shared.canOpenURL(emailAddress) { UIApplication.shared.open(emailAddress, options: [.universalLinksOnly : false], completionHandler: nil) } else { @@ -390,11 +398,11 @@ extension WebViewController: WKNavigationDelegate { } } else if components?.scheme == "tel" { decisionHandler(.cancel) - + if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [.universalLinksOnly : false], completionHandler: nil) } - + } else { decisionHandler(.allow) } @@ -406,7 +414,7 @@ extension WebViewController: WKNavigationDelegate { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { fullReload() } - + } // MARK: WKUIDelegate @@ -506,63 +514,55 @@ private extension WebViewController { func loadWebView(replaceExistingWebView: Bool = false) { guard isViewLoaded else { return } - + if !replaceExistingWebView, let webView = webView { self.renderPage(webView) return } - - let preferences = WKPreferences() - preferences.javaScriptCanOpenWindowsAutomatically = false - /// The defaults for `preferredContentMode` and `allowsContentJavaScript` are suitable - /// and don't need to be explicitly set. - /// `allowsContentJavaScript` replaces `WKPreferences.javascriptEnabled`. - let webpagePreferences = WKWebpagePreferences() + coordinator.webViewProvider.dequeueWebView() { webView in + + webView.ready { + + // Add the webview + webView.translatesAutoresizingMaskIntoConstraints = false + self.view.insertSubview(webView, at: 0) + NSLayoutConstraint.activate([ + self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor), + self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor), + self.view.topAnchor.constraint(equalTo: webView.topAnchor), + self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor) + ]) + + // UISplitViewController reports the wrong size to WKWebView which can cause horizontal + // rubberbanding on the iPad. This interferes with our UIPageViewController preventing + // us from easily swiping between WKWebViews. This hack fixes that. + webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0) + + webView.scrollView.setZoomScale(1.0, animated: false) + + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + + // Configure the webview + webView.navigationDelegate = self + webView.uiDelegate = self + webView.scrollView.delegate = self + self.configureContextMenuInteraction() + + webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked) + webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown) + webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector) + + self.renderPage(webView) + + } - let configuration = WKWebViewConfiguration() - configuration.defaultWebpagePreferences = webpagePreferences - configuration.preferences = preferences - configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs") - configuration.allowsInlineMediaPlayback = true - configuration.mediaTypesRequiringUserActionForPlayback = .audio - if #available(iOS 15.4, *) { - configuration.preferences.isElementFullscreenEnabled = true } - configuration.setURLSchemeHandler(articleIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme) - let webView = WKWebView(frame: self.view.bounds, configuration: configuration) - webView.isOpaque = false; - webView.backgroundColor = .clear; - - // Add the webview - using autolayout will cause fullscreen video to fail and lose the web view - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - self.view.insertSubview(webView, at: 0) - - // UISplitViewController reports the wrong size to WKWebView which can cause horizontal - // rubberbanding on the iPad. This interferes with our UIPageViewController preventing - // us from easily swiping between WKWebViews. This hack fixes that. - webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0) - - webView.scrollView.setZoomScale(1.0, animated: false) - - self.view.setNeedsLayout() - self.view.layoutIfNeeded() - - // Configure the webview - webView.navigationDelegate = self - webView.uiDelegate = self - webView.scrollView.delegate = self - self.configureContextMenuInteraction() - - webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked) - webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown) - webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector) - - self.renderPage(webView) } - func renderPage(_ webView: WKWebView?) { + func renderPage(_ webView: PreloadedWebView?) { guard let webView = webView else { return } let theme = ArticleThemesManager.shared.currentTheme diff --git a/iOS/Article/WebViewProvider.swift b/iOS/Article/WebViewProvider.swift new file mode 100644 index 000000000..4c49d3ed5 --- /dev/null +++ b/iOS/Article/WebViewProvider.swift @@ -0,0 +1,103 @@ +// +// WebViewProvider.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 9/21/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Foundation +import RSCore +import WebKit + +/// WKWebView has an awful behavior of a flash to white on first load when in dark mode. +/// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle. +class WebViewProvider: NSObject { + + private let articleIconSchemeHandler: ArticleIconSchemeHandler + private let operationQueue = MainThreadOperationQueue() + private var queue = NSMutableArray() + + init(coordinator: SceneCoordinator) { + articleIconSchemeHandler = ArticleIconSchemeHandler(coordinator: coordinator) + super.init() + replenishQueueIfNeeded() + } + + func replenishQueueIfNeeded() { + operationQueue.add(WebViewProviderReplenishQueueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler)) + } + + func dequeueWebView(completion: @escaping (PreloadedWebView) -> ()) { + operationQueue.add(WebViewProviderDequeueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler, completion: completion)) + operationQueue.add(WebViewProviderReplenishQueueOperation(queue: queue, articleIconSchemeHandler: articleIconSchemeHandler)) + } + +} + +class WebViewProviderReplenishQueueOperation: MainThreadOperation { + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "WebViewProviderReplenishQueueOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private let minimumQueueDepth = 3 + + private var queue: NSMutableArray + private var articleIconSchemeHandler: ArticleIconSchemeHandler + + init(queue: NSMutableArray, articleIconSchemeHandler: ArticleIconSchemeHandler) { + self.queue = queue + self.articleIconSchemeHandler = articleIconSchemeHandler + } + + func run() { + while queue.count < minimumQueueDepth { + let webView = PreloadedWebView(articleIconSchemeHandler: articleIconSchemeHandler) + webView.preload() + queue.insert(webView, at: 0) + } + self.operationDelegate?.operationDidComplete(self) + } + +} + +class WebViewProviderDequeueOperation: MainThreadOperation { + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "WebViewProviderFlushQueueOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private var queue: NSMutableArray + private var articleIconSchemeHandler: ArticleIconSchemeHandler + private var completion: (PreloadedWebView) -> () + + init(queue: NSMutableArray, articleIconSchemeHandler: ArticleIconSchemeHandler, completion: @escaping (PreloadedWebView) -> ()) { + self.queue = queue + self.articleIconSchemeHandler = articleIconSchemeHandler + self.completion = completion + } + + func run() { + if let webView = queue.lastObject as? PreloadedWebView { + self.completion(webView) + self.queue.remove(webView) + self.operationDelegate?.operationDidComplete(self) + return + } + + assertionFailure("Creating PreloadedWebView in \(#function); queue has run dry.") + + let webView = PreloadedWebView(articleIconSchemeHandler: articleIconSchemeHandler) + webView.preload() + self.completion(webView) + self.operationDelegate?.operationDidComplete(self) + } + +} diff --git a/iOS/MainFeed/MainFeedViewController.swift b/iOS/MainFeed/MainFeedViewController.swift index 4ecef0bdf..4fc8afb16 100644 --- a/iOS/MainFeed/MainFeedViewController.swift +++ b/iOS/MainFeed/MainFeedViewController.swift @@ -35,10 +35,10 @@ class MainFeedViewController: UITableViewController, UndoableCommandRunner { private let keyboardManager = KeyboardManager(type: .sidebar) override var keyCommands: [UIKeyCommand]? { - // If the first responder is the WKWebView we don't want to supply any keyboard + // If the first responder is the WKWebView (PreloadedWebView) we don't want to supply any keyboard // commands that the system is looking for by going up the responder chain. They will interfere with // the WKWebViews built in hardware keyboard shortcuts, specifically the up and down arrow keys. - guard let current = UIResponder.currentFirstResponder, !(current is WKWebView) else { return nil } + guard let current = UIResponder.currentFirstResponder, !(current is PreloadedWebView) else { return nil } return keyboardManager.keyCommands } diff --git a/iOS/MainTimeline/MainTimelineViewController.swift b/iOS/MainTimeline/MainTimelineViewController.swift index 06853e1bd..ad7af3551 100644 --- a/iOS/MainTimeline/MainTimelineViewController.swift +++ b/iOS/MainTimeline/MainTimelineViewController.swift @@ -98,11 +98,11 @@ class MainTimelineViewController: UITableViewController, UndoableCommandRunner { private let keyboardManager = KeyboardManager(type: .timeline) override var keyCommands: [UIKeyCommand]? { - // If the first responder is the WKWebView we don't want to supply any keyboard + // If the first responder is the WKWebView (PreloadedWebView) we don't want to supply any keyboard // commands that the system is looking for by going up the responder chain. They will interfere with // the WKWebViews built in hardware keyboard shortcuts, specifically the up and down arrow keys. - guard let current = UIResponder.currentFirstResponder, !(current is WKWebView) else { return nil } - + guard let current = UIResponder.currentFirstResponder, !(current is PreloadedWebView) else { return nil } + return keyboardManager.keyCommands } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index a487dc02a..5475255ed 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -40,20 +40,22 @@ struct FeedNode: Hashable { } class SceneCoordinator: NSObject, UndoableCommandRunner { - + var undoableCommands = [UndoableCommand]() var undoManager: UndoManager? { return rootSplitViewController.undoManager } - + + lazy var webViewProvider = WebViewProvider(coordinator: self) + private var activityManager = ActivityManager() - + private var rootSplitViewController: RootSplitViewController! private var mainFeedViewController: MainFeedViewController! private var mainTimelineViewController: MainTimelineViewController? private var articleViewController: ArticleViewController? - + private let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5) private let rebuildBackingStoresQueue = CoalescingQueue(name: "Rebuild The Backing Stores", interval: 0.5) private var fetchSerialNumber = 0