はじめに
SwiftUIでリストコンテンツを表示するような画面を作る際に、List
やForEach
等のビューを使用する場面があるかと思います。
下のコードでは、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オブジェクトのコピーを渡している
}
RankingRow
がMovie
型の実体を受け取る設計であれば何ら問題ありませんが、もしRankingRow
がMovie
のBinding
オブジェクトを受け取りたいとなった場合にはどうすればいいでしょうか。
ForEachのイニシャライザ
次のようにviewModel.movies
のBinding
オブジェクトをそのまま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:)
を呼び出してしまうのですが、このイニシャライザはImmutable
なRange
オブジェクト向けとなっており、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:
を付け忘れていないか確認してみましょう。