Jamone who took Philip Pegden's approach to a more robust NumericTextField did us a great service. One problem I found with the approach however occurs if the NumericTextField is used in a scrollable list and part scrolls out of view. The internal state of the string can be lost causing unexpected behavior on scrolling. I also wanted to be able to enter negative numbers and exponential parts (numbers like -1.6E-19). I make a new NumericTextField that allows for options of a decimal point, an exponent and a minus sign that only contains the string. I also made a reformat function that is fired from the onEditingChanged false condition. My version works pretty well but still could use some more testing and improvements. Since a partially entered number creates updates immediately the partial entries often aren't numbers and return nil from the number converter. It seems it would be straightforward to remove the last character of the string on a failed conversion and try again until a number is returned or no more characters are left in which case a nil is returned. In general this would be the last good number entered.
If a lot of calculation occurs on a change it may be better to wait until done editing before binding, but then this is not the right textfield for that, as was the point originally at the top of the post. In any case, here is the code for my version as it is so far.
//String+Numeric.swift
import Foundation
public extension String {
/// Get the numeric only value from the string
/// - Parameter allowDecimalSeparator: If `true` then a single decimal separator will be allowed in the string's mantissa.
/// - Parameter allowMinusSign: If `true` then a single minus sign will be allowed at the beginning of the string.
/// - Parameter allowExponent: If `true` then a single e or E separator will be allowed in the string to start the exponent which can be a positive or negative integer
/// - Returns: Only numeric characters and optionally a single decimal character and optional an E followed by numeric characters.
/// If non-numeric values were interspersed `1a2b` then the result will be `12`.
/// The numeric characters returned may not be valid numbers so conversions will generally be optional strings.
func numericValue(allowDecimalSeparator: Bool = true, allowNegatives: Bool = true, allowExponent: Bool = true) -> String {
// Change parameters to single enum ?
var hasFoundDecimal = false
var allowMinusSign = allowNegatives // - can only be first char or first char after E (or e)
var hasFoundExponent = !allowExponent
var allowFindingExponent = false // initially false to avoid E as first character and then to prevent finding 2nd E
return self.filter {
if allowMinusSign && "-".contains($0){
return true
} else {
allowMinusSign = false
if $0.isWholeNumber {
allowFindingExponent = true
return true
} else if allowDecimalSeparator && String($0) == (Locale.current.decimalSeparator ?? ".") {
defer { hasFoundDecimal = true }
return !hasFoundDecimal
} else if allowExponent && !hasFoundExponent && allowFindingExponent && "eE".contains($0) {
allowMinusSign = true
hasFoundDecimal = true
allowFindingExponent = false
hasFoundExponent = true
return true
}
}
return false
}
}
This extension allows strings with minus signs and one E or e but only in the correct places.
Then the NumericTextModifier a la Jamone is
//NumericTextModifier.swift
import SwiftUI
/// A modifier that observes any changes to a string, and updates that string to remove any non-numeric characters.
/// It also will convert that string to a `NSNumber` for easy use.
public struct NumericTextModifier: ViewModifier {
/// Should the user be allowed to enter a decimal number, or an integer
public let isDecimalAllowed: Bool
public let isExponentAllowed: Bool
public let isMinusAllowed: Bool
/// The string that the text field is bound to
/// A number that will be updated when the `text` is updated.
@Binding public var number: String
/// - Parameters:
/// - number:: The string 'number" that this should observe and filter
/// - isDecimalAllowed: Should the user be allowed to enter a decimal number, or an integer
/// - isExponentAllowed: Should the E (or e) be allowed in number for exponent entry
/// - isMinusAllowed: Should negatives be allowed with minus sign (-) at start of number
public init( number: Binding<String>, isDecimalAllowed: Bool, isExponentAllowed: Bool, isMinusAllowed: Bool) {
_number = number
self.isDecimalAllowed = isDecimalAllowed
self.isExponentAllowed = isExponentAllowed
self.isMinusAllowed = isMinusAllowed
}
public func body(content: Content) -> some View {
content
.onChange(of: number) { newValue in
let numeric = newValue.numericValue(allowDecimalSeparator: isDecimalAllowed, allowNegatives: isMinusAllowed, allowExponent: isExponentAllowed).uppercased()
if newValue != numeric {
number = numeric
}
}
}
}
public extension View {
/// A modifier that observes any changes to a string, and updates that string to remove any non-numeric characters.
func numericText(number: Binding<String>, isDecimalAllowed: Bool, isMinusAllowed: Bool, isExponentAllowed: Bool) -> some View {
modifier(NumericTextModifier( number: number, isDecimalAllowed: isDecimalAllowed, isExponentAllowed: isExponentAllowed, isMinusAllowed: isMinusAllowed))
}
}
The NumericTextField then becomes:
// NumericTextField.swift
import SwiftUI
/// A `TextField` replacement that limits user input to numbers.
public struct NumericTextField: View {
/// This is what consumers of the text field will access
@Binding private var numericText: String
private let isDecimalAllowed: Bool
private let isExponentAllowed: Bool
private let isMinusAllowed: Bool
private let title: LocalizedStringKey
//private let formatter: NumberFormatter
private let onEditingChanged: (Bool) -> Void
private let onCommit: () -> Void
/// Creates a text field with a text label generated from a localized title string.
///
/// - Parameters:
/// - titleKey: The key for the localized title of the text field,
/// describing its purpose.
/// - numericText: The number to be displayed and edited.
/// - isDecimalAllowed: Should the user be allowed to enter a decimal number, or an integer
/// - isExponentAllowed:Should the user be allowed to enter a e or E exponent character
/// - isMinusAllowed:Should user be allow to enter negative numbers
/// - formatter: NumberFormatter to use on getting focus or losing focus used by on EditingChanged
/// - onEditingChanged: An action thats called when the user begins editing `text` and after the user finishes editing `text`.
/// The closure receives a Boolean indicating whether the text field is currently being edited.
/// - onCommit: An action to perform when the user performs an action (for example, when the user hits the return key) while the text field has focus.
public init(_ titleKey: LocalizedStringKey, numericText: Binding<String>, isDecimalAllowed: Bool = true,
isExponentAllowed: Bool = true,
isMinusAllowed: Bool = true,
onEditingChanged: @escaping (Bool) -> Void = { _ in },
onCommit: @escaping () -> Void = {}) {
_numericText = numericText
self.isDecimalAllowed = isDecimalAllowed || isExponentAllowed
self.isExponentAllowed = isExponentAllowed
self.isMinusAllowed = isMinusAllowed
title = titleKey
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
}
public var body: some View {
TextField(title, text: $numericText,
onEditingChanged: { exited in
if !exited {
numericText = reformat(numericText)
}
onEditingChanged(exited)},
onCommit: {
numericText = reformat(numericText)
onCommit() })
.onAppear { numericText = reformat(numericText) }
.numericText( number: $numericText, isDecimalAllowed: isDecimalAllowed, isMinusAllowed: isMinusAllowed, isExponentAllowed: isExponentAllowed )
//.modifier(KeyboardModifier(isDecimalAllowed: isDecimalAllowed))
}
}
func reformat(_ stringValue: String) -> String {
if let value = NumberFormatter().number(from: stringValue) {
let compare = value.compare(NSNumber(0.0))
if compare == .orderedSame {
return "0"
}
if (compare == .orderedAscending) { // value negative
let compare = value.compare(NSNumber(-1e-3))
if compare != .orderedDescending {
let compare = value.compare(NSNumber(-1e5))
if compare == .orderedDescending {
return value.stringValue
}
}
}
else {
let compare = value.compare(NSNumber(1e5))
if compare == .orderedAscending {
let compare = value.compare(NSNumber(1e-3))
if compare != .orderedAscending {
return value.stringValue
}
}
}
return value.scientificStyle
}
return stringValue
}
private struct KeyboardModifier: ViewModifier {
let isDecimalAllowed: Bool
func body(content: Content) -> some View {
#if os(iOS)
return content
.keyboardType(isDecimalAllowed ? .decimalPad : .numberPad)
#else
return content
#endif
}
}
I used the func reformat(String) -> String rather than a formatter directly. Reformat uses a couple of formatters and was more flexible at least to me.
import Foundation
var decimalNumberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.allowsFloats = true
return formatter
}()
var scientificFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .scientific
formatter.allowsFloats = true
return formatter
}()
extension NSNumber {
var scientificStyle: String {
return scientificFormatter.string(from: self) ?? description
}
}
I hope some of this helps others who want to use scientific notation and negative numbers in their app.
Happy coding.
IntField
, which takes about a dozen lines of code. See #56799956 β Alt