swiftswiftuistructstate

SwiftUI, @State fed from Struct, building an editor


Ok, I couldn't find this exact problem in searching, so forgive me if I overlooked something deep in the bowels of searches.

Premise: Existing system. Adding an editor. Data is passed around in Swift Structs, pulled and updated via existing REST API stuff. I don't have a lot of control over the upstream stuff. Or more accurately, I don't have the time to re-architect anything upstream at the moment (solo dev on a recently inherited project).

Problem: As noted, the data is pushed around in structs. Here's a (pared-down) example:

struct Person: Codable, Identifiable {
    let id: Int
    let firstname: String
    let lastname: String
}

These are displayed and selectable in a View and the user can tap on one to selected it, and (presumably) display the editor. Navigating to the editor is the final model, but here in this example, I'm just shoving it into the single/main view for simplicity.

My editor receives the Person, copies the values into @State variables for display and editing and then when the user hits Save, the updates are pushed to the REST server, then the editor is dismissed, and the struct is replaced on the read-back/refresh, etc. Pretty normal workflow.

Note: the save button and API code are not in this example to keep it lean and simple.

However, in my editor, I'm copying the values from the passed-in Person struct and in the View's Init, you can verify the value changes (see Print statement there), but the actual view isn't updated. Presumably the body isn't redrawing?

I'm returning to SwiftUI after about a year completely buried in Python server side stuff, and I just can't believe I'm THIS rusty. I must be missing something simple.

What am I missing? (code follows)

import SwiftUI

struct ContentView: View {
    
    @State private var selectedPerson: Person?
    
    // could also do this if optionals and nils are scary
    // @State private var selectedPerson: Person = Person(
    //    id: 0,
    //    firstname: "No one",
    //    lastname: "Selected"
    //) // or whatever
    
    
    // pretend these are populated from an API REST call 
    private var people: [Person] = [
        Person(id: 1, firstname: "Bob", lastname: "Bobberson"),
        Person(id: 2, firstname: "Joe", lastname: "Smith"),
        Person(id: 3, firstname: "Taylor", lastname: "Swift") // because apparently that's what you do now
    ]
    
    var body: some View {
        VStack {
            if let sp = self.selectedPerson {
                Text("Selected: \(sp.firstname)")
                    .padding()
            } else {
                Text("No one is selected")
            }
            
            ScrollView {
                ForEach(people){ person in
                    Text(person.firstname)
                        .padding()
                        .onTapGesture {
                            selectedPerson = person
                        }
                } // all works as expected. Trouble starts below.


                if let _ = self.selectedPerson {
                    PersonEditor(person: selectedPerson!)
                }

                // or this if it's more desired or more "swifty"
                if let e = self.selectedPerson {
                    PersonEditor(person: e)
                }
            }
            
        }
        .padding()
    }
}


struct PersonEditor: View {
    // this doesn't need to exist, just including for completeness.
    private var person: Person
    
    @State private var edtFirstname: String = ""
    @State private var edtLastname: String = ""
    
    init(person: Person){
        // this doesn't need to exist. included for completeness.
        self.person = person
        
        self._edtFirstname = State(wrappedValue: person.firstname)
        self._edtLastname = State(wrappedValue: person.lastname)
        // changing to initialValue to wrappedValue seems to not matter

        // this runs and prints correct.
        print("current first name is \(self.edtFirstname)")
        // so why does this show the fed-in value after setting the state
        // but the Text and TextField objects in the body do not?
        // suggests body isn't being called despite state changing.
    }
    
    var body: some View {
        VStack {
            Text("Editing \(person.firstname)") // this updates correctly (as expected)
            Text("Editing \(self.edtFirstname)") // this does not
            
            TextField("firstname", text: $edtFirstname) // this never changes despite init running
            TextField("lastname", text: $edtLastname) // this never changes despite init running
        }
    }
}

Solution

  • Make selectedPerson the id for the editor.

    if let selectedPerson {
          PersonEditor(person: selectedPerson)
          .id(selectedPerson)
    }
    

    In SwiftUI @State determines the identity of a View the init being called doesn’t mean that that SwiftUI’s storage is being reset.

    It is actually meant to outlive the recreation of the View which happens all the time.

    If you want to reset the storage you have to force it.