尝试使用UIViewRepresentable在Swift UI中显示稍微丰富的Webview


简介

从iOS14开始,SwiftUI当前不支持与WKWebView等效的View。
因此,有必要包装和使用UIKit的WKWebView来实现SwiftUI。
这次,目标是显示配备基本功能(工具栏,进度栏)的WebView,我想总结一下如何使用其中显示的UIViewRepresentable

*在本文中,考虑到多功能性,将使用iOS13之前支持的技术来显示WebView。

关于UIViewRepresentable

您必须使用UIViewRepresentable来将UIKit的View与Swift UI一起使用。
UIViewRepresentable是在SwiftUI中使用UIKit View的包装。
描述UIViewRepresentable协议中定义的每个功能。

1
func makeUIView(context: Self.Context) -> Self.UIViewType

需要实施。
创建要显示的View的实例。
SwiftUI中使用的UIKit的View作为返回值返回。

1
func updateUIView(Self.UIViewType, context: Self.Context)

需要实施。
在应用程序状态更新时调用。
如果有View的更新,请在此功能中对其进行描述。

1
static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

在删除指定的UIKit视图时调用。
描述此功能的清除过程,例如,如有必要,删除注册的通知。

1
func makeCoordinator() -> Self.Coordinator

在有事件要从View端通知时实施。
通过定义Coordinator,可以通过诸如Delegate之类的用户操作执行事件处理。

使用WKWebView

创建WebView

中,我们将输入WKWebView的显示内容,这是主要主题。

显示WKWebView

首先,只需在SwiftUI中显示WebView。
UIViewType变为associatedtype,因此将其更改为要在此处包装的UIKit的"视图"类型。
这次,我将使用WKWebView。

WebView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct WebView: UIViewRepresentable {
    /// 表示するView
    private let webView = WKWebView()
    /// 表示するURL
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        // 戻り値をWKWebViewとし、返却する
        webView.load(URLRequest(url: url))
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) { }
}

创建工具栏

接下来,我们将创建包含三个元素戻る 進む リロード的工具栏。

点击按钮时的动作控制

首先,创建一个工具栏并放置每个按钮。
接下来,使用Property Wrappers启用WebView来检测何时单击工具栏上的每个按钮。
如果在此状态下点击工具栏上的按钮,则应用程序的状态将被更新,并且UIViewRepresentableupdateUIView(_ uiView:, context:)将被触发。
从上面的WebView一侧,根据updateUIView(_ uiView:, context:)中的按钮描述操作。

WebView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct WebView: UIViewRepresentable {
    // 省略...

    /// WebViewのアクション
    enum Action {
        case none
        case goBack
        case goForward
        case reload
    }

    /// アクション
    @Binding var action: Action

    func updateUIView(_ uiView: WKWebView, context: Context) {
        /// バインドしている値が更新されるたびに呼ばれる
        /// actionが更新されたら、更新された値に応じて処理を行う
        switch action {
        case .goBack:
            uiView.goBack()
        case .goForward:
            uiView.goForward()
        case .reload:
            uiView.reload()
        case .none:
            break
        }
        action = .none
    }
}

WebToolBarView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct WebToolBarView: View {
    /// アクション
    @Binding var action: WebView.Action

    var body: some View {
        VStack() {
            HStack() {
                // タップしたボタンに応じてアクションを更新
                Button("Back") { action = .goBack }
                Button("Forward") { action = .goForward }
                Button("Reload") { action = .reload }
            }
        }
    }
}

RichWebView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct RichWebView: View {
    /// URL
    let url: URL
    /// アクション
    @State private var action: WebView.Action = .none

    var body: some View {
        VStack() {
            WebView(url: url,
                    action: $action)
            WebToolBarView(action: $action)
        }
    }
}

此处不解释

* Property Wrappers,例如@State@Binding,因为它们与主要主题有所不同。
有许多文章详细解释了这些内容,因此如有必要,请分别参阅它们。
状态和数据流| Apple开发人员文档

按钮停用

现在,可以在WebView中点击按钮时进行处理。
但是,可以在任何状态下轻按按钮,因此,如果您无法移至上一页或下一页,请停用每个按钮。

定义协调器
WebView页面加载完成后,确定是否可以移动到上一页或下一页。
为此,您需要实现WKNavigationDelegate,但是不能直接在WebView中实现它。
因此,在UIViewRepresentable中,您需要创建一个名为Coordinator的自定义实例,以告知Swift UI从UIKit视图接收的更改。
WKNavigationDelegateCoordinator中实现,并且通过它执行Swift UI端的事件处理。

WebView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct WebView: UIViewRepresentable {
    // 省略...

    /// 戻れるか
    @Binding var canGoBack: Bool
    /// 進めるか
    @Binding var canGoForward: Bool

    func makeCoordinator() -> WebView.Coordinator {
        return Coordinator(parent: self)
    }
}

extension WebView {
    final class Coordinator: NSObject, WKNavigationDelegate {
        /// 親View
        let parent: WebView

        init(parent: WebView) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            parent.canGoBack = webView.canGoBack
            parent.canGoForward = webView.canGoForward
        }
    }
}

之后,通过RichWebView接收WebToolBarView处的每个值,并描述每个按钮的不活动和活动处理,您已完成。

WebToolBarView.swift

1
2
    Button("Back") { action = .goBack }.disabled(!canGoBack)
    Button("Forward") { action = .goForward }.disabled(!canGoForward)

创建进度条

最后,让我们创建一个进度条。

通过KVO

获得价值

您需要获取WKWebViewestimatedProgressisLoading来创建进度栏。
这次,我们将对每个使用KVO。
您将收到来自View的更改通知,因此请使用之前使用的Coordinator

WebView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct WebView: UIViewRepresentable {
    // 省略...

     /// 読み込みの進捗状況
    @Binding var estimatedProgress: Double
    /// ローディング中かどうか
    @Binding var isLoading: Bool

    static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
        // WKWebView削除時に呼ばれます
        // インスタンスが削除されるタイミングで通知を無効化、削除しておきます
        coordinator.observations.forEach({ $0.invalidate() })
        coordinator.observations.removeAll()
    }
}

extension WebView {
    final class Coordinator: NSObject, WKNavigationDelegate {
        /// 親View
        let parent: WebView
        /// NSKeyValueObservations
        var observations: [NSKeyValueObservation] = []

        init(parent: WebView) {
            self.parent = parent
            // 通知を登録する
            let progressObservation = parent.webView.observe(\.estimatedProgress, options: .new, changeHandler: { _, value in
                parent.estimatedProgress = value.newValue ?? 0
            })
            let isLoadingObservation = parent.webView.observe(\.isLoading, options: .new, changeHandler: { _, value in
                parent.isLoading = value.newValue ?? false
            })
            observations = [
                progressObservation,
                isLoadingObservation
            ]
        }
        // 省略...
    }
}

创建进度条

现在,您拥有进度条所需的所有部分。
您要做的就是创建ProgressBarView.swift,通过RichWebView接收值并显示它。

ProgressBarView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct ProgressBarView: View {
    /// 読み込みの進捗状況
    var estimatedProgress: Double

    var body: some View {
        VStack {
            GeometryReader { geometry in
                Rectangle()
                    .foregroundColor(Color.gray)
                    .opacity(0.3)
                    .frame(width: geometry.size.width)
                Rectangle()
                    .foregroundColor(Color.blue)
                    .frame(width: geometry.size.width * CGFloat(estimatedProgress))
            }
        }.frame(height: 3.0)
    }
}

RichWebView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct RichWebView: View {
    // 省略...

    /// 読み込みの進捗状況
    @State private var estimatedProgress: Double = 0.0
    /// ローディング中かどうか
    @State private var isLoading: Bool = false

    var body: some View {
        VStack() {
            if isLoading {
                ProgressBarView(estimatedProgress: estimatedProgress)
            }
            // 省略...
        }
    }
}

您现在已经实现了一组功能。
还有其他要考虑的方面,例如错误处理,但我认为我们能够使用粗糙的功能创建WebView。

结论

这次,我旨在显示丰富的WebView来实现它,但是为了使用SwiftUI实现WebView,有必要使用UIViewRepresentable的基本功能来创建它,因此对于研究来说,我认为它是正确的。
如果您有兴趣,请尝试一下。
我将在下面的github上总结源代码,因此,如果您有兴趣,请看一下。
(github端包含一些此处省略的代码,例如导航栏的标题显示和布局限制。)
RichWebViewSample

另外,如果您对此实施有任何改进建议或意见,请提出意见,我们将不胜感激。
非常感谢你。

参考

  • UIViewRepresentable | Apple开发人员文档
  • 与UIKit交互— SwiftUI教程| Apple开发人员文档