├── DecimalField.swift └── README.md /DecimalField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DecimalField.swift 3 | // 4 | // Created by Edwin Watkeys on 9/20/19. 5 | // Copyright © 2019 Edwin Watkeys. 6 | // 7 | // Permission is hereby granted, free of charge, to any person 8 | // obtaining a copy of this software and associated documentation 9 | // files (the "Software"), to deal in the Software without 10 | // restriction, including without limitation the rights to use, 11 | // copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | // copies of the Software, and to permit persons to whom the Software 13 | // is furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be 16 | // included in all copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | // DEALINGS IN THE SOFTWARE. 26 | // 27 | 28 | import SwiftUI 29 | import Combine 30 | 31 | struct DecimalField : View { 32 | let label: LocalizedStringKey 33 | @Binding var value: Decimal? 34 | let formatter: NumberFormatter 35 | let onEditingChanged: (Bool) -> Void 36 | let onCommit: () -> Void 37 | 38 | // The formatter that formats the editing string. 39 | private let editStringFormatter: NumberFormatter 40 | 41 | // The text shown by the wrapped TextField. This is also the "source of 42 | // truth" for the `value`. 43 | @State private var textValue: String = "" 44 | 45 | // When the view loads, `textValue` is not synced with `value`. 46 | // This flag ensures we don't try to get a `value` out of `textValue` 47 | // before the view is fully initialized. 48 | @State private var hasInitialTextValue = false 49 | 50 | init( 51 | _ label: LocalizedStringKey, 52 | value: Binding, 53 | formatter: NumberFormatter, 54 | onEditingChanged: @escaping (Bool) -> Void = { _ in }, 55 | onCommit: @escaping () -> Void = {} 56 | ) { 57 | self.label = label 58 | self._value = value 59 | self.formatter = formatter 60 | self.onEditingChanged = onEditingChanged 61 | self.onCommit = onCommit 62 | 63 | // We configure the edit string formatter to behave like the 64 | // input formatter without add the currency symbol, 65 | // percent symbol, etc... 66 | self.editStringFormatter = NumberFormatter() 67 | self.editStringFormatter.allowsFloats = formatter.allowsFloats 68 | self.editStringFormatter.alwaysShowsDecimalSeparator = formatter.alwaysShowsDecimalSeparator 69 | self.editStringFormatter.decimalSeparator = formatter.decimalSeparator 70 | self.editStringFormatter.maximumIntegerDigits = formatter.maximumIntegerDigits 71 | self.editStringFormatter.maximumSignificantDigits = formatter.maximumSignificantDigits 72 | self.editStringFormatter.maximumFractionDigits = formatter.maximumFractionDigits 73 | self.editStringFormatter.multiplier = formatter.multiplier 74 | } 75 | 76 | var body: some View { 77 | TextField(label, text: $textValue, onEditingChanged: { isInFocus in 78 | // When the field is in focus we replace the field's contents 79 | // with a plain specifically formatted number. When not in focus, the field 80 | // is treated as a label and shows the formatted value. 81 | if isInFocus { 82 | let newValue = self.formatter.number(from: self.textValue) 83 | self.textValue = self.editStringFormatter.string(for: newValue) ?? "" 84 | } else { 85 | let f = self.formatter 86 | let newValue = f.number(from: self.textValue)?.decimalValue 87 | self.textValue = f.string(for: newValue) ?? "" 88 | } 89 | self.onEditingChanged(isInFocus) 90 | }, onCommit: { 91 | self.onCommit() 92 | }) 93 | .onReceive(Just(textValue)) { 94 | guard self.hasInitialTextValue else { 95 | // We don't have a usable `textValue` yet -- bail out. 96 | return 97 | } 98 | // This is the only place we update `value`. 99 | self.value = self.formatter.number(from: $0)?.decimalValue 100 | } 101 | .onAppear(){ // Otherwise textfield is empty when view appears 102 | self.hasInitialTextValue = true 103 | // Any `textValue` from this point on is considered valid and 104 | // should be synced with `value`. 105 | if let value = self.value { 106 | // Synchronize `textValue` with `value`; can't be done earlier 107 | self.textValue = self.formatter.string(from: NSDecimalNumber(decimal: value)) ?? "" 108 | } 109 | } 110 | .keyboardType(.decimalPad) 111 | } 112 | } 113 | 114 | struct DecimalField_Previews: PreviewProvider { 115 | static var previews: some View { 116 | TipCalculator() 117 | } 118 | 119 | struct TipCalculator: View { 120 | @State var amount: Decimal? = 50 121 | @State var tipRate: Decimal? 122 | 123 | var tipValue: Decimal { 124 | guard let amount = self.amount, let tipRate = self.tipRate else { 125 | return 0 126 | } 127 | return amount * tipRate 128 | } 129 | 130 | var totalValue: Decimal { 131 | guard let amount = self.amount else { 132 | return tipValue 133 | } 134 | return amount + tipValue 135 | } 136 | 137 | static var currencyFormatter: NumberFormatter { 138 | let nf = NumberFormatter() 139 | nf.numberStyle = .currency 140 | nf.isLenient = true 141 | return nf 142 | } 143 | 144 | static var percentFormatter: NumberFormatter { 145 | let nf = NumberFormatter() 146 | nf.numberStyle = .percent 147 | nf.isLenient = true 148 | return nf 149 | } 150 | 151 | var body: some View { 152 | Form { 153 | Section { 154 | DecimalField("Amount", value: $amount, formatter: Self.currencyFormatter) 155 | DecimalField("Tip Rate", value: $tipRate, formatter: Self.percentFormatter) 156 | } 157 | Section { 158 | HStack { 159 | Text("Tip Amount") 160 | Spacer() 161 | Text(Self.currencyFormatter.string(for: tipValue)!) 162 | } 163 | HStack { 164 | Text("Total") 165 | Spacer() 166 | Text(Self.currencyFormatter.string(for: totalValue)!) 167 | } 168 | } 169 | } 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NumberField: Real-time NumberFormatter Validation of TextField Input 2 | 3 | One of the most frustrating parts of building SwiftUI-based apps at 4 | the moment is dealing with input validation of numeric values. As of 5 | this writing, the native support for `numberFormatter`s in 6 | `TextField`s appears to be broken. 7 | 8 | Various folks[1] have suggested that the way around this is to create 9 | a `Binding` that manages conversion between string and numeric 10 | values. This is indeed possible -- and is part of what this code 11 | does. But there is currently an additional problem with `TextField`, 12 | and IIRC, it's also a maddening problem with `UITextField` in UIKit: 13 | Input validation (and the associated conversion of string to numeric 14 | values) happens either too often or not often enough. 15 | 16 | In a naive version of the use-a-binding approach, string to numeric 17 | and back to string conversion occurs with each keystroke, which is a 18 | nightmare if you are using a currency format. The string `"1"` gets 19 | converted to 1.0 which gets converted to `"$1.00"` with the insertion 20 | point not between the dollar sign and the numeral one. Comma and 21 | decimal point handling is similarly frustrating for the user. 22 | 23 | If you try to deal with this problem by implementing string validation 24 | and conversion only when focus is lost, you are now in a position 25 | where your numeric state object does not reflect the most recent value 26 | entered by the user. This makes it impossible to type "12.34" and then 27 | tap a button that then consumes the converted decimal. More subtley, 28 | you will not be able to guard the button's enabled state using 29 | something like `Button("Add"){ ... }.disabled(myNumber==nil)`. 30 | 31 | One way of partially mitigating some of these issues is using 32 | `onCommit:` but its behavaior if not broken is at least awkward. Most 33 | importantly, your `onCommit:` closure (only) fires when the user taps 34 | return on a text field, which means 1) more stale value problems and 35 | 2) using the decimal pad keyboard means your `onCommit`: handler will 36 | never be fired. 37 | 38 | I finally figured out an approach that works well for my needs, and it 39 | is based on the insight that things get much simpler if an additional 40 | numeric value is introduced. One `Decimal` holds the most recent 41 | numeric value _as of the last loss of focus_, which reflects the 42 | formatted appearance of the field when the user is not interacting 43 | with it. The other `Decimal` holds the most recent converted value _as 44 | of the last keystroke._ The insight is that you want to convert on a 45 | per-keystroke basis but you do not want to use that conversion to 46 | update the text field string contents until after the user is no 47 | longer typing in the field. 48 | 49 | Please enjoy. Pull requests et c. welcome. 50 | 51 | [1]: See [this Stack Overflow post](https://stackoverflow.com/questions/56799456/swiftui-textfield-with-formatter-not-working) and [this Twitter thread](https://twitter.com/olebegemann/status/1146823791605112833?lang=en). 52 | --------------------------------------------------------------------------------