Chapter 6, TextField with Formatter

I decided to try and use a TextField with other types as described in Chapter 6. I built a simple interface with string, int and double fields (see below) and I believe I built and wired it correctly. The string works as planned. I can edit its TextField and the change is carried through to UserDefaults and the func works correctly. If I edit the string and press the button without committing the edit (i.e. pressing enter) it still works.

Not so, the formatted TextFields. They work correctly if I commit my editing but if I fail to do it and click another field/control, they continue to display my edited values but nothing is propagated to UserDefaults and the func operates with the old values. I would expect that a “reactive” environment would either accept my changes when focus is shifted or, more naturally, revert those fields to their pre-edit state.

Curiously, I log the onEditingChanged and onCommit closures and I learned that onCommit is called before onEditingChanged(true). Seems unnatural! I’d like to know how to trap this loss of focus and revert my display to correct values.

Here’s my really basic code:

  1. A “model” which contains a string, an int, a double and a func and makes them persist in UserDefaults. (Strangely, though I expect models to be UI neutral, I needed to import SwiftUI to access @AppStorage).

    import Foundation
    import SwiftUI

    struct Model {
    @AppStorage(“Model_Name”) var name: String = “Jack”
    @AppStorage(“Model_Real”) var realNumber: Double = 6.5
    @AppStorage(“Model_Integer”) var integer: Int = 6

    func run() {
       print("Running ...")
       print("Model_Name: ",self.name)
       print("Model_Real: ",self.realNumber)
       print("Model_Integer: ",self.integer)
    }
    

    }

  2. A “view model” to mediate between the model and the view. It’s a class to avail of @ObservableObject.

    class ViewModel: ObservableObject {
    @Published var model = Model()

    func runModel() { model.run() }
    
    var name: String {
       get { model.name }
       set { model.name = newValue }
    }
    
    var realNumber: Double {
       get { model.realNumber }
       set { model.realNumber = newValue }
    }
    
    var integer: Int {
       get { model.integer }
       set { model.integer = newValue }
    }
    

    }

  3. A view to allow users to update the values in the model and run the func.

    import SwiftUI

    struct ParameterView: View {
    @ObservedObject var viewModel = ViewModel()

    var body: some View {
       VStack {
          HStack {
             Text("Name:")
             TextField("enter name here",
                       text: $viewModel.name,
                       onEditingChanged: { isEditing in
                         isEditing ?
                            print("Name editing starts: ", viewModel.name)
                            : print("Name editing ends: ", viewModel.name)
                       },
                       onCommit: { print("Name commit: ",viewModel.name) }
             )
          }
       }
       .padding()
    
       HStack {
          Text("Name:")
          TextField("enter real number here ...",
                    value: $viewModel.realNumber,
                    formatter: realFormatter,
                    onEditingChanged: { isEditing in
                      isEditing ?
                         print("Real editing starts: ", viewModel.realNumber)
                         : print("Real editing ends: ", viewModel.realNumber)
                    },
                    onCommit: { print("Real commit: ",viewModel.realNumber) }
          )
       }
       .padding()
    
       HStack {
          Text("Integer:")
          TextField("enter nteger here ...",
                    value: $viewModel.integer,
                    formatter: integerFormatter,
                    onEditingChanged: { isEditing in
                      isEditing ?
                         print("Integer editing starts: ", viewModel.integer)
                         : print("Integer editing ends: ", viewModel.integer)
                    },
                    onCommit: { print("Integer commit: ",viewModel.integer) }
          )
       }
       .padding()
    
       Button("Run") {
          viewModel.runModel()
       }
    }
    

    }

    extension ParameterView {
    var realFormatter: NumberFormatter {
    let formatter = NumberFormatter()
    formatter.numberStyle = .decimal
    formatter.maximumFractionDigits = 6
    formatter.roundingMode = .halfEven
    return formatter
    }

    var integerFormatter: NumberFormatter {
       let formatter = NumberFormatter()
       formatter.numberStyle = .none
       return formatter
    }
    

    }

1 Like

I just read this in the documentation of the TextField initializer:

formatter

A formatter to use when converting between the string the user edits and the underlying value of type T. In the event that formatter is unable to perform the conversion, binding.value isn’t modified.

So this may be at the heart of my problem.

I guess this is the answer I was looking for though I don’t quite understand the Combine bit.

*** deleted and replaced by article below ***

Well I’ve cracked it: a replacement for the TextField initialiser referred to in Chapter 6 which doesn’t work correctly, misleadingly failing to update the view when focus is lost, (among other problems).

My CustomField<T: CustomFieldProtocol>(title: String, placeholder: String, value: Binding) will apply the following rules to any type which conforms to CustomFieldProtocol (see examples at end of posting).

/*   Editing rules
 When a TextField obtains focus: colour the foreground red as a visual signal
 While editing: accept any input until:
 1) we lose focus 
 or 2) we hit enter 
In both cases then recolour the foreground black and .... 
(if contents valid, accept) or (if invalid, revert to pre-edit values)
*/

Here’s the code (split into two structs):

struct CustomField<T: CustomFieldProtocol>: View {
   var title: String = ""
   var placeholder: String = ""
   
   @Binding var value: T { didSet { self.string = value.simpleString } }
   @State private var string: String = ""
   
   var body: some View {
      HStack {
         if title.count > 0 {
            Text(title)
               .frame(width: 150, alignment: .trailing)
         } else {Spacer(minLength: 150)}

         RevertingField(placeholder: placeholder,
                        string: $string,
                        revert: T.revert)
            .onAppear { self.string = value.simpleString }
            .onChange(of: string)  {
               if let v = T($0) { value = v }
            }
            .frame(width:120, alignment: .trailing)
      }
   }
}

fileprivate struct RevertingField: View {
   var placeholder: String
   @Binding var string: String
   
   @State private var typing = false
   @State private var store = ""
   @State private var foregroundColor: Color? = .black
   var revert: (String) -> Bool
   
   var body: some View {
      HStack {
         TextField(placeholder,
                   text: $string,
                   onEditingChanged: { isEditing in
                     self.typing = isEditing
                     if typing {
                        foregroundColor = .red
                        store = string
                     }
                   },
                   onCommit: {
                     foregroundColor = .black
                     if revert(string) { string = store }
                   })
            .onChange(of: typing) { _ in
               if !typing {
                  if revert(string) { string = store }
                  foregroundColor = .black
               }
            }
            .multilineTextAlignment(.trailing)
            .foregroundColor(foregroundColor)
            .padding(EdgeInsets(top: 8, leading: 16,
                                bottom: 8, trailing: 16))
            .background(Color.white)
            .overlay(
              RoundedRectangle(cornerRadius: 8)
                .stroke(lineWidth: 2)
            )
            .shadow(color: Color.gray.opacity(0.4),
                    radius: 3, x: 1, y: 2)
         Spacer()
      }
   }
}

Pretty simple really! CustomField converts the bound T into a string for handling by RevertingField and converts the string returned to it into the bound T It uses .onAppear to load the value as a string and .onCommit to restore it.

The job of RevertingField is to watch the string being entered and enforce the rules. It uses the onEditingChanged and onCommit parameters and the .onChange modifier to achieve this. The first spots that editing has commenced and colours the foreground. Any kind of editing is permitted but when the user loses the focus or commits, which is noticed using the isTyping Bool, the other functions validate simply by casting the string as a T or reverting to the original value held in store. No formatter required!!

Here’s the protocol and its application to double, int and string. The only “difficult” part is the revert var which provides a function which basically casts the T as a string to see if it is valid.

protocol CustomFieldProtocol {
   var simpleString: String { get }
   init?(_ string: String)
   static var revert: (String) -> Bool { get }
}

extension Double: CustomFieldProtocol {
   var simpleString: String {
      var str = String(format: "%f", self)
      while str.last == "0" { str.removeLast() }
      return str
   }
   static var revert: (String) -> Bool {
      return { str in Double(str) == nil }
   }
}

extension Int: CustomFieldProtocol {
   var simpleString: String { String(format: "%d", self) }
   static var revert: (String) -> Bool {
      return { str in Int(str) == nil }
   }
}

extension String: CustomFieldProtocol {
   var simpleString: String { self }
   static var revert: (String) -> Bool { return { _ in false } }
}

Here’s how to use it:

@main
 struct EditApp: App {
   @State var number: Double =  123.4
    var body: some Scene {
        WindowGroup {
              CustomField(title: "Real number:", placeholder: "enter value", value: $value)
        }
    }
}

I hope it’s useful.

A residual issue is that the most recently edited field always holds the focus and therefore remains red.

1 Like

Hi @tchelyzt, thanks a lot for posting this - I’ll check it out soon, sorry for replying so late.

I’d drop it here. I’ve since improved the code and posted a zip here