SwiftUIメモ: 親ビューでリストを削除すると、子ビューが index out of range で落ちる

クラッシュするパターンとしないパターンの紹介。
とりあえず現状のメモ。

# クラッシュする例

* `List`の中で`ForEach`して、`.onDelete`やら`.onMove`を設定
* 行の中身は、データを**バインド渡し**した子ビューに任せる。
* 一見問題なく動くように見えるが、最終行を削除するとクラッシュ

```swift
import Foundation
import SwiftUI

// お店は商品を複数持っている
struct Shop {
    var name: String
    var items: [Item]
}
// 商品。とりあえず名前だけ。
struct Item: Identifiable {
    var id = UUID()
    var name: String
}

// 親ビュー 
struct SimpleList: View {

    // サンプルデータ。
    @State var shop = Shop(
        name: "くだもの屋さん",
        items: [
            Item(name: "りんご"),
            Item(name: "ばなな"),
            Item(name: "みかん")
        ]
    )
 
    var body: some View {
        VStack {
            Text(shop.name).font(.title)
            HStack(spacing:20) {
                Button(action: onAdd){ Image(systemName: "plus") }
                Spacer()
                EditButton()
            }
            List {
                // お店の商品でForEach
                ForEach(shop.items){ item in
                    // 子ビューにぜんぶ任せる、バインドで！
                    SimpleSubView(item: $shop.items[id2index(item.id)])
                }
                // 移動・削除の処理
                .onMove(perform: onMove)
                .onDelete(perform: onDelete)
            }
        }
        .padding()
    }

    // IDからリストのindex番号取得
    func id2index(_ id: UUID) -> Int {
        return shop.items.firstIndex(where: { $0.id == id })!
    }
    // お店の商品追加、移動、削除
    func onAdd() {
        let newItem = Item(name: "new item")
        shop.items.append(newItem)
    }
    func onMove(_ iset: IndexSet, _ newOffset: Int) {
        shop.items.move(fromOffsets: iset, toOffset: newOffset)
    }
    func onDelete(_ iset: IndexSet) {
        shop.items.remove(atOffsets: iset)
    }
}

//　子ビュー
struct SimpleSubView: View {
    // 編集したり色々する予定でバインドで受け取っている
    @Binding var item: Item  
    var body: some View {
        Text(item.name)    // が、今はとりあえず、単純に表示
    }
}
```

## 動作スクリーンショット

![ss2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351781/caadea29-a1d7-913f-d50e-7315b3749fdc.png "行追加できる")
![ss3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351781/e96b45c2-dca0-ebe4-d1e6-c8dec72802ec.png "編集モードで並べ替えできる")
![ss4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351781/eabd4094-c5fe-df75-b901-5a029e94a52b.png "削除も一見OKのように思えて、")
![ss5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/351781/09733675-550b-7e22-56df-61417031a1b8.png "最終行を削除するとクラッシュ")

## エラーメッセージ

> Fatal error: Index out of range: file Swift/ContiguousArrayBuffer.swift, line 444
> (タイムスタンプ) (プロジェクト名)[9600:1174243] Fatal error: Index out of range: file Swift/ContiguousArrayBuffer.swift, line 444
> (lldb) 

インデックスが範囲外…？

# クラッシュしない例

ググったり試したりして分かったことは、

* 「`ForEach(リスト, id:_)` てな風に明示的にidを指示せよ」的な記事を見つけたけど、いま現在（2021年1月）id指定しても関係なかった。
* **子ビューに渡さず**、直接ForEach内に書けばクラッシュしない
* バインドではなく、**値で**子ビューに渡せばクラッシュしない

ForEach内に長々とコードを書くのは、後々見づらくて後悔しそうな気がする。
なので、子ビューへは値で渡しつつ、何とかして**子ビューから元のリストを更新**する方向で考えた。

## リストはリストで別途、バインドで渡してみた

```swift:前述のコードからの変更点
// 親ビューのForEachの中
// SimpleSubView(item: $shop.items[id2index(item.id)])
SimpleSubView(item: item, items: $shop.items)
// 親の id2index(UUID)->Int は不要になる。

// 子ビュー
struct SimpleSubView: View {
    @State var item: Item          // 普通に値で受け取る
    @Binding var items: [Item]     // 加えてリスト全体をバインドで受け取る

    var body: some View {
        HStack {
            // 値で受け取ったitemで編集。コミット時にバインドされたリストに反映
            TextField("name", text: $item.name, onCommit: commit)
        }
    }

    // 値をリストに反映する
    func commit() {
        // リストから自分のIDを探してインデックス取得。
        // 見つからない（たぶん親が削除した）なら、何もせず終了。
        guard let index = items.firstIndex(where: { $0.id == item.id })
        else { return }
        // 編集内容をリスト内の商品に反映。
        // indexが存在することは確認済み、out of index とは言わせないぞ
        items[index].name = item.name
    }
}

```

消しても大丈夫になった。

* リストをバインドで渡す代わりに、`@EnvironmentObject`に入れる手もある。
    * そっちに上記の`commit()`相当のメソッド作って、子ビューでは`item`丸ごと渡すだけ、にすれば楽そう
* 子ビューで`commit()`を呼ぶタイミングは要検討。
    * `TextField`の`onCommit`はエンターキー押したときとかなので、操作方法によって発生しないケースもあるかも
    * 編集画面と表示画面が別で、編集終わったら画面切り替わる、とかだったら、`.onDisappear`で確実に反映できるのでは？