はじめに
本記事は SwiftUI Advent Calendar 2022 の4日目の記事です。
昨日は @fus1ondev さんで 【macOS】SwiftUIだけでメニューバー常駐アプリを作ろう でした。
SwiftUI の ForEach
で、値のほかにインデックスも使う方法を紹介します。
環境
- OS: macOS Ventura 13.5.2
- Swift: 5.9
- Xcode: 15.0 (15A240d)
結論: indexed()を使う
先に結論です。
Swift Algorithms の indexed()
を使います。
Swift AlgorithmsはAppleが提供しているライブラリで、2021/09に安定版の1.0.0がリリースされたので、実務でも安心して使っていいと思います。
+ import Algorithms
private struct Monster: Hashable {
let name: String
}
struct MonstersView: View {
private let monsters: [Monster] = [.init(name: "uhooi")]
var body: some View {
- ForEach(monsters) { monster in
- Text(monster.name)
+ ForEach(monsters.indexed(), id: \.element) { index, monster in
+ Text("No.\(index): \(monster.name)")
}
}
理由
一見すると、インデックスだけをループさせる方法でも問題なさそうです。
// 🔺
ForEach(0..<monsters.count, id: \.self) { index in
let monster = monsters[index]
// ...
}
しかし配列のインデックスは不安定であり、 ForEach
の id
にはstableな(安定した)値を渡さないと、idによってViewを正しく追跡できなくなります。
例えばmove時のアニメーションが発生しなくなります。(なぜ発生しなくなるかは後述します)
indexed()
を使うなら index
は配列の要素の順序によって変わるので、 id
は \.index
でなく \.element
を使うとアニメーションが正しく発生します。
配列の要素を Hashable
に準拠させる必要があるのに注意です。
以下はサンプルコードです。
よかったら実際に実行して試してみてください。
import SwiftUI
import Algorithms
private struct Monster: Hashable {
let name: String
}
struct ContentView: View {
@State private var monsters: [Monster] = [
.init(name: "uhooi"),
.init(name: "ayausa"),
.init(name: "chibird"),
]
var body: some View {
VStack {
// ❌: 移動のアニメーションが有効にならない
List {
ForEach(0..<monsters.count, id: \.self) { index in
let monster = monsters[index]
Text("No.\(index): \(monster.name)")
}
.onMove(perform: moveRow)
}
// ❌: 移動のアニメーションが有効にならない
List {
ForEach(Array(monsters.enumerated()), id: \.offset) { index, monster in
Text("No.\(index): \(monster.name)")
}
.onMove(perform: moveRow)
}
// ✅: 移動のアニメーションが有効になる
List {
ForEach(Array(monsters.enumerated()), id: \.element) { index, monster in
Text("No.\(index): \(monster.name)")
}
.onMove(perform: moveRow)
}
// ❌: 移動のアニメーションが有効にならない
List {
ForEach(monsters.indexed(), id: \.index) { index, monster in
Text("No.\(index): \(monster.name)")
}
.onMove(perform: moveRow)
}
// ✅: 移動のアニメーションが有効になる
List {
ForEach(monsters.indexed(), id: \.element) { index, monster in
Text("No.\(index): \(monster.name)")
}
.onMove(perform: moveRow)
}
// ✅: 移動のアニメーションが有効になる
List {
ForEach(monsters, id: \.self) { monster in
Text(monster.name)
}
.onMove(perform: moveRow)
}
}
}
}
// MARK: - Privates
private extension ContentView {
func moveRow(from source: IndexSet, to destination: Int) {
withAnimation {
monsters.move(fromOffsets: source, toOffset: destination)
}
}
}
他の方法
他の方法も紹介します。
enumerated()を使う
ForEach(Array(monsters.enumerated()), id: \.element) { index, monster in
// ...
}
enumerated()
を使うと、Swift Algorithmsを導入せずに標準のAPIのみで実現できます。
id
には \.offset
でなく \.element
などstableな値を使いましょう。
おまけ
おまけです。
なぜ移動時にアニメーションが発生しなかったか
あくまで推測ですが、私はこのように解釈しました。
まず以下の ForEach
の展開後を想像します。
@State private var monsters: [Monster] = [
.init(name: "uhooi"),
.init(name: "ayausa"),
.init(name: "chibird"),
]
// ...
// ❌: 移動のアニメーションが有効にならない
List {
ForEach(monsters.indexed(), id: \.index) { index, monster in
Text("No.\(index): \(monster.name)")
}
.onMove(perform: moveRow)
}
実際は異なるかもしれませんが、このようにイメージできます。
// ❌: 移動のアニメーションが有効にならない
List {
- ForEach(monsters.indexed(), id: \.index) { index, monster in
- Text("No.\(index): \(monster.name)")
- }
+ Text("No.0: uhooi")
+ .id(0)
+ Text("No.1: ayausa")
+ .id(1)
+ Text("No.2: chibird")
+ .id(2)
.onMove(perform: moveRow)
}
次に uhooi
と ayausa
を入れ替えたときを想像します。
@State private var monsters: [Monster] = [
- .init(name: "uhooi"),
- .init(name: "ayausa"),
+ .init(name: "ayausa"),
+ .init(name: "uhooi"),
.init(name: "chibird"),
]
// ...
// ❌: 移動のアニメーションが有効にならない
List {
- Text("No.0: uhooi")
+ Text("No.1: ayausa")
.id(0)
- Text("No.1: ayausa")
+ Text("No.0: uhooi")
.id(1)
Text("No.2: chibird")
.id(2)
.onMove(perform: moveRow)
}
Text
の id
は変わらずに、中の文字列だけ変わるとイメージできます。
そのためViewが移動したとみなされず、アニメーションが発生しなかったと推測できます。
ForEach
の id
に \.element
を指定した場合の展開を想像します。
// ✅: 移動のアニメーションが有効になる
List {
- Text("No.0: uhooi")
- .id(Monster(name: "uhooi"))
- Text("No.1: ayausa")
- .id(Monster(name: "ayausa"))
+ Text("No.1: ayausa")
+ .id(Monster(name: "ayausa"))
+ Text("No.0: uhooi")
+ .id(Monster(name: "uhooi"))
Text("No.2: chibird")
.id(2)
.onMove(perform: moveRow)
}
1番目と2番目の Text
の id
が入れ替わっているので、移動のアニメーションが発生したと推測できます。
elementがstableでない場合がある
おまけといいつつ非常に重要な内容です。
上記の例では \.element
を ForEach
の id
として使いましたが、それだと問題が発生する場合があります。
Monster
の name
が var
で、更新される可能性があるときです。
name
が変わると \.element
のハッシュ値も変わるので、 \.index
と同様にViewを正しく追跡できなくなります。
そのため可変なプロパティを持つ要素の場合、要素自体を Hashable
に準拠させるのでなく、UUIDのように不変で一意に決まる値をプロパティに持たせるのが望ましいです。
つまり要素を Hashable
でなく Identifiable
に準拠させ、 id
を ForEach
の id
に使います。
import Algorithms
- private struct Monster: Hashable {
+ private struct Monster: Identifiable {
+ let id = UUID()
- let name: String
+ var name: String
}
struct MonstersView: View {
private let monsters: [Monster] = [.init(name: "uhooi")]
var body: some View {
- ForEach(monsters.indexed(), id: \.element) { index, monster in
+ ForEach(monsters.indexed(), id: \.element.id) { index, monster in
Text("No.\(index): \(monster.name)")
}
}
これで Monster
の name
が変わっても id
は変わらないので、Viewを正しく追跡することができます。
私はこれで不具合を発生させてしまったので、みなさんもお気をつけください。
おわりに
これでForEachでインデックスが欲しいときも迷わずに実装できます
以上 SwiftUI Advent Calendar 2022 の4日目の記事でした。
明日も私で「ローカライズのtips」です。
参考リンク
- Fix ForEach by uhooi · Pull Request #9 · uhooi/Loki
- Fix refreshing SakatsuList by uhooi · Pull Request #194 · uhooi/Loki
- https://x.com/kntkymt/status/1702759068623053043
- https://zenn.dev/kntk/articles/1f1b40da6fe181#explicit-identityにはuniqueかつstableなidを使おう
- ID | Apple Developer Documentation
- 【SwiftUI】Listの並び替えを実装する | DevelopersIO