[SwiftUI]Listの削除でout of rangeになる問題を解消する

2021/08/31に公開

何が問題になったか

下記のように、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

考えられる原因

https://www.yururiwork.net/archives/1295

この記事にあるように、サブ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を使い、初期値をセットしなくてはならない

ソースコード

今回のソースコードをこちらに置いておきます。

https://github.com/usk-sample/EditListSample2/

参考

https://stackoverflow.com/questions/60316727/got-fatal-error-index-out-of-range-show-index-in-list-item-for-swiftui

Discussion