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.
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.
The next screenshot shows switching to year 2025.
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.
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 @State
s 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.