├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md └── Sources └── CurrencyField └── CurrencyField.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Javier Trinchero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "CurrencyField", 7 | platforms: [ 8 | .iOS(.v13) 9 | ], 10 | products: [ 11 | .library( 12 | name: "CurrencyField", 13 | targets: ["CurrencyField"] 14 | ), 15 | ], 16 | dependencies: [], 17 | targets: [ 18 | .target( 19 | name: "CurrencyField", 20 | dependencies: [] 21 | ), 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CurrencyField 2 | 3 | [![Swift](https://img.shields.io/badge/swift-5.0-orange.svg)](https://developer.apple.com/swift/) 4 | [![SwiftPm](https://img.shields.io/badge/swiftpm-compatible-limegreen.svg?)](https://swift.org/package-manager) 5 | [![SwiftUI](https://img.shields.io/badge/swiftUI-blue.svg)](https://developer.apple.com/xcode/swiftui/) 6 | 7 | With **Currency Field** you can create a numerical input field most commonly found in banking apps. The user types in the amount, and the digits fill in from the right. It uses a `UITextField` for custom input, and a `SwiftUI.Text` to display the formatted value which allows for easy styling. This also makes it possible to use the `.focused()` modifier to update its first responder state. 8 | 9 | Example: 10 | 11 | ![Example](https://trinchero.me/samples/swiftui-currency-field-sample-1.gif) 12 | 13 | ## Installation 14 | 15 | ### Swift Package Manager 16 | 17 | ```text 18 | dependencies: [ 19 | .package(url: "https://github.com/jtrinc/swiftui-currency-field.git", from: "1.0.0") 20 | ] 21 | ``` 22 | 23 | ## Sample Usage 24 | 25 | ```swift 26 | import SwiftUI 27 | import CurrencyField 28 | 29 | struct Content: View { 30 | @State private var value: Int = 0 31 | 32 | var body: some View { 33 | Form { 34 | HStack { 35 | Spacer() 36 | 37 | CurrencyField(value: $value) 38 | .font(.largeTitle.monospaced()) 39 | } 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | * The value assigned is an `Int` representing the amount in cents. 46 | 47 | ### Using a Custom Number Formatter 48 | 49 | ```swift 50 | import SwiftUI 51 | import CurrencyField 52 | 53 | struct Content: View { 54 | @State private var value = 0 55 | @State private var chosenLocale = Locale(identifier: "en_CA") 56 | 57 | private var formatter: NumberFormatter { 58 | let fmt = NumberFormatter() 59 | fmt.numberStyle = .currency 60 | fmt.minimumFractionDigits = 2 61 | fmt.maximumFractionDigits = 2 62 | fmt.locale = chosenLocale 63 | return fmt 64 | } 65 | 66 | private let locales = [ 67 | Locale(identifier: "en_CA"), 68 | Locale(identifier: "fr_FR"), 69 | Locale(identifier: "ja_JP"), 70 | ] 71 | 72 | var body: some View { 73 | Form { 74 | Picker(selection: $chosenLocale) { 75 | ForEach(locales, id: \.self) { 76 | if let cc = $0.currencyCode, let sym = $0.currencySymbol { 77 | Text("\(cc) \(sym)") 78 | } 79 | } 80 | } label: { 81 | Text("Currency") 82 | } 83 | 84 | CurrencyField(value: $value, formatter: formatter) 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | * Pass in a custom number formatter to support different currencies. 91 | 92 | ![Change Currency](https://trinchero.me/samples/swiftui-currency-field-sample-2.gif) 93 | 94 | ## Credits 95 | 96 | This package was originally inspired by this tutorial: [Currency TextField in SwiftUI](https://benoitpasquier.com/currency-textfield-in-swiftui/) 97 | 98 | ## License 99 | 100 | `CurrencyField` is available under the MIT license. See the [LICENSE](/LICENSE) for more info. 101 | -------------------------------------------------------------------------------- /Sources/CurrencyField/CurrencyField.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | public struct CurrencyField: View { 5 | @Binding var value: Int 6 | var formatter: NumberFormatter 7 | 8 | private var label: String { 9 | let mag = pow(10, formatter.maximumFractionDigits) 10 | return formatter.string(for: Decimal(value) / mag) ?? "" 11 | } 12 | 13 | public init(value: Binding, formatter: NumberFormatter) { 14 | self._value = value 15 | self.formatter = formatter 16 | } 17 | 18 | public init(value: Binding) { 19 | let formatter = NumberFormatter() 20 | formatter.numberStyle = .currency 21 | formatter.minimumFractionDigits = 2 22 | formatter.maximumFractionDigits = 2 23 | formatter.locale = .current 24 | 25 | self.init(value: value, formatter: formatter) 26 | } 27 | 28 | public var body: some View { 29 | ZStack { 30 | // Text view to display the formatted currency 31 | // Set as priority so CurrencyInputField size doesn't affect parent 32 | Text(label).layoutPriority(1) 33 | 34 | // Input text field to handle UI 35 | CurrencyInputField(value: $value, formatter: formatter) 36 | } 37 | } 38 | } 39 | 40 | // Sub-class UITextField to remove selection and caret 41 | class NoCaretTextField: UITextField { 42 | override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { 43 | false 44 | } 45 | 46 | override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { 47 | [] 48 | } 49 | 50 | override func caretRect(for position: UITextPosition) -> CGRect { 51 | .null 52 | } 53 | } 54 | 55 | struct CurrencyInputField: UIViewRepresentable { 56 | @Binding var value: Int 57 | var formatter: NumberFormatter 58 | 59 | func makeCoordinator() -> Coordinator { 60 | Coordinator(self) 61 | } 62 | 63 | func makeUIView(context: Context) -> NoCaretTextField { 64 | let textField = NoCaretTextField(frame: .zero) 65 | 66 | // Assign delegate 67 | textField.delegate = context.coordinator 68 | 69 | // Set keyboard type 70 | textField.keyboardType = .numberPad 71 | 72 | // Make visual components invisible 73 | textField.tintColor = .clear 74 | textField.textColor = .clear 75 | textField.backgroundColor = .clear 76 | 77 | // Add editing changed event handler 78 | textField.addTarget( 79 | context.coordinator, 80 | action: #selector(Coordinator.editingChanged(textField:)), 81 | for: .editingChanged 82 | ) 83 | 84 | // Set initial textfield text 85 | context.coordinator.updateText(value, textField: textField) 86 | 87 | return textField 88 | } 89 | 90 | func updateUIView(_ uiView: NoCaretTextField, context: Context) {} 91 | 92 | class Coordinator: NSObject, UITextFieldDelegate { 93 | // Reference to currency input field 94 | private var input: CurrencyInputField 95 | 96 | // Last valid text input string to be displayed 97 | private var lastValidInput: String? = "" 98 | 99 | init(_ currencyTextField: CurrencyInputField) { 100 | self.input = currencyTextField 101 | } 102 | 103 | func setValue(_ value: Int, textField: UITextField) { 104 | // Update input value 105 | input.value = value 106 | 107 | // Update textfield text 108 | updateText(value, textField: textField) 109 | } 110 | 111 | func updateText(_ value: Int, textField: UITextField) { 112 | // Update field text and last valid input text 113 | textField.text = String(value) 114 | lastValidInput = String(value) 115 | } 116 | 117 | func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 118 | // If replacement string is empty, we can assume the backspace key was hit 119 | if string.isEmpty { 120 | // Resign first responder when delete is hit when value is 0 121 | if input.value == 0 { 122 | textField.resignFirstResponder() 123 | } 124 | 125 | // Remove trailing digit 126 | setValue(Int(input.value / 10), textField: textField) 127 | } 128 | return true 129 | } 130 | 131 | @objc func editingChanged(textField: NoCaretTextField) { 132 | // Get a mutable copy of last text 133 | guard var oldText = lastValidInput else { 134 | return 135 | } 136 | 137 | // Iterate through each char of the new string and compare LTR with old string 138 | let char = (textField.text ?? "").first { next in 139 | // If old text is empty or its next character doesn't match new 140 | if oldText.isEmpty || next != oldText.removeFirst() { 141 | // Found the mismatching character 142 | return true 143 | } 144 | return false 145 | } 146 | 147 | // Find new character and try to get an Int value from it 148 | guard let char = char, let digit = Int(String(char)) else { 149 | // New character could not be converted to Int 150 | // Revert to last valid text 151 | textField.text = lastValidInput 152 | return 153 | } 154 | 155 | // Multiply by 10 to shift numbers one position to the left, revert if an overflow occurs 156 | let (multValue, multOverflow) = input.value.multipliedReportingOverflow(by: 10) 157 | if multOverflow { 158 | textField.text = lastValidInput 159 | return 160 | } 161 | 162 | // Add the new trailing digit, revert if an overflow occurs 163 | let (addValue, addOverflow) = multValue.addingReportingOverflow(digit) 164 | if addOverflow { 165 | textField.text = lastValidInput 166 | return 167 | } 168 | 169 | // If new value has more digits than allowed by formatter, revert 170 | if input.formatter.maximumFractionDigits + input.formatter.maximumIntegerDigits < String(addValue).count { 171 | textField.text = lastValidInput 172 | return 173 | } 174 | 175 | // Update new value 176 | setValue(addValue, textField: textField) 177 | } 178 | } 179 | } 180 | --------------------------------------------------------------------------------