21

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)
       }
    }
2
  • 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. Commented Jan 30, 2022 at 18:42
  • Just wanted to say for other people, you might need to implement Equatable; static func == (left: MyStrct, right: MyStrct) -> Bool Commented Dec 3, 2024 at 17:40

2 Answers 2

16

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)")
    }
}
Sign up to request clarification or add additional context in comments.

3 Comments

Thanks for the detailed answer. I updated my question with a workaround that works but it's not pretty. What do you think?
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.
Silly autocorrect -- implantation = implementation
7

The reason your Text() shows one character less than the original name:

When inside onChange { } closure you update contact.givenName it does not refresh/reload your whole view. Because you are not actually updating the whole contact @State variable rather you are updating just a property givenName of the contact @State variable.

By the rules of @State variable, when you update the whole @State variable only then the view reloads.

Based on this I will share 2 simpler solutions:

Solution 1: Update the @State variable contact object completely

import SwiftUI
import Contacts

struct ContentView: View {
    @State var name: String = ""
    @State var contact = CNMutableContact()
    
    var body: some View {
        TextField("name", text: $name)
            .padding()
            .onChange(of: name) { oldValue, newValue in
                contact = .init() // Updating the @State variable contact completely
                contact.givenName = newValue
                print("contact.givenName = \(contact.givenName)")
            }
        Text("contact.givenName = \(contact.givenName)")
    }
}

It works fine. But I personally don't like this solution because it creates new contact reference each time. So it is not efficient in my opinion.

Solution 2: User another @State var contactName to track contact name

import SwiftUI
import Contacts

struct ContentView: View {
    @State var name: String = ""
    @State var contactName: String = ""
    
    var contact = CNMutableContact() // We don't need @State for this
    
    var body: some View {
        TextField("name", text: $name)
            .padding()
            .onChange(of: name) { oldValue, newValue in
                contact.givenName = newValue
                contactName = newValue
                print("contact.givenName = \(contact.givenName)")
            }
        Text("contact.givenName = \(contactName)")
    }
}

Don't worry, your contact.givenName will update accordingly.

8 Comments

This will create a new CNMutableContact every single time the View is reevaluated.
Yes, that’s why I said I recommend the 2nd solution i shared. I already mentioned the problem you mentioned.
The 2nd solution is the one I was referring to.
In my 2nd solution CNMutableContact will be created only once, when you create the view. Other time during view reevaluation i.e when any @State property changes then, only the var body will be recalculated. Which doesn’t create new instance of the contact. Only the contact.givenName can be updated during view reevaluation.
Yes, in the case that it's a ContentView @State variable. But, if it's higher up in the tree, the View may be re-evaluated (in particular, if ContentView had an input coming from the outside). The point I'm making is that this solution works in a vacuum (which, to be fair, this question is), but with almost any added complexity (again, like ContentView having a parameter from the outside that ever changes), it will likely end up creating multiple CNMutableContact
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.