[SwiftUI] Listの削除でFatal error: Index out of rangeが出る問題を解消する
状況
SwiftUIのList
を使う上で大きな悩みの種が「アイテムを削除すると落ちることがある」です。あちこちのサイトで言及されていますが、どの対処法もさっぱり通用せず困りました。
この記事では以下のような方法を提案します。原理的に落ちる可能性を排除できているはずです。
-
Element
がIdentifiable
である条件のもと-
List
で落ちない -
index
を取得できる -
array[index]
と$array[index]
がともに取得できる - 呼び出しが簡単
-
考え方
まず、経験的にForEach(data.indices, id: \.self)
を用いるよりもForEach(data)
を用いる方がこのバグの発生が少ない、またはなくなります。そこでdata
のelement
をIdentifiable
にした上で後者のイニシャライザを用いていくのが基本的な対処の方針になります。これで上手く行けばひとまずそうしましょう。
しかしこの方法では、data
の要素をバインドしたい場合に不都合が生じます。ForEach(data){datum ...}
とした場合、datum
からBinding
で包まれた$datum
を作り出すことができないからです。また、インデックスを取りたい場合もこの方法では不都合になってしまいます。
以前この記事で書いたBinding<Array<T>>
をIdentifiable
に準拠させる例は、Binding
を取り出せない問題の対処に使われていました。こうすることでForEach($data)
という書き方ができるようになり、Binding
で包まれた形のdatum
を取り出すことができます。
ところが今回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