Deleting list elements from SwiftUI's List
SwiftUI seems to have a rather annoying limitation that makes it hard to create a List
or a ForEach
while getting a binding to each element to pass to child views.
The most often suggested approach I've seen is to iterate over indices, and get the binding with $arr[index]
(in fact, something similar was suggested by Apple when they removed Binding
's conformance to Collection
):
@State var arr: [Bool] = [true, true, false]
var body: some View {
List(arr.indices, id: \.self) { index in
Toggle(isOn: self.$arr[index], label: { Text("\(idx)") } )
}
}
That works until the array changes in size, and then it crashes with index out of range run-time error.
Here's an example that will crash:
class ViewModel: ObservableObject {
@Published var arr: [Bool] = [true, true, false]
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.arr = []
}
}
}
struct ContentView: View {
@ObservedObject var vm: ViewModel = .init()
var body: some View {
List(vm.arr.indices, id: \.self) { idx in
Toggle(isOn: self.$vm.arr[idx], label: { Text("\(idx)") } )
}
}
}
What's the right way to handle deletion from a List, while still maintaining the ability to modify elements of it with a Binding?
Solution 1:
Using insights from @pawello2222 and @Asperi, I came up with an approach that I think works well, without being overly nasty (still kinda hacky).
I wanted to make the approach more general than just for the simplified example in the question, and also not one that breaks separation of concerns.
So, I created a new wrapper view that creates a binding to an array element inside itself (which seems to fix the state invalidation/update ordering as per @pawello2222's observation), and passes the binding as a parameter to the content closure.
I initially expected to be needing to do safety checks on the index, but turns out it wasn't required for this problem.
struct Safe<T: RandomAccessCollection & MutableCollection, C: View>: View {
typealias BoundElement = Binding<T.Element>
private let binding: BoundElement
private let content: (BoundElement) -> C
init(_ binding: Binding<T>, index: T.Index, @ViewBuilder content: @escaping (BoundElement) -> C) {
self.content = content
self.binding = .init(get: { binding.wrappedValue[index] },
set: { binding.wrappedValue[index] = $0 })
}
var body: some View {
content(binding)
}
}
Usage is:
@ObservedObject var vm: ViewModel = .init()
var body: some View {
List(vm.arr.indices, id: \.self) { index in
Safe(self.$vm.arr, index: index) { binding in
Toggle("", isOn: binding)
Divider()
Text(binding.wrappedValue ? "on" : "off")
}
}
}
Solution 2:
It looks like your Toggle
is refreshed before the List
(possibly a bug, fixed in SwiftUI 2.0).
You can extract your row to another view and check if the index still exists.
struct ContentView: View {
@ObservedObject var vm: ViewModel = .init()
var body: some View {
List(vm.arr.indices, id: \.self) { index in
ToggleView(vm: self.vm, index: index)
}
}
}
struct ToggleView: View {
@ObservedObject var vm: ViewModel
let index: Int
@ViewBuilder
var body: some View {
if index < vm.arr.count {
Toggle(isOn: $vm.arr[index], label: { Text("\(vm.arr[index].description)") })
}
}
}
This way the ToggleView
will be refreshed after the List
.
If you do the same but inside the ContentView
it will still crash:
ContentView {
...
@ViewBuilder
func toggleView(forIndex index: Int) -> some View {
if index < vm.arr.count {
Toggle(isOn: $vm.arr[index], label: { Text("\(vm.arr[index].description)") })
}
}
}