SwiftUI 上下文中的实时 NSTextField 格式化程序
Real-time NSTextField formatter in SwiftUI context
经过反复试验,我最终实现了以下实现,可以对文本字段中的数字输入进行实时格式化。使用 SwiftUI TextField() 的各种尝试导致了许多异常。下面的方法似乎是可靠的,但即使在这里我也很难找到正确的子类化 NSTextField 的方法,因为我找不到任何关于如何处理指定的初始化程序以使其与 SwiftUI 的框架修饰符兼容的文档。
剩下的一个小异常是,当将光标放在输入数字的中间然后键入非数字字符时,即使文本没有发生变化,光标也会前进。这是宜居的,但我宁愿避免这种情况发生。
有没有更好、更“合适”的方法来实现这个?
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
}
经过一些额外的试验和错误,我能够解决我最初问题中提到的光标问题。据我所知,这里的版本是防弹的(尽管测试团队会对此进行重击,所以它可能会改变)。
仍然欢迎任何改进建议。
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.configure(text: defaultText, digits: maxDigits, intBinding: numberValue)
return numberTextField
}
func updateNSView(_ nsView: NSViewType, context: Context) {
}
}
/// 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
// The following variable declarations are all immediately initialized to avoid having to write an init() function
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
// Make sure that default text is shown if numberBinding.wrappedValue is 0
if numberBinding.wrappedValue == 0 {self.stringValue = ""}
}
override func textDidChange(_ notification: Notification) {
self.stringValue = numberTextFromString(self.stringValue, maxLength: maxDigits) // numberTextFromString() also sets the wrappedValue of numberBinding
if self.stringValue == "0" {self.stringValue = ""}
}
/// Takes in string from text field and returns the best number string that can be made from it by removing any non-numeric characters and adding comma separators in the right places.
/// Along the way, self.numberBinding.warppedValue is set to the Int corresponding to the output string and self's cursor is reset to account for the erasure of invalid characters and the addition of commas
/// - Parameters:
/// - inputText: Incoming text from text field
/// - maxLength: Maximum number of digits allowed in this field
/// - Returns:String representing number
func numberTextFromString(_ inputText: String, maxLength: Int) -> String {
var decrementCursorForInvalidChar = 0
var incomingDigitsBeforeCursor = 0
// For cursor calculation, find digit count behind cursor in incoming string
// Get incoming cursor location
let incomingCursorLocation = currentEditor()?.selectedRange.location ?? 0
// Create prefix behind incoming cursor location
let incomingPrefixToCursor = inputText.prefix(incomingCursorLocation)
// Count digits in prefix
for character in incomingPrefixToCursor {
if character.isNumber == true {
incomingDigitsBeforeCursor += 1
}
}
// Create a filtered and trucated version of inputText
var characterCount = 0
let filteredText = inputText.filter { character in
characterCount += 1
if character.isNumber == true {
return true
} else { // character is invalid or comma.
if character != "," { // character is invalid,
if characterCount < inputText.count { // Only decrement cursor if not at end of string
// Decrement cursor
decrementCursorForInvalidChar += 1
}
}
return false
}
}
// Decrement cursor as needed for invalid character entries
currentEditor()!.selectedRange.location = incomingCursorLocation - decrementCursorForInvalidChar
let truncatedText = String(filteredText.prefix(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 outgoingString = decimalFormatter.string(from: myNumber as NSNumber) ?? "?"
// For cursor calculation, find character representing incomingDigitsBeforeCursor.lastIndex
var charCount = 0
var digitCount = 0
var charIndex = outgoingString.startIndex
while digitCount < incomingDigitsBeforeCursor && charCount < outgoingString.count {
charIndex = outgoingString.index(outgoingString.startIndex, offsetBy: charCount)
charCount += 1
if outgoingString[charIndex].isNumber == true {
digitCount += 1
}
}
// Get integer corresponding to current charIndex
let outgoingCursorLocation = outgoingString.distance(from: outgoingString.startIndex, to: charIndex) + 1
currentEditor()!.selectedRange.location = outgoingCursorLocation
return outgoingString
}
}
经过反复试验,我最终实现了以下实现,可以对文本字段中的数字输入进行实时格式化。使用 SwiftUI TextField() 的各种尝试导致了许多异常。下面的方法似乎是可靠的,但即使在这里我也很难找到正确的子类化 NSTextField 的方法,因为我找不到任何关于如何处理指定的初始化程序以使其与 SwiftUI 的框架修饰符兼容的文档。
剩下的一个小异常是,当将光标放在输入数字的中间然后键入非数字字符时,即使文本没有发生变化,光标也会前进。这是宜居的,但我宁愿避免这种情况发生。
有没有更好、更“合适”的方法来实现这个?
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
}
经过一些额外的试验和错误,我能够解决我最初问题中提到的光标问题。据我所知,这里的版本是防弹的(尽管测试团队会对此进行重击,所以它可能会改变)。
仍然欢迎任何改进建议。
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.configure(text: defaultText, digits: maxDigits, intBinding: numberValue)
return numberTextField
}
func updateNSView(_ nsView: NSViewType, context: Context) {
}
}
/// 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
// The following variable declarations are all immediately initialized to avoid having to write an init() function
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
// Make sure that default text is shown if numberBinding.wrappedValue is 0
if numberBinding.wrappedValue == 0 {self.stringValue = ""}
}
override func textDidChange(_ notification: Notification) {
self.stringValue = numberTextFromString(self.stringValue, maxLength: maxDigits) // numberTextFromString() also sets the wrappedValue of numberBinding
if self.stringValue == "0" {self.stringValue = ""}
}
/// Takes in string from text field and returns the best number string that can be made from it by removing any non-numeric characters and adding comma separators in the right places.
/// Along the way, self.numberBinding.warppedValue is set to the Int corresponding to the output string and self's cursor is reset to account for the erasure of invalid characters and the addition of commas
/// - Parameters:
/// - inputText: Incoming text from text field
/// - maxLength: Maximum number of digits allowed in this field
/// - Returns:String representing number
func numberTextFromString(_ inputText: String, maxLength: Int) -> String {
var decrementCursorForInvalidChar = 0
var incomingDigitsBeforeCursor = 0
// For cursor calculation, find digit count behind cursor in incoming string
// Get incoming cursor location
let incomingCursorLocation = currentEditor()?.selectedRange.location ?? 0
// Create prefix behind incoming cursor location
let incomingPrefixToCursor = inputText.prefix(incomingCursorLocation)
// Count digits in prefix
for character in incomingPrefixToCursor {
if character.isNumber == true {
incomingDigitsBeforeCursor += 1
}
}
// Create a filtered and trucated version of inputText
var characterCount = 0
let filteredText = inputText.filter { character in
characterCount += 1
if character.isNumber == true {
return true
} else { // character is invalid or comma.
if character != "," { // character is invalid,
if characterCount < inputText.count { // Only decrement cursor if not at end of string
// Decrement cursor
decrementCursorForInvalidChar += 1
}
}
return false
}
}
// Decrement cursor as needed for invalid character entries
currentEditor()!.selectedRange.location = incomingCursorLocation - decrementCursorForInvalidChar
let truncatedText = String(filteredText.prefix(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 outgoingString = decimalFormatter.string(from: myNumber as NSNumber) ?? "?"
// For cursor calculation, find character representing incomingDigitsBeforeCursor.lastIndex
var charCount = 0
var digitCount = 0
var charIndex = outgoingString.startIndex
while digitCount < incomingDigitsBeforeCursor && charCount < outgoingString.count {
charIndex = outgoingString.index(outgoingString.startIndex, offsetBy: charCount)
charCount += 1
if outgoingString[charIndex].isNumber == true {
digitCount += 1
}
}
// Get integer corresponding to current charIndex
let outgoingCursorLocation = outgoingString.distance(from: outgoingString.startIndex, to: charIndex) + 1
currentEditor()!.selectedRange.location = outgoingCursorLocation
return outgoingString
}
}