Compare commits

...

1 Commits

Author SHA1 Message Date
d71bb1f6e1 feat: Add iOS 26 native SwiftUI WebView implementation
- Implement automatic version detection for iOS 26+ vs legacy WebView
- Add NativeWebView using SwiftUI WebView and WebPage for iOS 26+
- Maintain LegacyWebView with WKWebView for older iOS versions
- Include JavaScript height calculation with multiple timing strategies
- Add scroll disabling and proper height management
- Implement graceful fallback when JavaScript fails
- Update BookmarkDetailView to use new WebView structure
2025-09-27 22:14:01 +02:00
3 changed files with 267 additions and 22 deletions

View File

@ -83,6 +83,7 @@
Data/CoreData/CoreDataManager.swift,
"Data/Extensions/NSManagedObjectContext+SafeFetch.swift",
Data/KeychainHelper.swift,
Data/Utils/LabelUtils.swift,
Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift,
Logger.swift,

View File

@ -25,10 +25,9 @@ struct BookmarkDetailView: View {
private let headerHeight: CGFloat = 360
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId
self.viewModel = viewModel
self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings
self.showingLabelsSheet = showingLabelsSheet
}
@ -61,6 +60,8 @@ struct BookmarkDetailView: View {
if webViewHeight != height {
webViewHeight = height
}
}, onScroll: { progress in
// Handle scroll progress if needed
})
.frame(height: webViewHeight)
.cornerRadius(14)
@ -247,17 +248,7 @@ struct BookmarkDetailView: View {
@ViewBuilder
private var contentSection: some View {
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
withAnimation(.easeInOut(duration: 0.1)) {
webViewHeight = height
}
}
.frame(height: webViewHeight)
.cornerRadius(14)
.padding(.horizontal)
.animation(.easeInOut, value: webViewHeight)
} else if viewModel.isLoadingArticle {
if viewModel.isLoadingArticle {
ProgressView("Loading article...")
.frame(maxWidth: .infinity, alignment: .center)
.padding()
@ -464,7 +455,6 @@ struct ScrollOffsetPreferenceKey: PreferenceKey {
NavigationView {
BookmarkDetailView(bookmarkId: "123",
viewModel: .init(MockUseCaseFactory()),
webViewHeight: 300,
showingFontSettings: false,
showingLabelsSheet: false,
playerUIState: .init())

View File

@ -1,7 +1,249 @@
import SwiftUI
import WebKit
struct WebView: UIViewRepresentable {
// iOS 26+ Native SwiftUI WebView Implementation
@available(iOS 26.0, *)
struct NativeWebView: View {
let htmlContent: String
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
var body: some View {
WebKit.WebView(webPage)
.scrollDisabled(true) // Disable internal scrolling
.onAppear {
loadStyledContent()
}
.onChange(of: htmlContent) { _, _ in
loadStyledContent()
}
.onChange(of: colorScheme) { _, _ in
loadStyledContent()
}
.onChange(of: webPage.isLoading) { _, isLoading in
if !isLoading {
// Update height when content finishes loading
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
Task {
await updateContentHeightWithJS()
}
}
}
}
}
private func updateContentHeightWithJS() async {
var lastHeight: CGFloat = 0
// More frequent attempts with shorter delays
let delays = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0] // 9 attempts
for (index, delay) in delays.enumerated() {
let attempt = index + 1
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
do {
// Try to get height via JavaScript
let result = try await webPage.callJavaScript("getContentHeight()")
if let height = result as? Double, height > 0 {
let cgHeight = CGFloat(height)
// Update height if it's significantly different or this is the first valid measurement
if lastHeight == 0 || abs(cgHeight - lastHeight) > 10 {
print("JavaScript height updated: \(height)px on attempt \(attempt)")
DispatchQueue.main.async {
self.onHeightChange(cgHeight)
}
lastHeight = cgHeight
}
// If height seems stable (no change in last few attempts), we can exit early
if attempt >= 3 && lastHeight > 0 {
print("Height stabilized at \(lastHeight)px")
return
}
}
} catch {
print("JavaScript attempt \(attempt) failed: \(error)")
}
}
// If no valid height was found, use fallback
if lastHeight == 0 {
print("No valid JavaScript height found, using fallback")
updateContentHeightFallback()
}
}
private func updateContentHeightFallback() {
// Simplified fallback calculation
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
let plainText = htmlContent.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
let characterCount = plainText.count
let estimatedLines = max(1, characterCount / 80)
let textHeight = CGFloat(estimatedLines) * CGFloat(fontSize) * 1.8
let finalHeight = max(400, min(textHeight + 100, 3000))
DispatchQueue.main.async {
self.onHeightChange(finalHeight)
}
}
private func loadStyledContent() {
let isDarkMode = colorScheme == .dark
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
let fontFamily = getFontFamily(from: settings.fontFamily ?? .serif)
let styledHTML = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
<style>
body {
font-family: \(fontFamily);
line-height: 1.8;
margin: 0;
padding: 16px;
background-color: \(isDarkMode ? "#000000" : "#ffffff");
color: \(isDarkMode ? "#ffffff" : "#1a1a1a");
font-size: \(fontSize)px;
-webkit-text-size-adjust: 100%;
-webkit-user-select: text;
user-select: text;
overflow: hidden; /* Disable scrolling in WebView */
height: auto;
}
html {
overflow: hidden; /* Disable scrolling */
height: auto;
}
h1, h2, h3, h4, h5, h6 {
color: \(isDarkMode ? "#ffffff" : "#000000");
margin-top: 24px;
margin-bottom: 12px;
font-weight: 600;
}
img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px 0; }
a { color: \(isDarkMode ? "#0A84FF" : "#007AFF"); text-decoration: none; }
blockquote { border-left: 4px solid \(isDarkMode ? "#0A84FF" : "#007AFF"); margin: 16px 0; padding: 12px 16px; color: \(isDarkMode ? "#8E8E93" : "#666666"); background-color: \(isDarkMode ? "rgba(58, 58, 60, 0.3)" : "rgba(0, 122, 255, 0.05)"); border-radius: 4px; }
code { background-color: \(isDarkMode ? "#1C1C1E" : "#f5f5f5"); color: \(isDarkMode ? "#ffffff" : "#000000"); padding: 2px 6px; border-radius: 4px; font-family: 'SF Mono', monospace; }
</style>
</head>
<body>
\(htmlContent)
<script>
function measureHeight() {
return Math.max(
document.body.scrollHeight || 0,
document.body.offsetHeight || 0,
document.documentElement.clientHeight || 0,
document.documentElement.scrollHeight || 0,
document.documentElement.offsetHeight || 0
);
}
// Make function globally available
window.getContentHeight = measureHeight;
// Auto-measure when everything is ready
function scheduleHeightCheck() {
// Multiple timing strategies
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', delayedHeightCheck);
} else {
delayedHeightCheck();
}
// Also check after images load
window.addEventListener('load', delayedHeightCheck);
// Force check after layout
setTimeout(delayedHeightCheck, 50);
setTimeout(delayedHeightCheck, 100);
setTimeout(delayedHeightCheck, 200);
setTimeout(delayedHeightCheck, 500);
}
function delayedHeightCheck() {
// Force layout recalculation
document.body.offsetHeight;
const height = measureHeight();
console.log('Height check:', height);
}
scheduleHeightCheck();
</script>
</body>
</html>
"""
webPage.load(html: styledHTML)
// Update height after content loads
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
await updateContentHeightWithJS()
}
}
}
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, sans-serif"
case .serif: return "'Times New Roman', Times, serif"
case .sansSerif: return "'Helvetica Neue', Helvetica, Arial, sans-serif"
case .monospace: return "'SF Mono', Menlo, Monaco, monospace"
}
}
}
// Main WebView - automatically chooses best implementation
struct WebView: View {
let htmlContent: String
let settings: Settings
let onHeightChange: (CGFloat) -> Void
var onScroll: ((Double) -> Void)? = nil
var body: some View {
if #available(iOS 26.0, *) {
// Use new native SwiftUI WebView on iOS 26+
NativeWebView(
htmlContent: htmlContent,
settings: settings,
onHeightChange: onHeightChange,
onScroll: onScroll
)
} else {
// Fallback to WKWebView wrapper for older iOS
LegacyWebView(
htmlContent: htmlContent,
settings: settings,
onHeightChange: onHeightChange,
onScroll: onScroll
)
}
}
}
// Fallback: Original WKWebView Implementation
struct LegacyWebView: UIViewRepresentable {
let htmlContent: String
let settings: Settings
let onHeightChange: (CGFloat) -> Void
@ -26,7 +268,7 @@ struct WebView: UIViewRepresentable {
webView.allowsBackForwardNavigationGestures = false
webView.allowsLinkPreview = true
// Message Handler hier einmalig hinzufügen
// Message Handler für Height und Scroll Updates
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
context.coordinator.onHeightChange = onHeightChange
@ -36,7 +278,7 @@ struct WebView: UIViewRepresentable {
}
func updateUIView(_ webView: WKWebView, context: Context) {
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
// Update callbacks
context.coordinator.onHeightChange = onHeightChange
context.coordinator.onScroll = onScroll
@ -240,6 +482,7 @@ struct WebView: UIViewRepresentable {
document.querySelectorAll('img').forEach(img => {
img.addEventListener('load', updateHeight);
});
// Scroll progress reporting
window.addEventListener('scroll', function() {
var scrollTop = window.scrollY || document.documentElement.scrollTop;
@ -288,6 +531,11 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
var isScrolling: Bool = false
var scrollEndTimer: Timer?
deinit {
scrollEndTimer?.invalidate()
scrollEndTimer = nil
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
if let url = navigationAction.request.url {
@ -300,7 +548,9 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "heightUpdate", let height = message.body as? CGFloat {
switch message.name {
case "heightUpdate":
guard let height = message.body as? CGFloat else { return }
DispatchQueue.main.async {
// Block height updates during active scrolling to prevent flicker
if !self.isScrolling && !self.hasHeightUpdate {
@ -308,20 +558,24 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
self.hasHeightUpdate = true
}
}
}
if message.name == "scrollProgress", let progress = message.body as? Double {
case "scrollProgress":
guard let progress = message.body as? Double else { return }
DispatchQueue.main.async {
// Track scrolling state
self.isScrolling = true
// Reset scrolling state after scroll ends
self.scrollEndTimer?.invalidate()
self.scrollEndTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
self.isScrolling = false
self.scrollEndTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] _ in
self?.isScrolling = false
}
self.onScroll?(progress)
}
default:
print("Unknown message: \(message.name)")
}
}
}