ForEachで値のほかにインデックスも使う方法(SwiftUI)

## はじめに

本記事は [SwiftUI Advent Calendar 2022](https://qiita.com/advent-calendar/2022/swiftui) の4日目の記事です。
昨日は @fus1ondev さんで [【macOS】SwiftUIだけでメニューバー常駐アプリを作ろう](https://qiita.com/fus1ondev/items/020bb96d01ae57afed48) でした。

SwiftUI の `ForEach` で、値のほかにインデックスも使う方法を紹介します。

## 環境

- OS: macOS Ventura 13.5.2
- Swift: 5.9
- Xcode: 15.0 (15A240d)

## 結論: indexed()を使う

先に結論です。

[Swift Algorithms](https://github.com/apple/swift-algorithms) の [`indexed()`](https://github.com/apple/swift-algorithms/blob/195e0316d7ba71e134d0f6c677f64b4db6160c46/Guides/Indexed.md) を使います。

Swift AlgorithmsはAppleが提供しているライブラリで、2021/09に安定版の1.0.0がリリースされたので、実務でも安心して使っていいと思います。

```diff_swift:MonsterView.swift
+ 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)")
    }
}
```

## 理由

一見すると、インデックスだけをループさせる方法でも問題なさそうです。

```diff_swift
// 🔺
ForEach(0..<monsters.count, id: \.self) { index in
    let monster = monsters[index]
      // ...
}
```

しかし配列のインデックスは不安定であり、 `ForEach` の `id` にはstableな（安定した）値を渡さないと、idによってViewを正しく追跡できなくなります。
例えばmove時のアニメーションが発生しなくなります。(なぜ発生しなくなるかは後述します）

`indexed()` を使うなら `index` は配列の要素の順序によって変わるので、 `id` は `\.index` でなく `\.element` を使うとアニメーションが正しく発生します。
配列の要素を `Hashable` に準拠させる必要があるのに注意です。

以下はサンプルコードです。
よかったら実際に実行して試してみてください。

```swift
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()を使う

```swift
ForEach(Array(monsters.enumerated()), id: \.element) { index, monster in
  // ...
}
```

`enumerated()` を使うと、Swift Algorithmsを導入せずに標準のAPIのみで実現できます。
`id` には `\.offset` でなく `\.element` などstableな値を使いましょう。

## おまけ

おまけです。

### なぜ移動時にアニメーションが発生しなかったか

あくまで推測ですが、私はこのように解釈しました。

まず以下の `ForEach` の展開後を想像します。

```swift
@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)
}
```

実際は異なるかもしれませんが、このようにイメージできます。

```diff_swift
// ❌: 移動のアニメーションが有効にならない
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` を入れ替えたときを想像します。

```diff_swift
@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` を指定した場合の展開を想像します。

```diff_swift
// ✅: 移動のアニメーションが有効になる
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` に使います。

```diff_swift:Monsterに可変なプロパティが存在する場合の実装
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を正しく追跡することができます。

私はこれで不具合を発生させてしまったので、みなさんもお気をつけください。

https://github.com/uhooi/Loki/issues/191

## おわりに

これでForEachでインデックスが欲しいときも迷わずに実装できます :relaxed:

以上 [SwiftUI Advent Calendar 2022](https://qiita.com/advent-calendar/2022/swiftui) の4日目の記事でした。
明日も私で「ローカライズのtips」です。

## 参考リンク

- [Fix ForEach by uhooi · Pull Request #9 · uhooi/Loki](https://github.com/uhooi/Loki/pull/9)
- [Fix refreshing SakatsuList by uhooi · Pull Request #194 · uhooi/Loki](https://github.com/uhooi/Loki/pull/194)
- https://x.com/kntkymt/status/1702759068623053043
- https://zenn.dev/kntk/articles/1f1b40da6fe181#explicit-identityにはuniqueかつstableなidを使おう
- [ID | Apple Developer Documentation](https://developer.apple.com/documentation/swift/identifiable/id-swift.associatedtype)
- [【SwiftUI】Listの並び替えを実装する | DevelopersIO](https://dev.classmethod.jp/articles/swiftui-list-rearrange/)