After lots of trial and error, I ended up with the following implementation to do real-time formatting for numeric entry in a text field. Various attempts to use a SwiftUI TextField() resulted in many anomalies. The approach below seems to be solid but even here I struggled with the proper approach to sub-classing NSTextField as I couldn't find any documentation on how to handle the designated initializer such that it would be compatible with SwiftUI's frame modifier.
The one minor remaining anomaly is that when placing the cursor in the middle of an entered number then typing non-numeric characters, the cursor advances even though no changes occur in the text. This is livable but I would prefer to keep that from happening.
Is there a better, more "proper" way to implement this?
import Foundation
import SwiftUI
struct NumberField : NSViewRepresentable {
typealias NSViewType = NumberText
var defaultText : String
var maxDigits : Int
var numberValue : Binding<Int>
func makeNSView(context: Context) -> NSViewType {
// Create text field
let numberTextField = NumberText()
numberTextField.isEditable = true
// numberTextField.numberBinding = numberValue
numberTextField.configure(text: defaultText, digits: maxDigits, intBinding: numberValue)
return numberTextField
}
func updateNSView(_ nsView: NSViewType, context: Context) {
// nsView.stringValue = "This is my string"
}
}
/// NumberText draws an NSTextField that will accept only digits up to a maximum number specified when calling Configure. Apple implements some nice integration between SwiftUI's frame and padding modifiers and the NSTextField's designated initializer. Rather than having to figure out how to fix/preserve this integration, this class provides a "configure()" function that is effectively it's initializer. As a result, it is MANDATORY that this class's configure() function be called immediately after initializing the class.
class NumberText : NSTextField {
// Code below jumps through a couple of hoops to avoid having to write a custom initializer since that gets in the middle of Apple's configuration of the text field using standard SwiftUI modifiers.
// NOTE THAT A USER OF NumberText MUST CALL CONFIGURE() IMMEDIATELY AFTER CREATING IT
var numberBinding : Binding<Int> = Binding( // This is initialized with a garbage Binding just to avoid having to write an initializer
get: {return -1},
set: {newValue in return}
)
var defaultText = "Default String"
var maxDigits = 9
private var decimalFormatter = NumberFormatter()
func configure(text: String, digits: Int, intBinding: Binding<Int>) { // Configure is used here instead of attempting to override init()
// Configure values
decimalFormatter.numberStyle = .decimal
defaultText = text
self.placeholderString = defaultText
maxDigits = digits
numberBinding = intBinding
// Set up TextField values
self.integerValue = numberBinding.wrappedValue
if self.integerValue == 0 {self.stringValue = ""}
}
override func textDidChange(_ notification: Notification) {
self.stringValue = numberTextFromString(self.stringValue)
if self.stringValue == "0" {self.stringValue = ""}
}
func numberTextFromString(_ inputText: String, maxLength: Int = 9) -> String {
// Create a filtered and trucated version of inputText
let filteredText = inputText.filter { character in
character.isNumber
}
let truncatedText = String(filteredText.suffix(maxLength))
// Make a number from truncated text
let myNumber = Int(truncating: decimalFormatter.number(from: truncatedText) ?? 0 )
// Set binding value
numberBinding.wrappedValue = myNumber
// Create formatted string for return
let returnValue = decimalFormatter.string(from: myNumber as NSNumber) ?? "?"
return returnValue
}
After some additional trial and error, I was able to fix the cursor problems mentioned in my initial question. The version here is, to the best of my knowledge, bullet proof (though the test team will have a whack at it so perhaps it will change).
Would still welcome any improvement suggestions.