背景
UIKit の時代では、下記のサンプルコードのように、子ビューの処理を親ビューやビューコントローラーから呼び出す設計が多々あるかと思います:
final class ChildView: UIView {
// ...
func refresh() {
// do some refreshing task
}
}
final class ParentViewController: UIViewController {
@IBOutlet var childView: ChildView!
// ...
@IBAction func refreshChildView(sender: UIButton) {
childView.refresh()
}
}
上記の例では、ParentViewController
は ChildView
のほかに、ボタンも持っており、ボタンをタップした際に childView.refresh()
を呼び出すように設定しています。
課題
ところが、同じような設計を SwiftUI で作ろうとすると、問題が起きます。SwiftUI の View
はただの描画レシピであって、子ビューのインスタンスを取得できないから、そのインスタンスのメソッドも当然ながらそのまま呼び出せないです。
解決策
その1
まずは一つの Workaround として、トリガー用の状態を作る方法を思いつく人も多いかと思います:
struct ChildView: View {
var refreshingID: UUID
var body: some View {
SomeViewContents()
.onChange(of: refreshingID) { _, _ in // refreshingID の値に意味はない
refresh()
}
}
// ...
func refresh() {
// do some refreshing task
}
}
struct ParentView: View {
@State private var refreshingID = UUID()
var body: some View {
HStack {
ChildView(refreshingID: refreshingID)
Button("refresh") {
refreshingID = UUID()
}
}
}
}
この方法はやりたいこととしてはできなくはないですが、慣れてない人からしてみたらやはり refreshingID
の存在がなんか紛らわしいし、それと refresh()
の関係も直感的とは言いにくいと思います。そして何より、今の ChildView
は普通の View
だからまだ onChange(of:)
が使えて楽ですが、タイトルのような UIViewRepresentable
なら onChange(of:)
は使えないので、Coordinator
の中で親から受け取った refreshingID
を保持して比較処理を書かないといけないから、まあ書きやすいとは言い難いです。
その2
次に思いつきやすい方法は、そもそも refresh
処理自体を親に持たせるよう作ることかと思います:
struct ParentView: View {
var body: some View {
HStack {
ChildView()
Button("Refresh") {
refresh()
}
}
}
// ...
func refresh() {
// do some refreshing task
}
}
これならそもそも ChildView
がどんなものかあまり関係なくなってしまいますので、見た目もスッキリするし、こう書けるならこれに越したことはないかもしれませんが、設計上 refresh
処理を ChildView
スコープ内に閉じたかったり、特に UIViewRepresentable
の場合はそもそもその処理が本来の UIView
の中にあって親に移動できないことも多々あると思います。なのでやはり親から refresh()
を呼び出したいだけで、その処理の実装はできれば避けたいです。
その3
肌感覚的にそんなに知られていないようですが、実は子供の状態を親に伝搬するのに、SwiftUI には Preference と言う仕組みもあります。なので「処理」自身も一種の「状態」とみなせば、このようなやり方もあるかと思います:
struct RefreshAction: Equatable {
private var childView: ChildView?
init(childView: ChildView?) {
self.childView = childView
}
static func == (lhs: Self, rhs: Self) -> Bool {
// 実際の実装では `childView` ではなく何かしらオブジェクトを持つことが多いかと思いますので、この例のような無理矢理な比較ではなくそのままオブジェクトインスタンスの同一比較すればいいと思います
switch (lhs.childView, rhs.childView) {
case (nil, nil),
(.some, .some):
return true
default:
return false
}
}
func callAsFunction() {
childView?.refresh()
}
}
struct RefreshActionKey: PreferenceKey {
static var defaultValue: RefreshAction = .init(childView: nil)
static func reduce(value: inout RefreshAction, nextValue: () -> RefreshAction) {
value = nextValue()
}
}
struct ChildView: View {
var body: some View {
SomeViewContents()
.preference(key: RefreshActionKey.self, value: .init(childView: self))
}
}
struct ParentView: View {
@State private var refresh: RefreshAction?
var body: some View {
HStack {
ChildView()
Button("Refresh") {
refresh?()
}
}
}
}
このやり方なら、子供の ChildView
から RefreshAction
を受け取るだけで、必要な時にそれを refresh?()
で呼び出せるので、一見最適解かもしれませんが、ちょっと致命的な問題点があります:タイトルに書いてある UIViewRepresentable
内部からは Preference の設定ができないのです。なので普通の View
ならこの方法が一番お勧めしたいのですが、UIViewRepresentable
からはこのまま使えないです。
本命
ではどうすればいいかと言うと、やはり微妙なところもありますが、個人的に一番お勧めしたい方法は ChildView
の init
時に RefreshAction
を渡す方法です:
struct RefreshAction: Equatable {
private weak var uiView: ChildView.UIViewType?
init(uiView: ChildView.UIViewType?) {
self.uiView = uiView
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.uiView === rhs.uiView
}
func callAsFunction() {
uiView?.refresh()
}
}
struct ChildView: UIViewRepresentable {
var refreshActionPassing: @MainActor (RefreshAction) -> Void
func makeUIView(context: Context) -> SomeUIView {
let uiView = SomeUIView()
passRefreshAction(.init(uiView: uiView))
return uiView
}
@MainActor
func passRefreshAction(_ action: RefreshAction) {
Task {
refreshActionPassing(action)
}
}
}
struct ParentView: View {
@State private var refresh: RefreshAction?
var body: some View {
HStack {
ChildView(refreshActionPassing: {
refresh = $0
})
Button("Refresh") {
refresh?()
}
}
}
}
この方法の最大のメリットは、ParentView
から直接明示的に refresh
を呼び出せながら、refresh
の詳細実装に関心を持たずに済むところです。特にまだ SwiftUI に対応していない 3rd Party ライブラリーでまだ UIView
バージョンのビューしか提供していないことが多いですが、これで SwiftUI からでも扱いやすくなったと思います。もちろん最初に紹介した refreshingID
を利用した方法でも同じことができますが、やはり呼び出しが直感的ではないので、個人的にはあまり好みではないです。
とは言え、このような状態伝搬の仕方はやはり一般的ではないから、伝搬処理の関数に Passing
後置詞を入れるなど、できる限りの工夫も必要かと思います。
またこの方法で実装するとき、気をつけないといけないのは ChildView
の passRefreshAction(_:)
の実装です。見ての通りその中では別に非同期処理が全くないにも関わらず Task
で処理を囲んでいます。なぜかというと、makeUIView
の実装でこの passRefreshAction
を呼び出すと、refreshAction
が親に渡された後、そのまま @State
に入れられると想定されますが、makeUIView
はビューのレンダリング中に呼ばれる処理ですので、すなわちビューのレンダリング中に @State
を更新することになります。その結果、SwiftUI ではお馴染みの Modifying state during view update, this will cause undefined behavior
ワーニングが発生します。なので、ここで Task
で囲むことによって、ビューのレンダリング後のサイクルで @State
を更新しますから、このワーニングを回避します。
まとめ
以上、4つの方法をご紹介してきましたが、状況を見て最適な設計を選んでいただけたら幸いです。