43
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SwiftUIにおけるForEach内からのBindingオブジェクトの渡し方

Last updated at Posted at 2019-12-21

はじめに

SwiftUIでリストコンテンツを表示するような画面を作る際に、ListForEach等のビューを使用する場面があるかと思います。

下のコードでは、RankingListリストビューを持つ画面が、自身のmovies配列の値を基に、リスト内の各行のビューRankingRowを生成しています。


class RankingViewModel: ObservableObject {
    @Published var movies: [Movie]
    ...
}

struct RankingList: View {
    @ObservedObject var viewModel: RankingViewModel

    var body: some View {
        List {
            RankingHeader(&viewModel.genre)
            ForEach(viewModel.movies) { movie in
                RankingRow(movie: movie)
            }
        }
    }
}

この方法でRankingRowに渡しているのはmovies配列の要素のコピーとなります。

ForEach(viewModel.movies) { movie in
    RankingRow(movie: movie) // Movieオブジェクトのコピーを渡している
}

RankingRowMovie型の実体を受け取る設計であれば何ら問題ありませんが、もしRankingRowMovieBindingオブジェクトを受け取りたいとなった場合にはどうすればいいでしょうか。

ForEachのイニシャライザ

次のようにviewModel.moviesBindingオブジェクトをそのままForEachに渡したいところですが、残念ながらできません。

ForEach($viewModel.movies) {...} // NG: Binding<[Movie]>を渡そうとしている
ForEach(viewModel.$movies) {...} // NG: Published<[Movie]>.Publisherを渡そうとしている

定義を見てみると分かりますが、ForEachの3つのイニシャライザはRandomAccessCollectionに準拠したオブジェクトか、もしくはRange<Int>型の値しか受け取らないためです。

struct ForEach<Data, ID, Content> where Data : RandomAccessCollection, ID : Hashable {

    // 1
    init(_ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content)

    // 2
    init(_ data: Data, id: KeyPath<Data.Element, ID>, content: @escaping (Data.Element) -> Content)

    // 3
    init(_ data: Range<Int>, @ViewBuilder content: @escaping (Int) -> Content)
}
// ※ 一部抜粋

何らかの配列を渡す方法を別途考えてみます。

Array.indices

SwiftのArrayには、自身の要素のインデックス範囲を返すindicesプロパティがあります。

@inlinable public var indices: Range<Int> { get }

Range<Int>は上記3のinit(_, content:)に渡すことのできる型ですが、Range自体がRandomAccessCollectionに適合しているため、つまり上記1、2のイニシャライザにも渡すことができます。

今回はこの値を上記2のinit(_, id:, content:)に渡すことで当初の問題を解決します。

ForEach(viewModel.movies.indices, id: \.self) {...}

まとめ

class RankingViewModel: ObservableObject {
    @Published var movies: [Movie]
    ...
}

struct RankingList: View {
    @ObservedObject var viewModel: RankingViewModel

    var body: some View {
        List {
            RankingHeader(&viewModel.genre)
            ForEach(viewModel.movies.indices, id: \.self) { index in
                RankingRow(movie: self.$viewModel.movies[index]) // OK: Binding<Movie>を渡している!
            }
        }
    }
}

ForEach内で各要素のindexを受け取ることができるようになった為、viewModel.movies配列の各要素を子ビューにバインディングして渡せるようになりました。


注意点として、ForEach生成時の引数に忘れずにid:を指定し、上記2のinit(_, id:, content:)を呼び出す必要があります。

引数id:を指定しない場合、コンパイルは問題なく通って上記3のinit(_, content:)を呼び出してしまうのですが、このイニシャライザはImmutableRangeオブジェクト向けとなっており、Rangeオブジェクトに変化があったとしてもSwiftUIはその変更をキャッチしてビューの更新を行ってくれません。

id:を指定せずにRange<Int>を渡した場合、初期化後のオブジェクトの更新時に以下のようなログが出力されます。

ForEach<Range<Int>, Int, RankingRow> count (3) != its initial count (0). `ForEach(_:content:)` should only be used for *constant* data. Instead conform data to `Identifiable` or use `ForEach(_: id:content:)` and provide an explicit `id`!

この警告が表示された際は、今一度id:を付け忘れていないか確認してみましょう。

43
25
2

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
43
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?