Home > other >  Observe changes to NumberFormatter in SwiftUI TextField
Observe changes to NumberFormatter in SwiftUI TextField

Time:03-12

I'm building a macOS app where I have an observable Formatter object that uses @AppStorage to store the significant digit settings (see Formatter.swift). The number formatter is passed to the other views as an environment object using the main app struct MyApp.swift. In the preferences window SettingsView.swift, the significant digits are adjusted using steppers. Finally, the number formatter is assigned to text fields in ContentView.swift to format the input.

The problem is the text fields in the content view do not automatically update their format when the significant digits are changed in the settings view. The text labels automatically update because they read the app storage values directly. But the text fields are not observing the change to the number formatter. If I change the settings, restart the app, then the text fields will properly show the updated format. But how do I tell the text field to update when the formatter significant digits change?

app windows

Formatter.swift

import Foundation
import SwiftUI

class Formatter: ObservableObject {
    
    @AppStorage("minSigDigits") var minSigDigits = 1
    @AppStorage("maxSigDigits") var maxSigDigits = 6
    
    var numberFormatter: NumberFormatter {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.usesSignificantDigits = true
        formatter.minimumSignificantDigits = self.minSigDigits
        formatter.maximumSignificantDigits = self.maxSigDigits
        return formatter
    }
    
    func resetValues() {
        self.minSigDigits = 1
        self.maxSigDigits = 6
    }
}

MyApp.swift

import SwiftUI

@main
struct MyApp: App {
    
    @StateObject var formatter = Formatter()
    
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(formatter)
        }
        
        Settings {
            SettingsView().environmentObject(formatter)
        }
    }
}

ContentView.swift

import SwiftUI

struct ContentView: View {
    
    @State private var min: Double = 0.0
    @State private var max: Double = 1.0
    @EnvironmentObject var formatter: Formatter
    
    var body: some View {
        VStack {
            Text("Min Sig. Digits = \(formatter.minSigDigits)")
            TextField("enter min value", value: $min, formatter: formatter.numberFormatter)
            
            Text("Max Sig. Digits = \(formatter.maxSigDigits)")
            TextField("enter max value", value: $max, formatter: formatter.numberFormatter)
        }
        .padding()
        .frame(width: 400, height: 300)
    }
}

SettingsView.swift

import SwiftUI

struct SettingsView: View {
    
    @EnvironmentObject var formatter: Formatter
    
    var body: some View {
        VStack {
            Stepper("Min Significant Digits: \(formatter.minSigDigits)", value: $formatter.minSigDigits, in: 1...5)
            Stepper("Max Significant Digits: \(formatter.maxSigDigits)", value: $formatter.maxSigDigits, in: 6...10)
            
            Button("Reset Values") {
                formatter.resetValues()
            }
        }
        .padding()
        .frame(width: 300, height: 200)
    }
}

CodePudding user response:

Add an .id to the TextFields, that will force a redraw on change:

        Text("Min Sig. Digits = \(formatter.minSigDigits)")
        TextField("enter min value", value: $min, formatter: formatter.numberFormatter)
            .id(formatter.minSigDigits)
        
        Text("Max Sig. Digits = \(formatter.maxSigDigits)")
        TextField("enter max value", value: $max, formatter: formatter.numberFormatter)
            .id(formatter.maxSigDigits)

EDIT: ...or set an .id to the whole view to redraw all:

    VStack {
        Text("Min Sig. Digits = \(formatter.minSigDigits)")
        TextField("enter min value", value: $min, formatter: formatter.numberFormatter)
        
        Text("Max Sig. Digits = \(formatter.maxSigDigits)")
        TextField("enter max value", value: $max, formatter: formatter.numberFormatter)
    }
    .id(formatter.maxSigDigits   formatter.minSigDigits)

The basic logic is, every time the id changes, SwiftUI will redraw that part.

CodePudding user response:

Your formatter.numberFormatter getter is creating an object every time (actually 2 objects because its called twice) its called in body, you can't do that in SwiftUI. Body needs to be fast because it is called repeatedly and creating objects that are immediately discarded slows it down.

You need to rearchitect so that instead of notifying SwiftUI when the default changes with @AppStorage, you need to listen to the defaults yourself, update the numberFormatter with the new values, and tell SwiftUI to redraw.

One way would be to make the numberFormatter an @Published. Then use Combine to listen to changes to both of the user defaults you are interested in, create a new formatter using the values, then assign the end of the pipeline to the numberFormatter published property which will notify SwiftUI. This is actually what Combine's ObservableObject is designed for. You could optimise on this further by not having it @Published and instead just a let and then manually send the objectWillChange() and then update the let formatter to use the new params from defaults.

  • Related