4

I have a complex data structure which uses value types (structs and enums), and I'm facing major issues getting basic CRUD to work. Specifically:

  1. How best to "Re-bind" a value in a ForEach for editing by a child view
  2. How to remove/delete a value

Rebinding

If I have an array of items as @State or @Binding, why isn't there a simple way to bind each element to a view? For example:

import SwiftUI

struct Item: Identifiable {
  var id = UUID()
  var name: String
}

struct ContentView: View {
  @State var items: [Item]
  var body: some View {
    VStack {
      ForEach(items, id: \.id) { item in
        TextField("name", text: $item) //  Cannot find '$item' in scope 
      }
    }
  }
}

Workaround

I've been able to work around this by introducing a helper function to find the correct index for the item within a loop:

struct ContentView: View {
  @State var items: [Item]

  func index(of item: Item) -> Int {
    items.firstIndex { $0.id == item.id } ?? -1
  }

  var body: some View {
    VStack {
      ForEach(items, id: \.id) { item in
        TextField("name", text: $items[index(of: item)].name)
      }
    }
  }
}

However, that feels clunky and possibly dangerous.

Deletion

A far bigger issue: how are you supposed to correctly delete an element? This sounds like such a basic question, but consider the following:

struct ContentView: View {
  @State var items: [Item]

  func index(of item: Item) -> Int {
    items.firstIndex { $0.id == item.id } ?? -1
  }

  var body: some View {
    VStack {
      ForEach(items, id: \.id) { item in
        TextField("name", text: $items[index(of: item)].name)
        Button( action: {
          items.remove(at: index(of: item))
        }) {
          Text("Delete")
        }
      }
    }
  }
}

Clicking the "Delete" button on the first few items works as expected, but trying to Delete the last item results in Fatal error: Index out of range...

My particular use case doesn't map to a List, so I can't use the deletion helper there.

Reference types

I know that reference types make much of this easier, especially if they can conform to @ObservableObject. However, I have a massive, nested, pre-existing value type which is not easily converted to classes.

Any help would be most appreciated!

Update: Suggested solutions

  • Deleting List Elements from SwiftUI's list: The accepted answer proposes a complex custom binding wrapper. Swift is powerful, so it's possible to solve many problems with elaborate workarounds, but I don't feel like an elaborate workaround should be necessary to have a list of editable items.
  • Mark Views as "deleted" using State or a private variable, then conditionally hide them, to avoid out-of-bounds errors. This can work, but feels like a hack, and something that should be handled by the framework.
mbxDev
  • 837
  • 1
  • 7
  • 17
  • Does this answer your question? [Deleting list elements from SwiftUI's List](https://stackoverflow.com/questions/63079221/deleting-list-elements-from-swiftuis-list) – New Dev Aug 03 '20 at 15:33
  • @NewDev The accepted answer from that question is an interesting approach, but it's rather complex and "custom." I keep hoping there's a cleaner or more first-party way to do this. – mbxDev Aug 03 '20 at 16:04
  • Well, first, you don't need the extra function in your "workaround". The linked question / answer gives you the "first party" approach: iterate over indices and use `$items[index]` - which is a binding. But the issue still remains with deletion - which appears to be a bug, since it's solved in SwiftUI2 – New Dev Aug 03 '20 at 17:17
  • I'm working with SwiftUI 2 now, and still facing this bug (out of range on deletion). Is it fixed in other scenarios? – mbxDev Aug 03 '20 at 18:02
  • Hmm.. indeed, your example still crashes in XCode12/iOS14. But my answer to the other question still works - and it's a fairly simple wrapper. – New Dev Aug 03 '20 at 18:34
  • @NewDev Your answer in the other question is ingenious, but I still believe it should be possible without a wrapper. I've filed a bug with Apple, and will keep this question open, to see if others in the community have solutions. Thanks! – mbxDev Aug 03 '20 at 18:58

1 Answers1

1

I confirm that more appropriate approach for CRUD is to use ObservableObject class based view model. And an answer provided by @NewDev in comments is a good demo for that approach.

However if you already have a massive, nested, pre-existing value type which is not easily converted to classes., it can be solved by @State/@Binding, but you should think about what/when/and how update each view and in each order - that is the origin of all such index out of bounds on delete issues (and some more).

Here is demo of approach of how to break this update dependency to avoid crash and still use value types.

Tested based on your code with Xcode 11.4 / iOS 13.4 (SwiftUI 1.0+)

struct ContentView: View {
  @State var items: [Item] = [Item(name: "Name1"), Item(name: "Name2"), Item(name: "Name3")]

  func index(of item: Item) -> Int {
    items.firstIndex { $0.id == item.id } ?? -1
  }

  var body: some View {
    VStack {
      ForEach(items, id: \.id) { item in
        // separate dependent views as much as possible to make them as 
        // smaller/lighter as possible
        ItemRowView(items: self.$items, index: self.index(of: item))
      }
    }
  }
}

struct ItemRowView: View {
    @Binding var items: [Item]
    let index: Int

    @State private var destroyed = false   // internal state to validate self

    var body: some View {
        // proxy binding to have possibility for validation
        let binding = Binding(
            get: { self.destroyed ? "" : self.items[self.index].name },
            set: { self.items[self.index].name = $0 }
        )

        return HStack {
            if !destroyed { // safety check against extra update
                TextField("name", text: binding)
                Button( action: {
                  self.destroyed = true
                  self.$items.wrappedValue.remove(at: self.index)
                }) {
                  Text("Delete")
                }
            }
        }
    }
}

Yes, it is not easy solution, but sometimes there are situations we need it.

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Hmm.. My point wasn't about using an `ObservableObject` instead of value-type. The OP is using a workaround function to get the index (you've included it too - why?), whereas the typical approach is to iterate over indices and access the binding through `$items[index]`. But the deletion would have caused the problem as per the linked question (and my answer there introduces a more flexible approach to prevent index-out-range) – New Dev Aug 03 '20 at 17:32
  • @Asperi, I like the idea of just manually creating the Binding—at least it makes it very clear at the call site what's happening. But it feels wrong to have to maintain a "destroyed" or "isDeleted" property on a view. Perhaps there is just no satisfying way to make full use of value types in SwiftUI... – mbxDev Aug 03 '20 at 18:01
  • @mbxDev, in this case `destroyed` is just a workaround for SwiftUI bug (wrong update order), but it is internal private state of small view, so it is not so big evil. – Asperi Aug 03 '20 at 18:11
  • @Asperi, I think you're right... I've filed it as FB8254559. If they give any updates, I'll be sure to revisit this question. – mbxDev Aug 03 '20 at 18:57