WKWebViewをSwiftUIでラップしたら挙動不審になった。



挙動不審な様子

WKWebViewUIViewRepresentableでラップするベストプラクティスがわかりません。
SwiftUIの描画更新サイクルとWKWebViewの持つ描画更新サイクルがうまく噛み合わないので、ある特定のページをロードするだけの利用ではなく、ユーザーがロードするページを操作するような場合は描画更新が無限ループして挙動不審になりました。どうすればいいのか。

構成
.
├── Extensions
│   └── String+Extension.swift
├── BrowserApp.swift
└── View
    ├── NetSurfingView.swift
    ├── SearchBar.swift
    ├── ToolBar.swift
    └── WebContentView.swift
WebContentView.swift
import SwiftUI
import WebKit
import Combine

struct WebContentView: UIViewRepresentable {
    enum Action {
        case none
        case goBack
        case goForward
        case refresh
        case search(String)
    }

    @Binding private var action: Action
    @Binding private var canGoBack: Bool
    @Binding private var canGoForward: Bool
    @Binding private var estimatedProgress: Double
    @Binding private var progressOpacity: Double

    let webView: WKWebView

    init(
        action: Binding<Action>,
        canGoBack: Binding<Bool>,
        canGoForward: Binding<Bool>,
        estimatedProgress: Binding<Double>,
        progressOpacity: Binding<Double>
    ) {
        self._action = action
        self._canGoBack = canGoBack
        self._canGoForward = canGoForward
        self._estimatedProgress = estimatedProgress
        self._progressOpacity = progressOpacity

        webView = WKWebView()
        webView.allowsBackForwardNavigationGestures = true
        webView.allowsLinkPreview = false
        NSLog("🌟🐮")
    }

    func makeUIView(context: Context) -> WKWebView {
        NSLog("🌟🐝")
        webView.uiDelegate = context.coordinator
        webView.navigationDelegate = context.coordinator
        return webView
    }

    func makeCoordinator() -> WebViewCoordinator {
        return WebViewCoordinator(contentView: self)
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        NSLog("🌟🐷")
        switch action {
        case .none:
            break
        case .goBack:
            if webView.canGoBack {
                webView.goBack()
            }
        case .goForward:
            if webView.canGoForward {
                webView.goForward()
            }
        case .refresh:
            webView.reload()
        case .search(let searchText):
            context.coordinator.search(text: searchText)
        }
        action = .none
    }

    final class WebViewCoordinator: UIViewController {
        let contentView: WebContentView
        private var cancellables = Set<AnyCancellable>()

        init(contentView: WebContentView) {
            self.contentView = contentView
            super.init(nibName: nil, bundle: nil)

            contentView.webView
                .publisher(for: \.estimatedProgress)
                .sink { value in
                    contentView.estimatedProgress = value
                }
                .store(in: &cancellables)

            contentView.webView
                .publisher(for: \.isLoading)
                .sink { value in
                    if value {
                        contentView.estimatedProgress = 0
                        contentView.progressOpacity = 1
                    } else {
                        contentView.progressOpacity = 0
                    }
                }
                .store(in: &cancellables)

            contentView.webView
                .publisher(for: \.canGoBack)
                .assign(to: \.canGoBack, on: contentView)
                .store(in: &cancellables)

            contentView.webView
                .publisher(for: \.canGoForward)
                .assign(to: \.canGoForward, on: contentView)
                .store(in: &cancellables)
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        private func openURL(urlString: String) {
            if let url = URL(string: urlString) {
                contentView.webView.load(URLRequest(url: url))
            }
        }

        func search(text: String) {
            if text.isEmpty {
                openURL(urlString: "https://www.google.com")
            } else if text.match(pattern: #"^[a-zA-Z]+://"#) {
                openURL(urlString: text)
            } else if let encoded = text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
                let urlString = "https://www.google.com/search?q=\(encoded)"
                openURL(urlString: urlString)
            }
        }
    }
}

// MARK: - WKUIDelegate
extension WebContentView.WebViewCoordinator: WKUIDelegate {
    // Alert
    func webView(
        _ webView: WKWebView,
        runJavaScriptAlertPanelWithMessage message: String,
        initiatedByFrame frame: WKFrameInfo
    ) async {
        return await withCheckedContinuation { continuation in
            let alertController = UIAlertController(title: nil,
                                                    message: message,
                                                    preferredStyle: .alert)
            let okAction = UIAlertAction(title: "OK", style: .default) { _ in
                continuation.resume()
            }
            alertController.addAction(okAction)
            self.present(alertController, animated: true, completion: nil)
        }
    }

    // Confirm
    func webView(
        _ webView: WKWebView,
        runJavaScriptConfirmPanelWithMessage message: String,
        initiatedByFrame frame: WKFrameInfo
    ) async -> Bool {
        return await withCheckedContinuation { continuation in
            let alertController = UIAlertController(title: nil,
                                                    message: message,
                                                    preferredStyle: .alert)
            let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
                continuation.resume(returning: false)
            }
            alertController.addAction(cancelAction)
            let okAction = UIAlertAction(title: "OK", style: .default) { _ in
                continuation.resume(returning: true)
            }
            alertController.addAction(okAction)
            self.present(alertController, animated: true, completion: nil)
        }
    }

    // Prompt
    func webView(
        _ webView: WKWebView,
        runJavaScriptTextInputPanelWithPrompt prompt: String,
        defaultText: String?, initiatedByFrame frame: WKFrameInfo
    ) async -> String? {
        return await withCheckedContinuation { continuation in
            let alertController = UIAlertController(title: nil,
                                                    message: prompt,
                                                    preferredStyle: .alert)
            alertController.addTextField { textField in
                textField.text = defaultText
            }
            let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
                continuation.resume(returning: nil)
            }
            alertController.addAction(cancelAction)
            let okAction = UIAlertAction(title: "OK", style: .default) { _ in
                if let result = alertController.textFields?.first?.text {
                    continuation.resume(returning: result)
                } else {
                    continuation.resume(returning: "")
                }
            }
            alertController.addAction(okAction)
            self.present(alertController, animated: true, completion: nil)
        }
    }
}

// MARK: - WKNavigationDelegate
extension WebContentView.WebViewCoordinator: WKNavigationDelegate {
    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationAction: WKNavigationAction
    ) async -> WKNavigationActionPolicy {
        guard let requestURL = navigationAction.request.url else {
            return .cancel
        }
        switch requestURL.scheme {
        case "http", "https":
            return .allow
        default:
            UIApplication.shared.open(requestURL, options: [:]) { result in
                NSLog("🌟🐙 \(result)")
            }
            return .cancel
        }
    }
}
その他のコード
MinBrowserApp.swift
import SwiftUI

@main
struct MinBrowserApp: App {
    var body: some Scene {
        WindowGroup {
            NetSurfingView()
        }
    }
}
NetSurfingView.swift
import SwiftUI

struct NetSurfingView: View {
    @State private var inputText: String = ""
    @State private var action: WebContentView.Action = .none
    @State private var canGoBack: Bool = false
    @State private var canGoForward: Bool = false
    @State private var estimatedProgress = 0.0
    @State private var progressOpacity = 1.0

    var body: some View {
        VStack(spacing: 0) {
            SearchBar(inputText: $inputText)
                .onSubmit {
                    action = .search(inputText)
                }
            ProgressView(value: estimatedProgress)
                .opacity(progressOpacity)
            WebContentView(action: $action,
                           canGoBack: $canGoBack,
                           canGoForward: $canGoForward,
                           estimatedProgress: $estimatedProgress,
                           progressOpacity: $progressOpacity)
            ToolBar(action: $action,
                    canGoBack: $canGoBack,
                    canGoForward: $canGoForward)
        }
        .onOpenURL(perform: { url in
            if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
               let queryItem = components.queryItems?.first(where: { $0.name == "url" }),
               let queryURL = queryItem.value {
                NSLog("🌟 \(queryURL)")
                action = .search(queryURL)
            }
        })
    }
}
SearchBar.swift
import SwiftUI

struct SearchBar: View {
    @Binding var inputText: String

    var body: some View {
        ZStack {
            Rectangle()
                .foregroundColor(Color("SearchBar"))
            HStack(spacing: 4) {
                Image(systemName: "magnifyingglass")
                TextField("Search ..", text: $inputText)
            }
            .foregroundColor(Color.gray)
            .padding(.leading, 8)
        }
        .frame(height: 36)
        .cornerRadius(10)
        .padding(.vertical, 8)
        .padding(.horizontal, 16)
    }
}
ToolBar.swift
struct ToolBar: View {
    @Binding var action: WebContentView.Action
    @Binding var canGoBack: Bool
    @Binding var canGoForward: Bool

    var body: some View {
        VStack(spacing: 0) {
            Divider()
                .background(Color("ToolBarBorder"))
            HStack {
                Button {
                    action = .goBack
                } label: {
                    Image(systemName: "chevron.backward")
                        .imageScale(.large)
                        .frame(width: 40, height: 40, alignment: .center)
                }
                .disabled(!canGoBack)
                Button {
                    action = .goForward
                } label: {
                    Image(systemName: "chevron.forward")
                        .imageScale(.large)
                        .frame(width: 40, height: 40, alignment: .center)
                }
                .disabled(!canGoForward)
                Spacer()
            }
            .padding(.vertical, 8)
            .padding(.horizontal, 16)
            .background(Color("ToolBar"))
        }
    }
}
String+Extension.swift
import Foundation

extension String {
    func match(pattern: String) -> Bool {
        let matchRange = self.range(of: pattern, options: .regularExpression)
        return matchRange != nil
    }
}