👏

[SwiftUI] Listの削除でFatal error: Index out of rangeが出る問題を解消する

2021/06/04に公開

状況

SwiftUIのListを使う上で大きな悩みの種が「アイテムを削除すると落ちることがある」です。あちこちのサイトで言及されていますが、どの対処法もさっぱり通用せず困りました。

https://www.yururiwork.net/【swiftui】謎のindex-out-of-rangeクラッシュで苦戦した話/
https://developer.apple.com/forums/thread/670154
https://nashikachi.hatenablog.com/entry/2021/01/27/221317
https://www.reddit.com/r/SwiftUI/comments/ib50r2/fatal_error_index_out_of_range_file/

この記事では以下のような方法を提案します。原理的に落ちる可能性を排除できているはずです。

  • ElementIdentifiableである条件のもと
    • Listで落ちない
    • indexを取得できる
    • array[index]$array[index]がともに取得できる
    • 呼び出しが簡単

考え方

まず、経験的にForEach(data.indices, id: \.self)を用いるよりもForEach(data)を用いる方がこのバグの発生が少ない、またはなくなります。そこでdataelementIdentifiableにした上で後者のイニシャライザを用いていくのが基本的な対処の方針になります。これで上手く行けばひとまずそうしましょう。

しかしこの方法では、dataの要素をバインドしたい場合に不都合が生じます。ForEach(data){datum ...}とした場合、datumからBindingで包まれた$datumを作り出すことができないからです。また、インデックスを取りたい場合もこの方法では不都合になってしまいます。

以前この記事で書いたBinding<Array<T>>Identifiableに準拠させる例は、Bindingを取り出せない問題の対処に使われていました。こうすることでForEach($data)という書き方ができるようになり、Bindingで包まれた形のdatumを取り出すことができます。

https://zenn.dev/en3_hcl/articles/ca6598e0493049

ところが今回iOS14.5で挙動が変わり、この方法ではListで落ちるようになってしまいました。そこで新たな方法を捻り出しました。

残念ながら細かな理由はわからないものの、落ちる理由がインデックスに関する何かであることは確かです。そこで都度indicesに基づいて配列を生成し直し、それをForEachに突っ込めば何があっても大丈夫に思えます。

そこでまずこのようにしました。

extension Binding where Value: RandomAccessCollection & MutableCollection, Value.Element: Identifiable, Value.Index: Hashable {
    struct IdentifiableItem: Identifiable {
        @Binding<Value.Element> private(set) var item: Value.Element
        let index: Value.Index
        let id: Value.Element.ID
    }

    var identifiableItems: [IdentifiableItem] {
        self.wrappedValue.indices.map { i in
            .init(
                bindedItem: self[i],
		index: i,
                id: self.wrappedValue[i].id
            )
        }
    }
}

残念ながらこれでは上手くいきませんでした。やはり落ちてしまうのです。
identifiableItemsの生成を行っている箇所でエラーになっているわけではありません。つまりself.wrappedValue[i].idの取得部分で落ちているわけではありません。

ということはおそらく、消去法でbindedItemが原因で落ちていることになります。self[i]とはBinding<Value.Element>型の値ですが、これはSwiftUI側の実装によって生成されたものをそのまま使っている形です。これが絡んでいる可能性は大いにあります。

以下のようにBindingを再度生成し直してみると、見事に落ちなくなりました。

extension Binding where Value: RandomAccessCollection & MutableCollection, Value.Element: Identifiable {
    struct IdentifiableItem: Identifiable {
        @Binding<Value.Element> private(set) var item: Value.Element
        let index: Value.Index
        let id: Value.Element.ID
    }

    var identifiableItems: [IdentifiableItem] {
        // Listが再描画の際に落ちる問題は、Bindingが悪さをしているせいだと考えられる。
        // そこでここでは`Binding`を生成し直すことで対処している。今のところうまく行っている。
        return self.wrappedValue.indices.map { i in
            return .init(
                item: .init {
                    self.wrappedValue[i]
                } set: {newValue in
                    self.wrappedValue[i] = newValue
                },
                index: i,
                id: self.wrappedValue[i].id
            )
        }
    }
}

理由はわかりませんが、self[i]が古いインデックスをキャプチャしたりしていたのでしょうか。この部分が何かしらの悪さをしていたようです。

さて、ともかくこれを使うと以下のように書けるようになります。

List($items.identifiableItems) { value in
    // value.$item が Binding<Value.Element>
    // value.item が Value.Element
    // value.index が Valueのインデックス
}

助けになれば嬉しいです。

Discussion