🙅

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

2022/04/03に公開
1


挙動不審な様子

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

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

struct WrappedWKWebView: 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

    private let webView: WKWebView

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

    func makeUIView(context: Context) -> WKWebView {
        webView.allowsBackForwardNavigationGestures = true
        webView.allowsLinkPreview = false
        return webView
    }

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

    func updateUIView(_ webView: WKWebView, context: Context) {
        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
    }

    class Coordinator {
        let contentView: WrappedWKWebView
        private var cancellables = Set<AnyCancellable>()

        init(contentView: WrappedWKWebView) {
            self.contentView = contentView
            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)
        }

        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)
            }
        }
    }
}
その他のコード
BrowserApp.swift
import SwiftUI

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

struct WebView: View {
    @State private var inputText: String = ""
    @State private var action: WrappedWKWebView.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)
            WrappedWKWebView(action: $action,
                             canGoBack: $canGoBack,
                             canGoForward: $canGoForward,
                             estimatedProgress: $estimatedProgress,
                             progressOpacity: $progressOpacity)
            ToolBar(action: $action,
                    canGoBack: $canGoBack,
                    canGoForward: $canGoForward)
        }
    }
}
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: WrappedWKWebView.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
    }
}

Discussion