I tried out Claude's code and it did mostly a great job, but it had a few serious problems. It works well if the user is only appending characters and only deleting from the end, but if they move the cursor and add/delete from the middle, or select several characters and add/delete replacing a multi-character range, then the code doesn't behave properly and jumps the cursor to the end of the text every time.
This code is based on Claude's and is a little more convoluted, but it seems to handle all of the scenarios that I can come up with and it should maintain the user's cursor in the same place that it was before we changed the text in the textfield.
My code also limits the fractional portion to a max of 3 digits, but you can customize that if you want.
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let oldText = textField.text ?? ""
let oldTextNS = oldText as NSString
let newText = oldTextNS.replacingCharacters(in: range, with: string)
formatter.usesGroupingSeparator = true
formatter.numberStyle = NumberFormatter.Style.decimal
formatter.maximumFractionDigits = 6
var wasBackspace = false
if let char = string.cString(using: String.Encoding.utf8) {
let compareToBackspace = strcmp(char, "\\b")
if (compareToBackspace == -92) {
wasBackspace = true
}
}
let strippedNewText = newText.replacingOccurrences(of: formatter.groupingSeparator, with: "")
if strippedNewText == "-" {
// Accept leading minus
return true
}
if strippedNewText.lengthOfBytes(using: .utf8) == 0 {
return true
} else {
// limit entry of fractional digits to max 3 places. But in order to do that, we need to let it format
// to more than 3 digits and then check if it is generating more than 3 digits so that we can tell
// the textfield to reject the change.
guard let value = Double(strippedNewText) else {
// couldn't parse a valid double, so reject the insert.
return false
}
let formattedNumber = formatter.string(from: NSNumber(value: value)) ?? ""
let formattedNumberWithoutThousandsGroups = formattedNumber.replacingOccurrences(of: formatter.groupingSeparator, with: "")
guard let decimal = Decimal(string: formattedNumberWithoutThousandsGroups) else {
// couldn't parse a valid decimal, so reject the insert.
return false
}
// significantFractionalDecimalDigits is a custom extension:
//
// extension Decimal {
// var significantFractionalDecimalDigits: Int {
// return max(-exponent, 0)
// }
// }
if decimal.significantFractionalDecimalDigits > 3 {
// too many fractional digits, don't let them insert another
return false
}
textField.text = formattedNumber
// check the original text, count up the number of grouping characters
// we had in the substring before the changed range
var numGroupersBeforeChange = 0
var numNumberCharsBeforeChange = 0
var i = 0
for index in oldText.indices {
if i == range.location {
break
}
i += 1
let char = oldText[index]
if String(char) == formatter.groupingSeparator {
numGroupersBeforeChange += 1
} else {
numNumberCharsBeforeChange += 1
}
}
// check the newly formatted text, count up the number of grouping characters
// we have spanning the range of the string covered that is analogous to the old text
// up to the changed ranged.
// for example, if the old text was "123,456,789" and we insert a 0 between the 5 and 6...
// the string before the change would be "123,45" and numgroupersbeforechange=1
// the analogous string after the change would be "1,234,5" and numgroupersafterchange=2
var numGroupersAfterChange = 0
var numNumberCharsAfterChange = 0
i = 0
for index in formattedNumber.indices {
let char = formattedNumber[index]
if String(char) == formatter.groupingSeparator {
numGroupersAfterChange += 1
} else {
numNumberCharsAfterChange += 1
}
if numNumberCharsBeforeChange == numNumberCharsAfterChange {
break
}
i += 1
}
let numGroupersAdded = numGroupersAfterChange - numGroupersBeforeChange
var cursorOffset = range.location + numGroupersAdded
let numCharsAddedByUserChange = wasBackspace ? 0 : string.count
cursorOffset += numCharsAddedByUserChange
if let newPosition = textField.position(from: textField.beginningOfDocument, offset: cursorOffset) {
let newSelectedRange = textField.textRange(from: newPosition, to: newPosition)
textField.selectedTextRange = newSelectedRange
}
// No need to insert the typed char: we've done just above, unless we just typed separator
return string == formatter.decimalSeparator
}
}