import SwiftUI import WebKit struct WebView: UIViewRepresentable { let htmlContent: String let settings: Settings let onHeightChange: (CGFloat) -> Void var onScroll: ((Double) -> Void)? = nil @Environment(\.colorScheme) private var colorScheme func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() // Enable text selection and copy functionality let preferences = WKWebpagePreferences() preferences.allowsContentJavaScript = true configuration.defaultWebpagePreferences = preferences let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator webView.scrollView.isScrollEnabled = false webView.isOpaque = false webView.backgroundColor = UIColor.clear // Allow text selection and copying webView.allowsBackForwardNavigationGestures = false webView.allowsLinkPreview = true webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate") webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress") context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll return webView } func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.onHeightChange = onHeightChange context.coordinator.onScroll = onScroll let isDarkMode = colorScheme == .dark let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge) let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif) let styledHTML = """
\(htmlContent) """ webView.loadHTMLString(styledHTML, baseURL: nil) } func dismantleUIView(_ webView: WKWebView, coordinator: WebViewCoordinator) { webView.stopLoading() webView.navigationDelegate = nil webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate") webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") webView.loadHTMLString("", baseURL: nil) coordinator.cleanup() } func makeCoordinator() -> WebViewCoordinator { WebViewCoordinator() } private func getFontSize(from fontSize: FontSize) -> Int { switch fontSize { case .small: return 14 case .medium: return 16 case .large: return 18 case .extraLarge: return 20 } } private func getFontFamily(from fontFamily: FontFamily) -> String { switch fontFamily { case .system: return "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" case .serif: return "'Times New Roman', Times, 'Liberation Serif', serif" case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif" case .monospace: return "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace" } } } class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { // Callbacks var onHeightChange: ((CGFloat) -> Void)? var onScroll: ((Double) -> Void)? // Height management var lastHeight: CGFloat = 0 var pendingHeight: CGFloat = 0 var heightUpdateTimer: Timer? // Scroll management var isScrolling: Bool = false var scrollVelocity: Double = 0 var lastScrollTime: Date = Date() var scrollEndTimer: Timer? // Lifecycle private var isCleanedUp = false deinit { cleanup() } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { if let url = navigationAction.request.url { UIApplication.shared.open(url) decisionHandler(.cancel) return } } decisionHandler(.allow) } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "heightUpdate", let height = message.body as? CGFloat { DispatchQueue.main.async { self.handleHeightUpdate(height: height) } } if message.name == "scrollProgress", let progress = message.body as? Double { DispatchQueue.main.async { self.handleScrollProgress(progress: progress) } } } private func handleHeightUpdate(height: CGFloat) { // Store the pending height pendingHeight = height // If we're actively scrolling, defer the height update if isScrolling { return } // Apply height update immediately if not scrolling applyHeightUpdate(height: height) } private func handleScrollProgress(progress: Double) { let now = Date() let timeDelta = now.timeIntervalSince(lastScrollTime) // Calculate scroll velocity to detect fast scrolling if timeDelta > 0 { scrollVelocity = abs(progress) / timeDelta } lastScrollTime = now isScrolling = true // Longer delay for scroll end detection, especially during fast scrolling let scrollEndDelay: TimeInterval = scrollVelocity > 2.0 ? 0.8 : 0.5 scrollEndTimer?.invalidate() scrollEndTimer = Timer.scheduledTimer(withTimeInterval: scrollEndDelay, repeats: false) { [weak self] _ in self?.handleScrollEnd() } onScroll?(progress) } private func handleScrollEnd() { isScrolling = false scrollVelocity = 0 // Apply any pending height update after scrolling ends if pendingHeight != lastHeight && pendingHeight > 0 { // Add small delay to ensure scroll has fully stopped heightUpdateTimer?.invalidate() heightUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in guard let self = self else { return } self.applyHeightUpdate(height: self.pendingHeight) } } } private func applyHeightUpdate(height: CGFloat) { // Only update if height actually changed significantly let heightDifference = abs(height - lastHeight) if heightDifference < 5 { // Ignore tiny height changes that cause flicker return } lastHeight = height onHeightChange?(height) } func cleanup() { guard !isCleanedUp else { return } isCleanedUp = true scrollEndTimer?.invalidate() scrollEndTimer = nil heightUpdateTimer?.invalidate() heightUpdateTimer = nil onHeightChange = nil onScroll = nil } }