Search code examples
cocoaswiftuimacos-sonomansviewrepresentable

Accessing StateObject's object without being installed on a View


There are other answers on SO about this issue but none seen to solve the problem because in my case I am using a NSViewRepresentable cocoa view, that is not exactly a SwiftUI view.

I have this code:

struct MyObject: Hashable, Identifiable {
  let id: UUID = UUID()
  var name, brand: String
  init(name: String = "", brand: String = "") {
    self.name = varName
    self.brand = brand
  }
}

final class Model:ObservableObject {
  @Published var objects = (0..<3).compactMap {_ in
    return MyObject()
  }
}

struct ContentView: View {
  
  private let columns = [GridItem(.flexible()), GridItem(.flexible())]
  @StateObject private var model = Model()
    
  var body: some View {
    VStack{
      LazyVGrid(
        columns: columns,
        alignment: .center,
        spacing: 10,
        pinnedViews: []
      ) {
        ForEach($model.objects, id:\.id) {$object in
          MyTextField($initObject)
        }
      }
      
    }
    .padding(20)
  }
}

struct MyTextField: View {
  @Binding private var initObject:InitObject
  
  init(_ initObject:Binding<InitObject>) {
    _initObject = initObject
  }
  
  var body: some View {
    AppKitTextField(property: $initObject.name,
                    placeholder: "name",
                    fontName:"Avenir-Medium",
                    fontSize:18) { text in
    }
    
    MyAppKitTextField(property: $initObject.type,
                    placeholder: "type",
                    fontName:"Avenir-Medium",
                    fontSize:18)
      
    }
  }

The problem is that MyAppKitTextField is a AppKit NSTextField NSViewRepresentable, like this:

public struct AppKitTextField: NSViewRepresentable {
  public typealias NSViewType = NSTextField
  @Binding var property: String

  public init(property: Binding<String>) {
    self._property = property
  }
    
  public class Coordinator: NSObject, NSTextFieldDelegate {
    @Binding private var text: String
    
    init(text: Binding<String>) {
      self._text = text
    }
  }

    public func makeNSView(context: NSViewRepresentableContext<AppKitTextField>) -> NSTextField {
        let textField = NSTextField()
        textField.delegate = context.coordinator
        textField.stringValue = property
        return textField
      }
      
      public func makeCoordinator() -> AppKitTextField.Coordinator {
        return Coordinator(text: $property)
      }
      
      public func updateNSView(_ nsView: NSTextField, context: Context) {
        nsView.stringValue = property
      }
    }
}

My problem is the line

@Binding var property: String

When I try to use this view I see this error:

Accessing StateObject's object without being installed on a View. This will create a new instance each time.

Solution

  • My first guess is @Binding property wrapper shouldn't be used in the Coordinator class since it's not a View where @Binding is designed to be used. Instead, you can either use Binding<String> or just a closure since you only need to set anyway.

    My second guess is this mistake:

    public func makeCoordinator() -> AppKitTextField.Coordinator {
        return Coordinator(text: $property)
    }
    

    It should be:

    public func makeCoordinator() -> AppKitTextField.Coordinator {
        return Coordinator() // make is only called once so there is no point in giving it an old version of the binding.
    }
    
    public func updateNSView(_ nsView: NSTextField, context: Context) {
    
        context.coordinator.textDidChange = nil // just to prevent endless updates depending on how your coordinator is designed
    
        nsView.stringValue = property
    
        // must also use the new version of the binding
        context.coordinator.textDidChange = { text
            self.property = text
        }
    }
    

    FYI Binding is just a pair of get/set closures, there is no need to give the Binding to the Coordinator because it doesn't need the get, thats the reason for the set only textDidChange closure which you should invoke from your delegate or action handler.