12

Is there anything wrong with this sample code? The Text view updates with a one character delay. For example, if I type "123" in the textfield, the Text view displays "12".

If I replace contacts with a simple structure and change its givenName property, then the view updates correctly.

Note that the print statement does print correctly (ie, if you type "123" it prints "1" then "12" then "123". So the contacts.givenName does get update as it should.

I have see other questions with a similar title, but this code does not seem to have the problems described in any of the questions that I have seen.

import SwiftUI
import Contacts

struct ContentView: View {
    @State var name: String = ""
    @State var contact = CNMutableContact()


    var body: some View {
         TextField("name", text: $name)
                .onChange(of: name) { newValue in
                    contact.givenName = newValue
                    print("contact.givenName = \(contact.givenName)")
                }
         Text("contact.givenName = \(contact.givenName)")
    }
}

Update: I added an id to the Text view and increment it when I update the contact state variable. It's not pretty but it works. Other solutions seem to be too involved fro something that shouldn't be this complicated.

   struct ContentView: View {
    @State var name: String = ""
    @State var contact = CNMutableContact()
    @State var viewID = 0   // change this to foce the view to update
    
    
    var body: some View {
        TextField("name", text: $name)
            .padding()
            .onChange(of: name) { newValue in
                contact.givenName = newValue
                print("contact.givenName = \(contact.givenName)")
                viewID += 1 // force the Text view to update
            }
        Text("contact.givenName = \(contact.givenName)").id(viewID)
       }
    }
jnpdx
  • 45,847
  • 6
  • 64
  • 94
RawMean
  • 8,374
  • 6
  • 55
  • 82
  • 2
    A State propert should be a struct but CNMutableContact is a class. When you change a struct you get a new value which SwiftUI reacts on but for a class the current instance is updated instead. – Joakim Danielson Jan 30 '22 at 18:42

1 Answers1

11

The cause of this is using @State for your CNMutableContact.

@State works best with value types -- whenever a new value is assigned to the property, it tells the View to re-render. In your case, though, CNMutableContact is a reference type. So, you're not setting a truly new value, you're modifying an already existing value. In this case, the View only updates when name changes, which then triggers your onChange, so there's no update after the contact changes and you're always left one step behind.

But, you need something like @State because otherwise you can't mutate the contact.

There are a couple of solutions to this. I think the simplest one is to wrap your CNMutableContact in an ObservableObject and call objectWillChange.send() explicitly when you change a property on it. That way, the View will be re-rendered (even though there aren't any @Published properties on it).

class ContactViewModel : ObservableObject {
    var contact = CNMutableContact()
    
    func changeGivenName(_ newValue : String) {
        contact.givenName = newValue
        self.objectWillChange.send()
    }
}

struct ContentView: View {
    @State var name: String = ""
    @StateObject private var contactVM = ContactViewModel()

    var body: some View {
         TextField("name", text: $name)
                .onChange(of: name) { newValue in
                    contactVM.changeGivenName(newValue)
                    print("contact.givenName = \(contactVM.contact.givenName)")
                }
        Text("contact.givenName = \(contactVM.contact.givenName)")
    }
}

Another option is moving name to the view model and using Combine to observe the changes. This works without objectWillChange because the sink updates contact on the same run loop as name gets changed, so the @Published property wrapper signals the View to update after the change to contact has been made.

import Combine
import SwiftUI
import Contacts

class ContactViewModel : ObservableObject {
    @Published var name: String = ""
    var contact = CNMutableContact()
    
    private var cancellable : AnyCancellable?
    
    init() {
        cancellable = $name.sink {
            self.contact.givenName = $0
        }
    }
}

struct ContentView: View {
    @StateObject private var contactVM = ContactViewModel()

    var body: some View {
        TextField("name", text: $contactVM.name)
        Text("contact.givenName = \(contactVM.contact.givenName)")
    }
}
jnpdx
  • 45,847
  • 6
  • 64
  • 94
  • 1
    Thanks for the detailed answer. I updated my question with a workaround that works but it's not pretty. What do you think? – RawMean Jan 30 '22 at 19:48
  • 6
    I would say that although it *works*, the intention is unclear in that case and encourages practices with unintended behavior (like using `@State` for a reference type), whereas both of my solutions have pretty good semantic clarity about what is actually happening. In terms of your comment that "Other solutions seem to be too involved fro something that shouldn't be this complicated", I'd say my second solution, in particular, is actually less complicated than your original implantation. – jnpdx Jan 30 '22 at 19:52
  • 2
    Silly autocorrect -- implantation = implementation – jnpdx Jan 30 '22 at 20:02