3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UIViewRepresentableの処理を親から呼び出す方法

Last updated at Posted at 2024-09-17

背景

UIKit の時代では、下記のサンプルコードのように、子ビューの処理を親ビューやビューコントローラーから呼び出す設計が多々あるかと思います:

ChildView.swift
final class ChildView: UIView {
    // ...
    func refresh() {
        // do some refreshing task
    }
}
ParentViewController.swift
final class ParentViewController: UIViewController {
    @IBOutlet var childView: ChildView!
    // ...
    @IBAction func refreshChildView(sender: UIButton) {
        childView.refresh()
    }
}

上記の例では、ParentViewControllerChildView のほかに、ボタンも持っており、ボタンをタップした際に childView.refresh() を呼び出すように設定しています。

output.gif

課題

ところが、同じような設計を SwiftUI で作ろうとすると、問題が起きます。SwiftUI の View はただの描画レシピであって、子ビューのインスタンスを取得できないから、そのインスタンスのメソッドも当然ながらそのまま呼び出せないです。

解決策

その1

まずは一つの Workaround として、トリガー用の状態を作る方法を思いつく人も多いかと思います:

ChildView.swift
struct ChildView: View {
    var refreshingID: UUID
    var body: some View {
        SomeViewContents()
            .onChange(of: refreshingID) { _, _ in // refreshingID の値に意味はない
                refresh()
            }
    }
    // ...
    func refresh() {
        // do some refreshing task
    }
}
ParentView.swift
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 処理自体を親に持たせるよう作ることかと思います:

ParentView.swift
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 と言う仕組みもあります。なので「処理」自身も一種の「状態」とみなせば、このようなやり方もあるかと思います:

RefreshAction.swift
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()
    }
}
ChildView.swift
struct ChildView: View {
    var body: some View {
        SomeViewContents()
            .preference(key: RefreshActionKey.self, value: .init(childView: self))
    }
}
ParentView.swift
struct ParentView: View {
    @State private var refresh: RefreshAction?
    var body: some View {
        HStack {
            ChildView()
            Button("Refresh") {
                refresh?()
            }
        }
    }
}

このやり方なら、子供の ChildView から RefreshAction を受け取るだけで、必要な時にそれを refresh?() で呼び出せるので、一見最適解かもしれませんが、ちょっと致命的な問題点があります:タイトルに書いてある UIViewRepresentable 内部からは Preference の設定ができないのです。なので普通の View ならこの方法が一番お勧めしたいのですが、UIViewRepresentable からはこのまま使えないです。

本命

ではどうすればいいかと言うと、やはり微妙なところもありますが、個人的に一番お勧めしたい方法は ChildViewinit 時に RefreshAction を渡す方法です:

RefreshAction.swift
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()
    }
}
ChildView.swift
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)
        }
    }
}
ParentView.swift
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 後置詞を入れるなど、できる限りの工夫も必要かと思います。

またこの方法で実装するとき、気をつけないといけないのは ChildViewpassRefreshAction(_:) の実装です。見ての通りその中では別に非同期処理が全くないにも関わらず Task で処理を囲んでいます。なぜかというと、makeUIView の実装でこの passRefreshAction を呼び出すと、refreshAction が親に渡された後、そのまま @State に入れられると想定されますが、makeUIView はビューのレンダリング中に呼ばれる処理ですので、すなわちビューのレンダリング中に @State を更新することになります。その結果、SwiftUI ではお馴染みの Modifying state during view update, this will cause undefined behavior ワーニングが発生します。なので、ここで Task で囲むことによって、ビューのレンダリング後のサイクルで @State を更新しますから、このワーニングを回避します。

まとめ

以上、4つの方法をご紹介してきましたが、状況を見て最適な設計を選んでいただけたら幸いです。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?