macosswiftuitextfieldswiftdata

SwiftUI TextField not updating with SwiftData


I have a program that uses a SwiftData @Model. The model has a unique attribute of a year to track the various SwiftData model entries. I allow the user to change the year through a menu. Views that have both a TextField and Text view are only updating the Text view when the year value changes. A minimally compilable version of the program, which reproduces the error is as follows. The data model and a data struct are:

@Model final public class Model {
    var multiples: Multiples = Multiples(multiple1: 1.0)
    @Attribute(.unique) var modelYear: Int
    
    init(modelYear: Int) {
        self.modelYear = modelYear
    }
}

struct Multiples: Codable {
    var multiple1: Double = 1.0
    
    init(multiple1: Double){
        self.multiple1 = multiple1
    }  
}

I have an environment variable to track the year.

extension EnvironmentValues {
    
    @Entry() public var currentYear: Int = 2024
    
}

I have a custom TextField, which relies on a local property to prevent updating of the model values when I type in the TextField.

public struct MyTextField: View {
    
    @Binding var value: Double
    @State  var localValue: Double
    
    @FocusState private var textFieldIsFocused: Bool
    
    public init(value: Binding<Double>) {
        self._value = value
        self._localValue = State(initialValue: value.wrappedValue)
    }
    
    public var body: some View {
        TextField("", value: $localValue, format: .number.precision(.fractionLength(2)))
            .onHover{
                hover in
                if hover == false {
                    localValue = value
                }
            }
            .onSubmit{
                if value != localValue {
                    value = localValue
                }
            }
            .textFieldStyle(.plain)
    }
}

The main app and main window is:

@main
struct WindowtestApp: App {
    init() {

        let defaults = UserDefaults.standard
        let year = defaults.object(forKey: "year") as? Int ?? 2024
        
        do {
            self.container = try ModelContainer(for: Model.self, configurations: ModelConfiguration(cloudKitDatabase: .none))
        } catch{
            print("Error")
            exit(99)
        }
        
        container.mainContext.autosaveEnabled = true
        
        let model = Model(modelYear: year)
        
        if let fetchResult = try? container.mainContext.fetch(FetchDescriptor<Model>(predicate: #Predicate{$0.modelYear == year})) {
            
            
            if fetchResult.isEmpty  {
                container.mainContext.insert(model)
            }
            
        } else {
            container.mainContext.insert(model)
        }

    }
    
    var availableYears: [Int] = [2024, 2025, 2026, 2027]
    @AppStorage("year") var year: Int = 2024
    
    var container: ModelContainer
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContext(container.mainContext)
                .environment(\.currentYear,year)
        }
       .commands{
            CommandMenu("Tools"){
                Menu("Years"){
                    ForEach( availableYears, id: \.self  ) { y in
                        Button{
                         let context = container.mainContext
                            let fetches = try? context.fetch(FetchDescriptor<Model>())
                          let years = fetches != nil ? fetches!.map{$0.modelYear} : [2024]
                    
                         if !years.contains(y) {
                                let newModel = Model(modelYear: y)
                                context.insert(newModel)
                                _ = try! context.save()
                            }
                       
                             year = y
                            
                        } label: {
                            Text("\(y)")
                            Image(systemName: y == year ? "checkmark.rectangle" : "rectangle")
                        }
                    }
                }
            }
        }
    }
}

struct ContentView: View{
    @Environment(\.currentYear) var currentYear
    @Environment(\.modelContext) var context
    
    var body: some View{
        @Bindable var model: Model = try! context.fetch(FetchDescriptor<Model>(predicate: #Predicate { $0.modelYear == currentYear}))[0]
        TabView{
            Tab(content: {
                VStack{
                    Text(verbatim: "Tab for \(currentYear)")
                    MyTextField(value: $model.multiples.multiple1)
                    Text(verbatim: "\(Double(model.multiples.multiple1) * Double(currentYear))")
                }
                  .frame(width:100)
                .navigationTitle(Text(verbatim: "Tab for \(currentYear)"))
            }){
                Text("Tab")
            }
        }
        .tabViewStyle(.sidebarAdaptable)
    }
}

The behavior that is not working is that when I switched the year, the MyTextField value is not updating, but the Text value does update, which shows that the correct model values are present. I would expect that both views would update when the year switches.

To reproduce the problem.

  1. Start the program. Select a multiple for the year in the TextField box.
  2. Select the "Tool" menu and select a new year.
  3. Select a different multiple for this year.
  4. Switch back to the previous year.
  5. The MyTextField value does not update, but the Text value does. The Text value is the stored property in the model multiplied by the year. The Text value shows the correct result, but the MyTextField is not showing the correct multiple.

The following screenshots show the behavior.

The first shows selecting a multiple of 2 for the Model with year 2024. The bottom most text view updates to reflect 2*2024.

enter image description here

The next screenshot shows switching to year 2025.

enter image description here

The MyTextField view still shows a multiple of 2. For the 2025 year, the multiple is already 3, which is reflected in the updated Text view, which shows 6025.0 (3*2025). So the model appears to update correctly, the MyTextField is just not refreshing the view to show the most recent version of the Model multiple1 property.


Solution

  • This is because you are not "invalidating" the localValue of the MyTextField.

    localValue is a @State, so its value is persisted as long as MyTextField is alive. When you switch to a different year, localValue will not change, because why should it? Nothing has changed it.

    Note that this is a deceiving line of code, and it almost always does not do what you intend it to do:

    self._localValue = State(initialValue: value.wrappedValue)
    

    This looks as if you are resetting localValue, but it is not. The initialValue parameter is only assigned to the state when the view first appears. On subsequent calls of init, initialValue is simply ignored, because the @State has already been initialised.

    You should always initialise @States in the property initialiser, not in init.

    If nothing else will change model.multiples.multiple1 during editing, you can simply use onChange(of: value) in MyTextField.

    @Binding var value: Double
    @State private var localValue: Double = 0.0
    
    @FocusState private var textFieldIsFocused: Bool
    
    public var body: some View {
        TextField("", value: $localValue, format: .number.precision(.fractionLength(2)))
            .onChange(of: value, initial: true) {
                localValue = value
            }
            .onSubmit{
                if value != localValue {
                    value = localValue
                }
            }
            .textFieldStyle(.plain)
    }
    

    If something else might change model.multiples.multiple1 during editing, and you don't want the new value to overwrite what's currently in the text field, MyTextField can expose a mechanism for telling it that it should reset its localValue. Here is an example:

    public struct MyTextField<Trigger: Equatable>: View {
        
        @Binding var value: Double
        @State private var localValue: Double = 0.0
        let invalidateLocalValueTrigger: Trigger
        
        @FocusState private var textFieldIsFocused: Bool
        
        public var body: some View {
            TextField("", value: $localValue, format: .number.precision(.fractionLength(2)))
                .onChange(of: invalidateLocalValueTrigger, initial: true) {
                    localValue = value
                }
                .onSubmit{
                    if value != localValue {
                        value = localValue
                    }
                }
                .textFieldStyle(.plain)
        }
    }
    

    Whenever invalidateLocalValueTrigger changes, the text field resets its local value. In ContentView, you can then simply pass currentYear to this parameter.

    @Environment(\.currentYear) var currentYear
    
    // ...
    
    MyTextField(value: $model.multiples.multiple1, invalidateLocalValueTrigger: currentYear)
    

    A third way is to keep MyTextField unchanged, and use .id(currentYear)

    MyTextField(value: $model.multiples.multiple1).id(currentYear)
    

    This destroys the old MyTextField and creates a new MyTextField whenever currentYear changes, causing the @State to be initialised again.