swiftcocoaswiftuinsviewrepresentable

SwiftUI Binding Edits Wrong TextField after Item Reordered


Xcode 13 beta 5, iOS 14, macOS 11.6

I have a parent SwiftUI view that lists some children. Each child is bound to an NSViewRepresentable. Everything works and I can edit the values as expected. But once I reorder the items in the list and edit a field, it edits the wrong field. It appears that the binding remains intact from the previous item order.

Here's what that looks like:

enter image description here

Here's the parent:

struct ParentView: View {
  @StateObject var model = ThingModel.shared
  
  var body: some View {
    VStack{
      ForEach($model.things){ $thing in
        ChildView(thing: $thing)
        //Reorder
        .onDrag{
          model.draggedThing = thing
          return NSItemProvider(object: NSString())
        }
      }
      Text("Value: \(model.value)").font(.title)
    }
    .frame(width:300, height: 200)
  }
}

...and here's the child view:

struct ChildView: View {
  @Binding var thing: Thing
  @StateObject var model = ThingModel.shared
  
  var body: some View{
    HStack{
      GrowingField(text: $thing.text, submit: {
        model.value = thing.text
        print(thing.text)
      })
      Text(" = ")
        .opacity(0.4)
    }
    .padding(10)
    .onDrop(of: [UTType.text], delegate: ThingReorderDelegate(hoveredThing: thing))
  }
}

Last of all, here is the NSViewRepresentable which is called GrowingField. For simplicity, I have omitted the NSTextField subclass.

struct GrowingField: NSViewRepresentable{
  @Binding var text: String
  var submit:(() -> Void)? //Hit enter
  
  func makeNSView(context: Context) -> NSTextField {
    let textField = NSTextField()
    textField.delegate = context.coordinator
    textField.stringValue = text
    
    return textField
  }
  func updateNSView(_ nsView: NSTextField, context: Context) {
    nsView.stringValue = text
    context.coordinator.textBinding = $text
  }

  //Delegates
  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }
  
  class Coordinator: NSObject, NSTextFieldDelegate {
    let parent: GrowingField
    var textBinding : Binding<String>?
    
    init(_ field: GrowingField) {
      self.parent = field
    }
    
    func controlTextDidChange(_ obj: Notification) {
      guard let textField = obj.object as? NSTextField else { return }
      self.textBinding?.wrappedValue = textField.stringValue
    }
    
    //Listen for certain keyboard keys
    func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
      switch commandSelector{
        case #selector(NSStandardKeyBindingResponding.insertNewline(_:)):
        //- Enter -
        parent.submit?()
        textView.window?.makeFirstResponder(nil) //Blur cursor
        return true
      default:
        return false
      }
    }
    
  }
}

Why does the binding to the NSViewRepresentable not follow the field after it is reordered?

Here is a sample project to download and try it out.


Solution

  • I believe the issue (bug?) is with the ForEach-generated binding.

    If you forego the generated binding and create your own, everything seems to work as expected.

    Added to the ThingModel:

    func bindingForThing(id: String) -> Binding<Thing> {
        .init {
            self.things.first { $0.id == id }!
        } set: { newThing in
            self.things = self.things.map { $0.id == id ? newThing : $0 }
        }
    }
    

    And the ParentView:

    ForEach(model.things){ thing in
        ChildView(thing: model.bindingForThing(id: thing.id))