Средство форматирования NSTextField в реальном времени в контексте SwiftUI

После множества проб и ошибок я пришел к следующей реализации для форматирования в реальном времени числового ввода в текстовом поле. Различные попытки использовать 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
    }

1 ответ

После некоторых дополнительных проб и ошибок мне удалось исправить проблемы с курсором, упомянутые в моем первоначальном вопросе. Версия здесь, насколько мне известно, пуленепробиваемая (хотя команда тестировщиков приложит к этому руку, так что, возможно, она изменится).

По-прежнему приветствовал бы любые предложения по улучшению.

      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
    }
    
}
Другие вопросы по тегам