🙅
WKWebViewをSwiftUIでラップしたら挙動不審になった。
挙動不審な様子
WKWebView
をUIViewRepresentable
でラップするベストプラクティスがわかりません。
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
この後、なんとか動く形にしたものがこれです。