New Dev
New Dev

Reputation: 49590

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?

Upvotes: 13

Views: 3736

Answers (4)

kristofkalai
kristofkalai

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

New Dev
New Dev

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

pawello2222
pawello2222

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

Asperi
Asperi

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

Related Questions