✨
[SwiftUI]Listの削除でout of rangeになる問題を解消する
何が問題になったか
下記のように、List,ForEachの中でカスタムViewを使いBindingで値を渡している場合、要素を削除するとクラッシュが起きました。
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
List {
ForEach(self.viewModel.fruits.indices, id: \.self) { index in
Row(fruit: $viewModel.fruits[index])
}.onDelete(perform: { indexSet in
self.viewModel.delete(indices: indexSet)
})
}
.listStyle(GroupedListStyle())
}
}
}
struct Row: View {
@Binding var fruit: Fruit
var body: some View {
HStack {
Text(fruit.name)
.foregroundColor(self.fruit.visible ? .black : .gray)
Spacer()
Toggle("", isOn: $fruit.visible)
}
}
}
class ViewModel: ObservableObject {
@Published var fruits: [Fruit] = [
.init(name: "Apple", visible: true),
.init(name: "Banana", visible: true),
.init(name: "Cherry", visible: true),
.init(name: "Durian", visible: true),
.init(name: "Grape", visible: true),
.init(name: "Kiwi", visible: true),
]
}
extension ViewModel {
func delete(indices: IndexSet) {
fruits.remove(atOffsets: indices)
}
}
struct Fruit {
let name: String
var visible: Bool //表示を変更する
}
Row
の中で、Toggle
を使い表示状態を更新しています。
これの削除を実行すると、下記のようなクラッシュが発生します。
Index out of range: file Swift/ContiguousArrayBuffer.swift, line 500
考えられる原因
この記事にあるように、サブViewの中でBinding
を使っている場合に起きることがあるようです。
独自に調査した結果、原因はここにあるようです。
struct Row: View {
@Binding var fruit: Fruit
var body: some View {
HStack {
Text(fruit.name)
.foregroundColor(self.fruit.visible ? .black : .gray)
Spacer()
Toggle("", isOn: $fruit.visible) //※
}
}
}
※
のToggleにBinding
の値を渡しているので、ここで値がキャッシュされているのではないかと考えられます。
その結果、配列を削除してViewを再構築する際、存在しないはずの値にアクセスしてクラッシュしていると考えられます。
解決方法
Row
を以下のように書き換えることでクラッシュを回避しました。
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
List {
ForEach(self.viewModel.fruits.indices, id: \.self) { index in
- Row(fruit: $viewModel.fruits[index])
+ Row(fruit: $viewModel.fruits[index], onToggle: {
+ self.viewModel.fruits[index].visible.toggle()
+ })
}.onDelete(perform: { indexSet in
self.viewModel.delete(indices: indexSet)
})
}
.listStyle(GroupedListStyle())
}
}
}
struct Row: View {
@Binding var fruit: Fruit
+ var onToggle: () -> Void
+ @State private var isOn: Bool = false
var body: some View {
HStack {
Text(fruit.name)
.foregroundColor(self.fruit.visible ? .black : .gray)
Spacer()
- Toggle("", $fruit.visible)
+ Toggle("", isOn: $isOn)
+ .onChange(of: isOn, perform: { value in
+ if isOn != fruit.visible {
+ debugPrint("=> changed")
+ onToggle()
+ }
+ })
+ }.onAppear {
+ self.isOn = self.fruit.visible
+ }
}
}
ポイントは
-
Row
では直接Bindingの値に変更を加えない。(ローカルの@Stateを定義する) - コールバック(ここではonToggle)を使い、変更を親Viewに伝える
このコードを見てもわかるように、処理は増えています。
-
.onChange
を使い、変更を監視しなくてはならない -
.onAppear
を使い、初期値をセットしなくてはならない
ソースコード
今回のソースコードをこちらに置いておきます。
参考
Discussion