13

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?

New Dev
  • 48,427
  • 12
  • 87
  • 129
  • No crash with Xcode 12 / iOS 14 – Asperi Jul 24 '20 at 18:32
  • @Asperi - interesting. Thanks for that finding. Not sure if this was an intentional fix by Apple or something else – New Dev Jul 24 '20 at 18:45
  • 1
    Interesting indeed. If you use `List` it doesn't crash, but if you replace `List` with `ForEach` with same signature - it crashes (xCode 12 Beta 5) – Nevs12 Aug 30 '20 at 08:39
  • I've experienced similar crashes with Swift 5.7 & XCode 14.2 simulating on iOS 16. Also XCode wouldn't always allow `$myarr[i]` notation, so this solution is much needed. Thank you. – kwiknik Feb 14 '23 at 09:40

4 Answers4

26

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")
      }
   }
}
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • Looks good. I think you just need to make your `@ViewBuilder content` parameter *escaping*: `@escaping (BoundElement) -> C`. And your `Safe.init` needs a 'binding' label for the first parameter - in your example there's none (`Safe(self.$vm.arr, ...`). – pawello2222 Jul 25 '20 at 07:42
  • I'm not sure how this works or why it's needed but it helped me with my index out of range issue. – Peter Warbo Jan 06 '21 at 17:02
  • 1
    This is working great. As noted in other comments, I only encountered this issue when using `ForEach`, and it seems to be resolved for `List` in latest releases. However, there are many cases where `List` is the wrong component for the job. Will be using this solution until Apple resolves this issue. – Oskar Jan 06 '21 at 18:39
  • it look something advance.. can you explain or share any post/link so that we could understand how this code is working... – Deepak Singh Dec 15 '22 at 05:36
4

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)") })
        }
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • 1
    Great! By adding `@ViewBuilder` to `ToggleView.body` and removing `Group` makes it even more elegant. – Asperi Jul 24 '20 at 19:28
  • @Asperi Thanks for suggestions, it's definitely better with a `@ViewBuilder`. – pawello2222 Jul 24 '20 at 19:36
  • 1
    @pawello2222, thanks for the reply. Indeed, it seems like something is perhaps being evaluated out of order. Your solution works for this example, but ideally, it would be nice not to break the separation of concerns, since `ToggleView` shouldn't ideally know anything about a view model of their parent. – New Dev Jul 24 '20 at 21:00
  • @NewDev True, fortunately it looks to be fixed in SwiftUI 2.0. – pawello2222 Jul 24 '20 at 21:13
2

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)") } )
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
0

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)
            }
        }
    }
}
jason d
  • 416
  • 1
  • 5
  • 10