├── .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 | [](https://developer.apple.com/swift/)
4 | [](https://swift.org/package-manager)
5 | [](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 | 
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 | 
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 |
--------------------------------------------------------------------------------