LoginSignup
24
3

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

Last updated at Posted at 2022-12-04

はじめに

本記事は 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 Algorithmsindexed() を使います。

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

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)")
    }
}

理由

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

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

しかし配列のインデックスは不安定であり、 ForEachid には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)
}

次に uhooiayausa を入れ替えたときを想像します。

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

Textid は変わらずに、中の文字列だけ変わるとイメージできます。
そのためViewが移動したとみなされず、アニメーションが発生しなかったと推測できます。

ForEachid\.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番目の Textid が入れ替わっているので、移動のアニメーションが発生したと推測できます。

elementがstableでない場合がある

おまけといいつつ非常に重要な内容です。

上記の例では \.elementForEachid として使いましたが、それだと問題が発生する場合があります。
Monsternamevar で、更新される可能性があるときです。
name が変わると \.element のハッシュ値も変わるので、 \.index と同様にViewを正しく追跡できなくなります。

そのため可変なプロパティを持つ要素の場合、要素自体を Hashable に準拠させるのでなく、UUIDのように不変で一意に決まる値をプロパティに持たせるのが望ましいです。
つまり要素を Hashable でなく Identifiable に準拠させ、 idForEachid に使います。

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)")
    }
}

これで Monstername が変わっても id は変わらないので、Viewを正しく追跡することができます。

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

おわりに

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

以上 SwiftUI Advent Calendar 2022 の4日目の記事でした。
明日も私で「ローカライズのtips」です。

参考リンク

24
3
6

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
24
3