Reputation: 49590
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?
Upvotes: 13
Views: 3736
Reputation: 444
If anyone interested I combined the Safe solution by New dev with a ForEach
:
struct ForEachSafe<T: RandomAccessCollection & MutableCollection, C: View>: View where T.Index: Hashable {
private let bindingArray: Binding<T>
private let array: T
private let content: (Binding<T.Element>) -> C
init(_ bindingArray: Binding<T>, _ array: T, @ViewBuilder content: @escaping (Binding<T.Element>) -> C) {
self.bindingArray = bindingArray
self.array = array
self.content = content
}
var body: some View {
ForEach(array.indices, id: \.self) { index in
Safe(bindingArray, index: index) {
content($0)
}
}
}
}
Upvotes: 0
Reputation: 49590
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")
}
}
}
Upvotes: 26
Reputation: 54466
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)") })
}
}
}
Upvotes: 4
Reputation: 257663
SwiftUI 2.0
As tested with Xcode 12 / iOS 14 - crash not reproducible
SwiftUI 1.0+
Crash happens due to dangling bindings to removed elements (presumably `cause of bad invalidation/update order). Here is a safe workaround. Tested with Xcode 11.4 / iOS 13.4
struct ContentView: View {
@ObservedObject var vm: ToggleViewModel = .init()
var body: some View {
List(vm.arr.indices, id: \.self, rowContent: row(for:))
}
// helper function to have possibility to generate & inject proxy binding
private func row(for idx: Int) -> some View {
let isOn = Binding(
get: {
// safe getter with bounds validation
idx < self.vm.arr.count ? self.vm.arr[idx] : false
},
set: { self.vm.arr[idx] = $0 }
)
return Toggle(isOn: isOn, label: { Text("\(idx)") } )
}
}
Upvotes: 2