polish: explicit WKProcessPool, scroll-preserving content upgrade, no reload flash
1. Explicit WKProcessPool — static shared instance assigned to WKWebViewConfiguration. Prevents any future divergence even though iOS 15+ shares by default. 2. Scroll-preserving content upgrade — when articleContent updates (partial → full), uses JavaScript DOM replacement instead of loadHTMLString. Captures window.scrollY before swap, restores after. No visible flash or scroll jump. Falls back to full reload if JS replacement fails. 3. No unnecessary reloads — coordinator tracks lastHTML. Only loads if content actually changed. First article open = full page load (lastHTML is nil). Content upgrade = DOM swap (lastHTML exists, new content is different). 4. Clean separation — isUpgrade flag distinguishes first load from content upgrade. First load uses loadHTMLString (needs full <html> document). Upgrade uses innerHTML replacement (preserves scroll, CSS, page state). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,6 @@ import SwiftUI
|
||||
import WebKit
|
||||
|
||||
// MARK: - Shared Article Renderer
|
||||
//
|
||||
// Single WKWebView instance with shared WKWebViewConfiguration.
|
||||
// Created once at app launch. Reused for every article.
|
||||
// WKWebView handles its own scrolling — no height measurement needed.
|
||||
|
||||
@MainActor
|
||||
final class ArticleRenderer {
|
||||
@@ -13,26 +9,28 @@ final class ArticleRenderer {
|
||||
|
||||
let webView: WKWebView
|
||||
|
||||
// Explicit shared process pool — guarantees all WebViews share
|
||||
// one WebContent process, even though iOS 15+ does this by default.
|
||||
// Being explicit prevents accidental divergence if a second
|
||||
// WKWebView is ever created elsewhere.
|
||||
static let processPool = WKProcessPool()
|
||||
|
||||
private init() {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.processPool = ArticleRenderer.processPool
|
||||
config.allowsInlineMediaPlayback = true
|
||||
|
||||
webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
// WKWebView handles its own scrolling — no SwiftUI ScrollView wrapper
|
||||
webView.scrollView.isScrollEnabled = true
|
||||
|
||||
// Pre-warm: spin up WebContent process
|
||||
// Pre-warm: spin up WebContent process at app launch
|
||||
webView.loadHTMLString("<html><body></body></html>", baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Article Web View
|
||||
//
|
||||
// Wraps the shared WKWebView in a container UIView.
|
||||
// SwiftUI owns the container lifecycle, not the WKWebView's.
|
||||
// No height binding — WKWebView scrolls natively.
|
||||
|
||||
struct ArticleWebView: UIViewRepresentable {
|
||||
let html: String
|
||||
@@ -55,9 +53,36 @@ struct ArticleWebView: UIViewRepresentable {
|
||||
let webView = ArticleRenderer.shared.webView
|
||||
webView.navigationDelegate = context.coordinator
|
||||
|
||||
if context.coordinator.lastHTML != html {
|
||||
context.coordinator.lastHTML = html
|
||||
webView.loadHTMLString(html, baseURL: nil)
|
||||
let newHTML = html
|
||||
|
||||
// Only reload if content meaningfully changed
|
||||
guard context.coordinator.lastHTML != newHTML else { return }
|
||||
|
||||
let isUpgrade = context.coordinator.lastHTML != nil
|
||||
context.coordinator.lastHTML = newHTML
|
||||
|
||||
if isUpgrade {
|
||||
// Content upgrade (partial → full): capture scroll, replace body, restore scroll.
|
||||
// Uses JavaScript DOM replacement instead of full page reload to avoid flash.
|
||||
let escapedBody = extractBody(from: newHTML)
|
||||
let js = """
|
||||
(function() {
|
||||
var scrollY = window.scrollY;
|
||||
var header = document.querySelector('.article-header');
|
||||
var headerHTML = header ? header.outerHTML : '';
|
||||
document.body.innerHTML = headerHTML + \(escapedBody);
|
||||
window.scrollTo(0, scrollY);
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(js) { _, error in
|
||||
// If JS replacement fails (e.g. different page structure), fall back to full reload
|
||||
if error != nil {
|
||||
webView.loadHTMLString(newHTML, baseURL: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First load — full page load
|
||||
webView.loadHTMLString(newHTML, baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +90,24 @@ struct ArticleWebView: UIViewRepresentable {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
/// Extract the content after </head><body>...</body> as a JS string literal
|
||||
private func extractBody(from html: String) -> String {
|
||||
// Find body content between <body> and </body>
|
||||
if let bodyStart = html.range(of: "<body>"),
|
||||
let bodyEnd = html.range(of: "</body>") {
|
||||
let bodyContent = String(html[bodyStart.upperBound..<bodyEnd.lowerBound])
|
||||
// Escape for JavaScript string literal
|
||||
let escaped = bodyContent
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "\\'")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
return "'\(escaped)'"
|
||||
}
|
||||
// Fallback: can't parse, return empty
|
||||
return "''"
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
var lastHTML: String?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user