mana-swift-ui/Sources/ManaWebShell/WebShellView.swift
Till JS 3aa90762cc ManaWebShell: Retry-Button in der Fehler-Leiste
Ein WebView-Lade-Fehler war bisher eine Sackgasse — Meldung sichtbar,
aber kein Weg zum Neuladen. Jetzt: "Erneut laden"-Button erhöht einen
internen reloadNudge, der in effectiveTarget.reloadToken einfließt →
updateUIView/updateNSView lädt die URL neu. WebTargetTests sichern die
Token-Ungleichheit ab (4/4 grün via swift test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:04:02 +02:00

200 lines
7.1 KiB
Swift

import SwiftUI
import WebKit
/// SwiftUI-Hülle um `WKWebView`. Eine Instanz gehört üblicherweise zu
/// einem Tab/Screen und behält ihren Web-State (Scroll-Position,
/// Browser-History) während die View lebt.
///
/// **Verhalten:**
/// - Lädt `target` beim ersten Auftauchen.
/// - Wechselt `target` während die View lebt lädt neue URL (oder
/// reloaded, wenn `target.reloadToken` sich erhöht).
/// - Pull-to-Refresh über `UIRefreshControl` (iOS / iPadOS).
/// - Links auf nicht-gelisteten Hosts (siehe ``WebShellConfig/allowedHosts``)
/// und `target=_blank` öffnen im System-Browser via
/// `OpenURLAction`, nicht im WebView.
/// - Cookies werden über `WKWebsiteDataStore.default()` geteilt.
///
/// **Theme-Hint:**
/// `config.backgroundColor` ist der Hintergrund hinter dem WKWebView
/// verhindert weißen Flash bis zum first paint. Apps mit Dark-Theme
/// setzen das auf ihren Theme-Background.
public struct WebShellView: View {
let target: WebTarget
let config: WebShellConfig
@State private var navState = WebNavState()
@State private var reloadNudge = 0
@Environment(\.openURL) private var openURL
/// `target` plus interner Reload-Zähler. Der Erneut laden"-Button in
/// der Fehler-Leiste erhöht `reloadNudge` neuer `reloadToken`
/// `updateUIView`/`updateNSView` lädt die URL neu.
private var effectiveTarget: WebTarget {
WebTarget(url: target.url, reloadToken: target.reloadToken + reloadNudge)
}
public init(target: WebTarget, config: WebShellConfig) {
self.target = target
self.config = config
}
public var body: some View {
VStack(spacing: 0) {
if navState.isLoading {
ProgressView(value: navState.estimatedProgress)
.progressViewStyle(.linear)
.tint(config.progressTint)
.frame(height: 2)
}
#if canImport(UIKit)
WebViewRepresentable(
target: effectiveTarget,
navState: navState,
openURL: openURL,
config: config
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(config.backgroundColor)
#elseif canImport(AppKit)
MacWebViewRepresentable(
target: effectiveTarget,
navState: navState,
openURL: openURL,
config: config
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(config.backgroundColor)
#endif
if let error = navState.lastError {
errorBar(error)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorBar(_ message: String) -> some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(config.errorIconColor)
Text(message)
.font(.caption)
.lineLimit(2)
.foregroundStyle(config.errorForegroundColor)
Spacer()
Button {
navState.lastError = nil
reloadNudge += 1
} label: {
Text("Erneut laden")
.font(.caption.weight(.semibold))
.foregroundStyle(config.errorForegroundColor)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(config.errorBackgroundColor)
}
}
#if canImport(UIKit)
import UIKit
private struct WebViewRepresentable: UIViewRepresentable {
let target: WebTarget
let navState: WebNavState
let openURL: OpenURLAction
let config: WebShellConfig
func makeCoordinator() -> WebShellCoordinator {
WebShellCoordinator(navState: navState, openURL: openURL, config: config)
}
func makeUIView(context: Context) -> WKWebView {
let wkConfig = WKWebViewConfiguration()
wkConfig.websiteDataStore = .default()
wkConfig.applicationNameForUserAgent = config.userAgent
for script in config.userScripts {
wkConfig.userContentController.addUserScript(script)
}
let webView = WKWebView(frame: .zero, configuration: wkConfig)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
// Ohne diese drei flackert WKWebView bis zum first paint weiß
// gegen das App-Theme egal was der SwiftUI-Container als
// Background setzt.
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.backgroundColor = .clear
webView.scrollView.refreshControl = makeRefreshControl(
webView: webView,
coordinator: context.coordinator
)
context.coordinator.observe(webView: webView)
context.coordinator.load(target.url, into: webView)
context.coordinator.lastTarget = target
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
let coord = context.coordinator
if coord.lastTarget != target {
coord.load(target.url, into: webView)
coord.lastTarget = target
}
}
private func makeRefreshControl(
webView: WKWebView,
coordinator: WebShellCoordinator
) -> UIRefreshControl {
let refresh = UIRefreshControl()
coordinator.attachRefresh(refresh, webView: webView)
return refresh
}
}
#elseif canImport(AppKit)
import AppKit
private struct MacWebViewRepresentable: NSViewRepresentable {
let target: WebTarget
let navState: WebNavState
let openURL: OpenURLAction
let config: WebShellConfig
func makeCoordinator() -> WebShellCoordinator {
WebShellCoordinator(navState: navState, openURL: openURL, config: config)
}
func makeNSView(context: Context) -> WKWebView {
let wkConfig = WKWebViewConfiguration()
wkConfig.websiteDataStore = .default()
wkConfig.applicationNameForUserAgent = config.userAgent
for script in config.userScripts {
wkConfig.userContentController.addUserScript(script)
}
let webView = WKWebView(frame: .zero, configuration: wkConfig)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
// macOS-Pendant zu UIView.isOpaque=false sonst weißer Flash
// vor first paint.
webView.setValue(false, forKey: "drawsBackground")
context.coordinator.observe(webView: webView)
context.coordinator.load(target.url, into: webView)
context.coordinator.lastTarget = target
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
let coord = context.coordinator
if coord.lastTarget != target {
coord.load(target.url, into: webView)
coord.lastTarget = target
}
}
}
#endif