├── .github
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── ci.yml
├── .gitignore
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ ├── CurrencyFormatter.xcscheme
│ ├── CurrencyText-Package.xcscheme
│ ├── CurrencyText.xcscheme
│ └── CurrencyTextSwiftUI.xcscheme
├── CurrencyText-Package.xctestplan
├── CurrencyText.podspec
├── CurrencyText.xcworkspace
├── contents.xcworkspacedata
└── xcshareddata
│ ├── IDEWorkspaceChecks.plist
│ └── swiftpm
│ └── Package.resolved
├── Example
├── Example.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ │ └── xcschemes
│ │ └── Example.xcscheme
└── Example
│ ├── AppDelegate.swift
│ ├── Assets.xcassets
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── Contents.json
│ ├── Base.lproj
│ └── LaunchScreen.storyboard
│ ├── Info.plist
│ ├── RootView.swift
│ ├── SwiftUI
│ ├── SwiftUIExampleView.swift
│ └── View+Extensions.swift
│ └── UIKit
│ ├── UIKitExampleView.swift
│ └── UIKitExampleViewController.swift
├── Gemfile
├── Gemfile.lock
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
├── CurrencyTextFieldTestSupport
│ └── Fixtures
│ │ ├── Fixtures+CurrencyFormatter.swift
│ │ └── Fixtures+CurrencyTextFieldConfiguration.swift
├── Formatter
│ ├── Currency.swift
│ ├── CurrencyFormatter.swift
│ ├── CurrencyLocale.swift
│ ├── NumberFormatter.swift
│ └── String.swift
├── SwiftUI
│ ├── CurrencyTextField.swift
│ ├── CurrencyTextFieldConfiguration.swift
│ ├── OptionalBinding.swift
│ └── WrappedTextField.swift
└── UITextFieldDelegate
│ ├── CurrencyUITextFieldDelegate.swift
│ └── UITextField.swift
├── Tests
├── Formatter
│ ├── CurrencyFormatterTests.swift
│ ├── NumberFormatterTests.swift
│ └── StringTests.swift
├── SwiftUI
│ ├── CurrencyTextFieldConfigurationTests.swift
│ └── WrappedTextFieldTests.swift
├── SwiftUISnapshotTests
│ ├── CurrencyTextFieldSnapshotTests.swift
│ └── __Snapshots__
│ │ └── CurrencyTextFieldSnapshotTests
│ │ ├── test.germanEuro.png
│ │ ├── test.noDecimals.png
│ │ ├── test.withDecimals.png
│ │ ├── test.withMinMaxValues.png
│ │ ├── test.yenJapanese.png
│ │ └── testWithCustomTextFiledConfiguration.1.png
└── UITextFieldDelegate
│ ├── CurrencyTextFieldDelegateTests.swift
│ ├── Mocks
│ └── PassthroughDelegateMock.swift
│ ├── UITextField.swift
│ └── UITextFieldTests.swift
├── UICurrencyTextField.podspec
├── codecov.yml
├── documentation
└── Documentation.md
├── fastlane
├── Appfile
├── Fastfile
└── README.md
└── images
└── logo.png
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## What problems I want to solve?
2 | The pod could have fastlane and ci integrations to:
3 | - pod delivery and tagging
4 | - PR ci checks
5 |
6 | ## What value it delivers?
7 | Repetitive work would be automated lowering chances of failures.
8 |
9 | ## Tasks
10 | - [ ] First Task
11 | - [ ] Subtask of first task
12 | - [ ] Second Task
13 | - [ ] Subtask of second task
14 |
15 | ## Dependecies / References
16 | No dependencies and/or references by now.
17 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ### Why?
2 | Describe the motivation
3 |
4 | ### Changes
5 | Describe the changes of this PR
6 |
7 | ### Tests
8 | How did you test to make sure this PR will not break
9 |
10 | ### [Issue]() (optional) add link to the issue here
11 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | workflow_dispatch:
6 |
7 | env:
8 | DEVELOPER_DIR: /Applications/Xcode_16.app/Contents/Developer
9 |
10 | concurrency:
11 | group: '${{ github.workflow }}-${{ github.head_ref }}'
12 | cancel-in-progress: true
13 |
14 | jobs:
15 | test:
16 | name: "Test"
17 | runs-on: macos-latest
18 | steps:
19 | - uses: actions/checkout@v2
20 |
21 | - name: Check cache for Bundler dependencies
22 | uses: actions/cache@v2
23 | with:
24 | path: vendor/bundle
25 | key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
26 | restore-keys: |
27 | ${{ runner.os }}-gems-
28 |
29 | - name: Run bundle install
30 | run: |
31 | bundle config path vendor/bundle
32 | bundle install
33 |
34 | - name: Check cache for Swift Package Manager dependencies
35 | uses: actions/cache@v2
36 | with:
37 | path: .build
38 | key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
39 | restore-keys: |
40 | ${{ runner.os }}-spm-
41 |
42 | - name: Execute fastlane `test`
43 | run: bundle exec fastlane test
44 |
45 | - name: Report code coverage
46 | uses: codecov/codecov-action@v4
47 | timeout-minutes: 10
48 | with:
49 | fail_ci_if_error: true
50 | token: ${{ secrets.CODECOV_TOKEN }}
51 | exclude: Tests/**/*
52 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 |
3 | ## Build generated
4 | build/
5 | DerivedData/
6 |
7 | ## Various settings
8 | *.pbxuser
9 | !default.pbxuser
10 | *.mode1v3
11 | !default.mode1v3
12 | *.mode2v3
13 | !default.mode2v3
14 | *.perspectivev3
15 | !default.perspectivev3
16 | xcuserdata/
17 |
18 | ## Other
19 | *.moved-aside
20 | *.xccheckout
21 | *.xcscmblueprint
22 |
23 | ## Obj-C/Swift specific
24 | *.hmap
25 | *.ipa
26 | *.dSYM.zip
27 | *.dSYM
28 | *.DS_Store
29 |
30 | # CocoaPods
31 | Pods/
32 |
33 | # Fastlane
34 | fastlane/report.xml
35 | fastlane/Preview.html
36 | fastlane/screenshots
37 | fastlane/test_output
38 |
39 | # Swift Package Manager
40 |
41 | # *.xcodeproj/
42 |
43 | .DS_Store
44 | .build
45 | Packages
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/CurrencyFormatter.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
35 |
41 |
42 |
43 |
44 |
45 |
55 |
56 |
62 |
63 |
69 |
70 |
71 |
72 |
74 |
75 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/CurrencyText-Package.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
43 |
49 |
50 |
51 |
57 |
63 |
64 |
65 |
71 |
77 |
78 |
79 |
85 |
91 |
92 |
93 |
99 |
105 |
106 |
107 |
113 |
119 |
120 |
121 |
127 |
133 |
134 |
135 |
141 |
147 |
148 |
149 |
150 |
151 |
157 |
158 |
161 |
162 |
163 |
164 |
168 |
174 |
175 |
176 |
180 |
186 |
187 |
188 |
192 |
198 |
199 |
200 |
204 |
210 |
211 |
212 |
213 |
214 |
224 |
225 |
231 |
232 |
238 |
239 |
240 |
241 |
243 |
244 |
247 |
248 |
249 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/CurrencyText.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
43 |
49 |
50 |
51 |
57 |
63 |
64 |
65 |
66 |
67 |
73 |
74 |
77 |
83 |
84 |
85 |
86 |
87 |
97 |
98 |
104 |
105 |
111 |
112 |
113 |
114 |
116 |
117 |
120 |
121 |
122 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/CurrencyTextSwiftUI.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
35 |
41 |
42 |
43 |
46 |
52 |
53 |
54 |
55 |
56 |
66 |
67 |
73 |
74 |
80 |
81 |
82 |
83 |
85 |
86 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/CurrencyText-Package.xctestplan:
--------------------------------------------------------------------------------
1 | {
2 | "configurations" : [
3 | {
4 | "id" : "0B05C04B-EDC2-4C2F-84B1-6DAB2FF7F614",
5 | "name" : "Configuration 1",
6 | "options" : {
7 | "region" : "US"
8 | }
9 | }
10 | ],
11 | "defaultOptions" : {
12 | "language" : "en"
13 | },
14 | "testTargets" : [
15 | {
16 | "parallelizable" : true,
17 | "target" : {
18 | "containerPath" : "container:",
19 | "identifier" : "CurrencyFormatterTests",
20 | "name" : "CurrencyFormatterTests"
21 | }
22 | },
23 | {
24 | "parallelizable" : true,
25 | "target" : {
26 | "containerPath" : "container:",
27 | "identifier" : "CurrencyTextFieldSnapshotTests",
28 | "name" : "CurrencyTextFieldSnapshotTests"
29 | }
30 | },
31 | {
32 | "parallelizable" : true,
33 | "target" : {
34 | "containerPath" : "container:",
35 | "identifier" : "CurrencyTextFieldTests",
36 | "name" : "CurrencyTextFieldTests"
37 | }
38 | },
39 | {
40 | "parallelizable" : true,
41 | "target" : {
42 | "containerPath" : "container:",
43 | "identifier" : "CurrencyUITextFieldDelegateTests",
44 | "name" : "CurrencyUITextFieldDelegateTests"
45 | }
46 | }
47 | ],
48 | "version" : 1
49 | }
50 |
--------------------------------------------------------------------------------
/CurrencyText.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = "CurrencyText"
3 | s.version = "2.2.0"
4 | s.summary = "Currency text formatter for UIKit and SwiftUI text fields."
5 | s.description = <<-DESC
6 | Provides a CurrencyText formatter (CurrencyFormatter sub-spec).
7 |
8 | It can be optionally used alongside `CurrencyUITextField` a custom
9 | UITextFieldDelegate to format UITextField inputs in UIKit.
10 | (CurrencyUITextField sub-spec).
11 |
12 | Or used in a `CurrencyTextField` for the same functionality in SwiftUI.
13 | (CurrencyTextField sub-spec).
14 | DESC
15 |
16 | s.homepage = "https://github.com/marinofelipe/CurrencyText"
17 | s.license = { :type => 'MIT', :file => 'LICENSE' }
18 | s.author = { "Felipe Lefèvre Marino" => "felipemarino91@gmail.com" }
19 | s.source = { :git => "https://github.com/marinofelipe/CurrencyText.git", :tag => "#{s.version}" }
20 | s.ios.deployment_target = '11.0'
21 |
22 | s.swift_version = "5.3"
23 | s.source_files = "Sources/**/*.swift"
24 | s.exclude_files = "Sources/CurrencyTextFieldTestSupport/*.swift"
25 |
26 | s.subspec 'CurrencyFormatter' do |ss|
27 | ss.requires_arc = true
28 | ss.source_files = "Sources/Formatter"
29 | end
30 |
31 | s.subspec 'CurrencyUITextField' do |ss|
32 | ss.requires_arc = true
33 | ss.source_files = "Sources/UITextFieldDelegate"
34 | ss.dependency 'CurrencyText/CurrencyFormatter'
35 | end
36 |
37 | s.subspec 'CurrencyTextField' do |ss|
38 | ss.requires_arc = true
39 | ss.source_files = "Sources/SwiftUI"
40 | ss.dependency 'CurrencyText/CurrencyFormatter'
41 | ss.dependency 'CurrencyText/CurrencyUITextField'
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/CurrencyText.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/CurrencyText.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/CurrencyText.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "swift-concurrency-extras",
6 | "repositoryURL": "https://github.com/pointfreeco/swift-concurrency-extras",
7 | "state": {
8 | "branch": null,
9 | "revision": "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8",
10 | "version": "1.3.1"
11 | }
12 | },
13 | {
14 | "package": "SnapshotTesting",
15 | "repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing",
16 | "state": {
17 | "branch": null,
18 | "revision": "f8a9c997c3c1dab4e216a8ec9014e23144cbab37",
19 | "version": "1.9.0"
20 | }
21 | }
22 | ]
23 | },
24 | "version": 1
25 | }
26 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 52;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | D54170DA209023D5008995D7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54170D9209023D5008995D7 /* AppDelegate.swift */; };
11 | D54170DC209023D5008995D7 /* UIKitExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54170DB209023D5008995D7 /* UIKitExampleViewController.swift */; };
12 | D54170E1209023D7008995D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D54170E0209023D7008995D7 /* Assets.xcassets */; };
13 | D54170E4209023D7008995D7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D54170E2209023D7008995D7 /* LaunchScreen.storyboard */; };
14 | ED1BCD0D25FE9933006F6DCD /* SwiftUIExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1BCD0C25FE9933006F6DCD /* SwiftUIExampleView.swift */; };
15 | ED4F57202623914A00E7877F /* CurrencyFormatter in Frameworks */ = {isa = PBXBuildFile; productRef = ED4F571F2623914A00E7877F /* CurrencyFormatter */; };
16 | ED4F57222623914A00E7877F /* CurrencyText in Frameworks */ = {isa = PBXBuildFile; productRef = ED4F57212623914A00E7877F /* CurrencyText */; };
17 | ED4F57242623914A00E7877F /* CurrencyTextSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = ED4F57232623914A00E7877F /* CurrencyTextSwiftUI */; };
18 | ED811552262C50D400F0E36E /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED811551262C50D400F0E36E /* View+Extensions.swift */; };
19 | EDF86EB6261B57F400FD9D82 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF86EB5261B57F400FD9D82 /* RootView.swift */; };
20 | EDF86EBB261B5DA300FD9D82 /* UIKitExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDF86EBA261B5DA300FD9D82 /* UIKitExampleView.swift */; };
21 | /* End PBXBuildFile section */
22 |
23 | /* Begin PBXFileReference section */
24 | D54170D9209023D5008995D7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
25 | D54170DB209023D5008995D7 /* UIKitExampleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitExampleViewController.swift; sourceTree = ""; };
26 | D54170E0209023D7008995D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
27 | D54170E3209023D7008995D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
28 | D54170E5209023D7008995D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
29 | ED1BCD0C25FE9933006F6DCD /* SwiftUIExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExampleView.swift; sourceTree = ""; };
30 | ED8115502629E44E00F0E36E /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
31 | ED811551262C50D400F0E36E /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; };
32 | EDF86EB5261B57F400FD9D82 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; };
33 | EDF86EBA261B5DA300FD9D82 /* UIKitExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitExampleView.swift; sourceTree = ""; };
34 | /* End PBXFileReference section */
35 |
36 | /* Begin PBXFrameworksBuildPhase section */
37 | D54170D3209023D5008995D7 /* Frameworks */ = {
38 | isa = PBXFrameworksBuildPhase;
39 | buildActionMask = 2147483647;
40 | files = (
41 | ED4F57222623914A00E7877F /* CurrencyText in Frameworks */,
42 | ED4F57202623914A00E7877F /* CurrencyFormatter in Frameworks */,
43 | ED4F57242623914A00E7877F /* CurrencyTextSwiftUI in Frameworks */,
44 | );
45 | runOnlyForDeploymentPostprocessing = 0;
46 | };
47 | /* End PBXFrameworksBuildPhase section */
48 |
49 | /* Begin PBXGroup section */
50 | D54170CD209023D5008995D7 = {
51 | isa = PBXGroup;
52 | children = (
53 | D54170D8209023D5008995D7 /* Example */,
54 | ED8115502629E44E00F0E36E /* Example.app */,
55 | );
56 | sourceTree = "";
57 | };
58 | D54170D8209023D5008995D7 /* Example */ = {
59 | isa = PBXGroup;
60 | children = (
61 | ED811555262C514B00F0E36E /* SwiftUI */,
62 | ED811554262C514600F0E36E /* UIKit */,
63 | D54170E5209023D7008995D7 /* Info.plist */,
64 | D54170D9209023D5008995D7 /* AppDelegate.swift */,
65 | EDF86EB5261B57F400FD9D82 /* RootView.swift */,
66 | D54170E0209023D7008995D7 /* Assets.xcassets */,
67 | D54170E2209023D7008995D7 /* LaunchScreen.storyboard */,
68 | );
69 | path = Example;
70 | sourceTree = "";
71 | };
72 | ED811554262C514600F0E36E /* UIKit */ = {
73 | isa = PBXGroup;
74 | children = (
75 | EDF86EBA261B5DA300FD9D82 /* UIKitExampleView.swift */,
76 | D54170DB209023D5008995D7 /* UIKitExampleViewController.swift */,
77 | );
78 | path = UIKit;
79 | sourceTree = "";
80 | };
81 | ED811555262C514B00F0E36E /* SwiftUI */ = {
82 | isa = PBXGroup;
83 | children = (
84 | ED1BCD0C25FE9933006F6DCD /* SwiftUIExampleView.swift */,
85 | ED811551262C50D400F0E36E /* View+Extensions.swift */,
86 | );
87 | path = SwiftUI;
88 | sourceTree = "";
89 | };
90 | /* End PBXGroup section */
91 |
92 | /* Begin PBXNativeTarget section */
93 | D54170D5209023D5008995D7 /* Example */ = {
94 | isa = PBXNativeTarget;
95 | buildConfigurationList = D54170FE209023D8008995D7 /* Build configuration list for PBXNativeTarget "Example" */;
96 | buildPhases = (
97 | D54170D2209023D5008995D7 /* Sources */,
98 | D54170D3209023D5008995D7 /* Frameworks */,
99 | D54170D4209023D5008995D7 /* Resources */,
100 | );
101 | buildRules = (
102 | );
103 | dependencies = (
104 | );
105 | name = Example;
106 | packageProductDependencies = (
107 | ED4F571F2623914A00E7877F /* CurrencyFormatter */,
108 | ED4F57212623914A00E7877F /* CurrencyText */,
109 | ED4F57232623914A00E7877F /* CurrencyTextSwiftUI */,
110 | );
111 | productName = UICurrencyTextFieldDemo;
112 | productReference = ED8115502629E44E00F0E36E /* Example.app */;
113 | productType = "com.apple.product-type.application";
114 | };
115 | /* End PBXNativeTarget section */
116 |
117 | /* Begin PBXProject section */
118 | D54170CE209023D5008995D7 /* Project object */ = {
119 | isa = PBXProject;
120 | attributes = {
121 | LastSwiftUpdateCheck = 0930;
122 | LastUpgradeCheck = 1230;
123 | ORGANIZATIONNAME = "Felipe Lefèvre Marino";
124 | TargetAttributes = {
125 | D54170D5209023D5008995D7 = {
126 | CreatedOnToolsVersion = 9.3;
127 | LastSwiftMigration = 1020;
128 | };
129 | };
130 | };
131 | buildConfigurationList = D54170D1209023D5008995D7 /* Build configuration list for PBXProject "Example" */;
132 | compatibilityVersion = "Xcode 10.0";
133 | developmentRegion = en;
134 | hasScannedForEncodings = 0;
135 | knownRegions = (
136 | en,
137 | Base,
138 | );
139 | mainGroup = D54170CD209023D5008995D7;
140 | productRefGroup = D54170CD209023D5008995D7;
141 | projectDirPath = "";
142 | projectRoot = "";
143 | targets = (
144 | D54170D5209023D5008995D7 /* Example */,
145 | );
146 | };
147 | /* End PBXProject section */
148 |
149 | /* Begin PBXResourcesBuildPhase section */
150 | D54170D4209023D5008995D7 /* Resources */ = {
151 | isa = PBXResourcesBuildPhase;
152 | buildActionMask = 2147483647;
153 | files = (
154 | D54170E4209023D7008995D7 /* LaunchScreen.storyboard in Resources */,
155 | D54170E1209023D7008995D7 /* Assets.xcassets in Resources */,
156 | );
157 | runOnlyForDeploymentPostprocessing = 0;
158 | };
159 | /* End PBXResourcesBuildPhase section */
160 |
161 | /* Begin PBXSourcesBuildPhase section */
162 | D54170D2209023D5008995D7 /* Sources */ = {
163 | isa = PBXSourcesBuildPhase;
164 | buildActionMask = 2147483647;
165 | files = (
166 | D54170DC209023D5008995D7 /* UIKitExampleViewController.swift in Sources */,
167 | EDF86EBB261B5DA300FD9D82 /* UIKitExampleView.swift in Sources */,
168 | ED1BCD0D25FE9933006F6DCD /* SwiftUIExampleView.swift in Sources */,
169 | D54170DA209023D5008995D7 /* AppDelegate.swift in Sources */,
170 | ED811552262C50D400F0E36E /* View+Extensions.swift in Sources */,
171 | EDF86EB6261B57F400FD9D82 /* RootView.swift in Sources */,
172 | );
173 | runOnlyForDeploymentPostprocessing = 0;
174 | };
175 | /* End PBXSourcesBuildPhase section */
176 |
177 | /* Begin PBXVariantGroup section */
178 | D54170E2209023D7008995D7 /* LaunchScreen.storyboard */ = {
179 | isa = PBXVariantGroup;
180 | children = (
181 | D54170E3209023D7008995D7 /* Base */,
182 | );
183 | name = LaunchScreen.storyboard;
184 | sourceTree = "";
185 | };
186 | /* End PBXVariantGroup section */
187 |
188 | /* Begin XCBuildConfiguration section */
189 | D54170FC209023D8008995D7 /* Debug */ = {
190 | isa = XCBuildConfiguration;
191 | buildSettings = {
192 | ALWAYS_SEARCH_USER_PATHS = NO;
193 | CLANG_ANALYZER_NONNULL = YES;
194 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
195 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
196 | CLANG_CXX_LIBRARY = "libc++";
197 | CLANG_ENABLE_MODULES = YES;
198 | CLANG_ENABLE_OBJC_ARC = YES;
199 | CLANG_ENABLE_OBJC_WEAK = YES;
200 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
201 | CLANG_WARN_BOOL_CONVERSION = YES;
202 | CLANG_WARN_COMMA = YES;
203 | CLANG_WARN_CONSTANT_CONVERSION = YES;
204 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
205 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
206 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
207 | CLANG_WARN_EMPTY_BODY = YES;
208 | CLANG_WARN_ENUM_CONVERSION = YES;
209 | CLANG_WARN_INFINITE_RECURSION = YES;
210 | CLANG_WARN_INT_CONVERSION = YES;
211 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
212 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
213 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
214 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
215 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
216 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
217 | CLANG_WARN_STRICT_PROTOTYPES = YES;
218 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
219 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
220 | CLANG_WARN_UNREACHABLE_CODE = YES;
221 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
222 | CODE_SIGN_IDENTITY = "iPhone Developer";
223 | COPY_PHASE_STRIP = NO;
224 | DEBUG_INFORMATION_FORMAT = dwarf;
225 | ENABLE_STRICT_OBJC_MSGSEND = YES;
226 | ENABLE_TESTABILITY = YES;
227 | GCC_C_LANGUAGE_STANDARD = gnu11;
228 | GCC_DYNAMIC_NO_PIC = NO;
229 | GCC_NO_COMMON_BLOCKS = YES;
230 | GCC_OPTIMIZATION_LEVEL = 0;
231 | GCC_PREPROCESSOR_DEFINITIONS = (
232 | "DEBUG=1",
233 | "$(inherited)",
234 | );
235 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
236 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
237 | GCC_WARN_UNDECLARED_SELECTOR = YES;
238 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
239 | GCC_WARN_UNUSED_FUNCTION = YES;
240 | GCC_WARN_UNUSED_VARIABLE = YES;
241 | IPHONEOS_DEPLOYMENT_TARGET = 9.0;
242 | MTL_ENABLE_DEBUG_INFO = YES;
243 | ONLY_ACTIVE_ARCH = YES;
244 | SDKROOT = iphoneos;
245 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
246 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
247 | SWIFT_VERSION = 4.2;
248 | };
249 | name = Debug;
250 | };
251 | D54170FD209023D8008995D7 /* Release */ = {
252 | isa = XCBuildConfiguration;
253 | buildSettings = {
254 | ALWAYS_SEARCH_USER_PATHS = NO;
255 | CLANG_ANALYZER_NONNULL = YES;
256 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
257 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
258 | CLANG_CXX_LIBRARY = "libc++";
259 | CLANG_ENABLE_MODULES = YES;
260 | CLANG_ENABLE_OBJC_ARC = YES;
261 | CLANG_ENABLE_OBJC_WEAK = YES;
262 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
263 | CLANG_WARN_BOOL_CONVERSION = YES;
264 | CLANG_WARN_COMMA = YES;
265 | CLANG_WARN_CONSTANT_CONVERSION = YES;
266 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
267 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
268 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
269 | CLANG_WARN_EMPTY_BODY = YES;
270 | CLANG_WARN_ENUM_CONVERSION = YES;
271 | CLANG_WARN_INFINITE_RECURSION = YES;
272 | CLANG_WARN_INT_CONVERSION = YES;
273 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
274 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
275 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
276 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
277 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
278 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
279 | CLANG_WARN_STRICT_PROTOTYPES = YES;
280 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
281 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
282 | CLANG_WARN_UNREACHABLE_CODE = YES;
283 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
284 | CODE_SIGN_IDENTITY = "iPhone Developer";
285 | COPY_PHASE_STRIP = NO;
286 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
287 | ENABLE_NS_ASSERTIONS = NO;
288 | ENABLE_STRICT_OBJC_MSGSEND = YES;
289 | GCC_C_LANGUAGE_STANDARD = gnu11;
290 | GCC_NO_COMMON_BLOCKS = YES;
291 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
292 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
293 | GCC_WARN_UNDECLARED_SELECTOR = YES;
294 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
295 | GCC_WARN_UNUSED_FUNCTION = YES;
296 | GCC_WARN_UNUSED_VARIABLE = YES;
297 | IPHONEOS_DEPLOYMENT_TARGET = 9.0;
298 | MTL_ENABLE_DEBUG_INFO = NO;
299 | SDKROOT = iphoneos;
300 | SWIFT_COMPILATION_MODE = wholemodule;
301 | SWIFT_OPTIMIZATION_LEVEL = "-O";
302 | SWIFT_VERSION = 4.2;
303 | VALIDATE_PRODUCT = YES;
304 | };
305 | name = Release;
306 | };
307 | D54170FF209023D8008995D7 /* Debug */ = {
308 | isa = XCBuildConfiguration;
309 | buildSettings = {
310 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
311 | CODE_SIGN_STYLE = Automatic;
312 | DEVELOPMENT_TEAM = X9MK4PBNUW;
313 | INFOPLIST_FILE = Example/Info.plist;
314 | IPHONEOS_DEPLOYMENT_TARGET = 14.0;
315 | LD_RUNPATH_SEARCH_PATHS = (
316 | "$(inherited)",
317 | "@executable_path/Frameworks",
318 | );
319 | PRODUCT_BUNDLE_IDENTIFIER = com.felipemarino.Example;
320 | PRODUCT_NAME = "$(TARGET_NAME)";
321 | SWIFT_VERSION = 5.0;
322 | TARGETED_DEVICE_FAMILY = "1,2";
323 | };
324 | name = Debug;
325 | };
326 | D5417100209023D8008995D7 /* Release */ = {
327 | isa = XCBuildConfiguration;
328 | buildSettings = {
329 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
330 | CODE_SIGN_STYLE = Automatic;
331 | DEVELOPMENT_TEAM = X9MK4PBNUW;
332 | INFOPLIST_FILE = Example/Info.plist;
333 | IPHONEOS_DEPLOYMENT_TARGET = 14.0;
334 | LD_RUNPATH_SEARCH_PATHS = (
335 | "$(inherited)",
336 | "@executable_path/Frameworks",
337 | );
338 | PRODUCT_BUNDLE_IDENTIFIER = com.felipemarino.Example;
339 | PRODUCT_NAME = "$(TARGET_NAME)";
340 | SWIFT_VERSION = 5.0;
341 | TARGETED_DEVICE_FAMILY = "1,2";
342 | };
343 | name = Release;
344 | };
345 | /* End XCBuildConfiguration section */
346 |
347 | /* Begin XCConfigurationList section */
348 | D54170D1209023D5008995D7 /* Build configuration list for PBXProject "Example" */ = {
349 | isa = XCConfigurationList;
350 | buildConfigurations = (
351 | D54170FC209023D8008995D7 /* Debug */,
352 | D54170FD209023D8008995D7 /* Release */,
353 | );
354 | defaultConfigurationIsVisible = 0;
355 | defaultConfigurationName = Release;
356 | };
357 | D54170FE209023D8008995D7 /* Build configuration list for PBXNativeTarget "Example" */ = {
358 | isa = XCConfigurationList;
359 | buildConfigurations = (
360 | D54170FF209023D8008995D7 /* Debug */,
361 | D5417100209023D8008995D7 /* Release */,
362 | );
363 | defaultConfigurationIsVisible = 0;
364 | defaultConfigurationName = Release;
365 | };
366 | /* End XCConfigurationList section */
367 |
368 | /* Begin XCSwiftPackageProductDependency section */
369 | ED4F571F2623914A00E7877F /* CurrencyFormatter */ = {
370 | isa = XCSwiftPackageProductDependency;
371 | productName = CurrencyFormatter;
372 | };
373 | ED4F57212623914A00E7877F /* CurrencyText */ = {
374 | isa = XCSwiftPackageProductDependency;
375 | productName = CurrencyText;
376 | };
377 | ED4F57232623914A00E7877F /* CurrencyTextSwiftUI */ = {
378 | isa = XCSwiftPackageProductDependency;
379 | productName = CurrencyTextSwiftUI;
380 | };
381 | /* End XCSwiftPackageProductDependency section */
382 | };
383 | rootObject = D54170CE209023D5008995D7 /* Project object */;
384 | }
385 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
51 |
52 |
53 |
54 |
56 |
62 |
63 |
64 |
65 |
66 |
76 |
78 |
84 |
85 |
86 |
87 |
93 |
95 |
101 |
102 |
103 |
104 |
106 |
107 |
110 |
111 |
112 |
--------------------------------------------------------------------------------
/Example/Example/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Example
4 | //
5 | // Created by Felipe Lefèvre Marino on 4/24/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import SwiftUI
11 |
12 | @UIApplicationMain
13 |
14 | final class AppDelegate: UIResponder, UIApplicationDelegate {
15 |
16 | var window: UIWindow?
17 |
18 | func application(
19 | _ application: UIApplication,
20 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
21 | ) -> Bool {
22 | window = UIWindow(frame: UIScreen.main.bounds)
23 | window?.rootViewController = UIHostingController(rootView: RootView())
24 | window?.makeKeyAndVisible()
25 |
26 | return true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "size" : "20x20",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "size" : "20x20",
51 | "scale" : "2x"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "size" : "29x29",
56 | "scale" : "1x"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "size" : "29x29",
61 | "scale" : "2x"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "size" : "40x40",
66 | "scale" : "1x"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "size" : "40x40",
71 | "scale" : "2x"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "size" : "76x76",
76 | "scale" : "1x"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "size" : "76x76",
81 | "scale" : "2x"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "size" : "83.5x83.5",
86 | "scale" : "2x"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "size" : "1024x1024",
91 | "scale" : "1x"
92 | }
93 | ],
94 | "info" : {
95 | "version" : 1,
96 | "author" : "xcode"
97 | }
98 | }
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/Example/Example/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/Example/Example/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UILaunchStoryboardName
24 | LaunchScreen
25 | UIRequiredDeviceCapabilities
26 |
27 | armv7
28 |
29 | UISupportedInterfaceOrientations
30 |
31 | UIInterfaceOrientationPortrait
32 | UIInterfaceOrientationLandscapeLeft
33 | UIInterfaceOrientationLandscapeRight
34 |
35 | UISupportedInterfaceOrientations~ipad
36 |
37 | UIInterfaceOrientationPortrait
38 | UIInterfaceOrientationPortraitUpsideDown
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/Example/Example/RootView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RootView.swift
3 | // Example
4 | //
5 | // Created by Marino Felipe on 05.04.21.
6 | // Copyright © 2021 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | enum CurrencyTextExample: Int, CaseIterable, Identifiable {
12 | case uiKit
13 | case swiftUI
14 |
15 | var id: Int { rawValue }
16 | }
17 |
18 | extension CurrencyTextExample {
19 | var title: String {
20 | switch self {
21 | case .uiKit:
22 | return "UIKit example"
23 | case .swiftUI:
24 | return "SwiftUI example"
25 | }
26 | }
27 |
28 | @ViewBuilder
29 | func makeBaseView() -> some View {
30 | switch self {
31 | case .uiKit:
32 | UIKitExampleView()
33 | .navigationTitle("UIKit")
34 | .navigationBarTitleDisplayMode(.inline)
35 | case .swiftUI:
36 | SwiftUIExampleView()
37 | }
38 | }
39 | }
40 |
41 | struct RootView: View {
42 | var body: some View {
43 | NavigationView {
44 | List(CurrencyTextExample.allCases) { example in
45 | NavigationLink(
46 | example.title,
47 | destination: example.makeBaseView()
48 | )
49 | }.navigationBarTitle("Try CurrencyText!")
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Example/Example/SwiftUI/SwiftUIExampleView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewController.swift
3 | // Example
4 | //
5 | // Created by Felipe Lefèvre Marino on 4/24/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | import CurrencyTextField
12 | import CurrencyFormatter
13 |
14 | /// - note: When using CocoaPods one should import the spec, with access to the full library or defined sub-specs
15 | /// import CurrencyText
16 |
17 | import Combine
18 | import UIKit
19 |
20 | struct CurrencyData {
21 | var text: String = ""
22 | var unformatted: String?
23 | var input: Double?
24 | var hasFocus: Bool?
25 | }
26 |
27 | final class CurrencyViewModel: ObservableObject {
28 | @Published var data = CurrencyData()
29 | }
30 |
31 | struct SwiftUIExampleView: View {
32 | @ObservedObject private var viewModel = CurrencyViewModel()
33 | @State private var currencyFormatter = CurrencyFormatter.default
34 | @State private var shouldClearTextField = false
35 | @State private var currency: Currency = .euro
36 |
37 | var body: some View {
38 | Form {
39 | Section {
40 | makeCurrencyTextField()
41 |
42 | Text("Formatted value: \(String(describing: $viewModel.data.text.wrappedValue))")
43 | Text("Unformatted value: \(String(describing: $viewModel.data.unformatted.wrappedValue))")
44 | Text("Input amount: \(String(describing: $viewModel.data.input.wrappedValue))")
45 |
46 | Picker(
47 | "Change currency",
48 | selection: $currency
49 | ) {
50 | ForEach(
51 | [
52 | Currency.euro,
53 | Currency.dollar,
54 | Currency.brazilianReal,
55 | Currency.yen
56 | ],
57 | id: \.self
58 | ) {
59 | Text($0.rawValue).tag($0)
60 | }
61 | }
62 | .pickerStyle(.segmented)
63 | .onChange(of: currency) { newValue in
64 | currencyFormatter = .init {
65 | $0.currency = newValue
66 | }
67 | }
68 |
69 | Button("Toggle clear text field on focus change") {
70 | shouldClearTextField.toggle()
71 | }
72 | .buttonStyle(.plain)
73 | .foregroundColor(.blue)
74 | }
75 | }
76 | .navigationTitle("SwiftUI")
77 | .navigationBarTitleDisplayMode(.inline)
78 | .onAppear {
79 | viewModel.data.hasFocus = true
80 | }
81 | }
82 |
83 | private func makeCurrencyTextField() -> some View {
84 | CurrencyTextField(
85 | configuration: .init(
86 | placeholder: "Play with me...",
87 | text: $viewModel.data.text,
88 | unformattedText: $viewModel.data.unformatted,
89 | inputAmount: $viewModel.data.input,
90 | hasFocus: $viewModel.data.hasFocus,
91 | clearsWhenValueIsZero: true,
92 | formatter: $currencyFormatter,
93 | textFieldConfiguration: { uiTextField in
94 | uiTextField.borderStyle = .roundedRect
95 | uiTextField.font = UIFont.preferredFont(forTextStyle: .body)
96 | uiTextField.textColor = .blue
97 | uiTextField.layer.borderColor = UIColor.red.cgColor
98 | uiTextField.layer.borderWidth = 1
99 | uiTextField.layer.cornerRadius = 4
100 | uiTextField.keyboardType = .numbersAndPunctuation
101 | uiTextField.layer.masksToBounds = true
102 | },
103 | onEditingChanged: { isEditing in
104 | if isEditing == false && shouldClearTextField {
105 | // How to programmatically clear the text of CurrencyTextField:
106 | // The Binding.text that is passed into CurrencyTextField.configuration can
107 | // manually cleared / updated with an empty String
108 | clearTextFieldText()
109 | }
110 | },
111 | onCommit: {
112 | print("onCommit")
113 | }
114 | )
115 | )
116 | .disabled(false)
117 | }
118 | }
119 |
120 | private extension SwiftUIExampleView {
121 | func clearTextFieldText() {
122 | viewModel.data.text = ""
123 | }
124 | }
125 |
126 | private extension CurrencyFormatter {
127 | static let `default`: CurrencyFormatter = {
128 | .init {
129 | $0.currency = .euro
130 | $0.locale = CurrencyLocale.germanGermany
131 | $0.hasDecimals = true
132 | $0.minValue = 5
133 | $0.maxValue = 100000000
134 | }
135 | }()
136 | }
137 |
--------------------------------------------------------------------------------
/Example/Example/SwiftUI/View+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+Extensions.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 18.04.21.
6 | //
7 |
8 | import SwiftUI
9 | import UIKit
10 |
11 | extension View {
12 | func endEditing() {
13 | UIApplication.shared.endEditing()
14 | }
15 | }
16 |
17 | extension UIApplication {
18 | func endEditing() {
19 | sendAction(
20 | #selector(Self.resignFirstResponder),
21 | to: nil,
22 | from: nil,
23 | for: nil
24 | )
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Example/Example/UIKit/UIKitExampleView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIKitExampleView.swift
3 | // Example
4 | //
5 | // Created by Marino Felipe on 05.04.21.
6 | // Copyright © 2021 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct UIKitExampleView: UIViewControllerRepresentable {
12 | func makeUIViewController(
13 | context: UIViewControllerRepresentableContext
14 | ) -> UIKitExampleViewController {
15 | UIKitExampleViewController()
16 | }
17 |
18 | func updateUIViewController(
19 | _ uiViewController: UIKitExampleViewController,
20 | context: UIViewControllerRepresentableContext
21 | ) { }
22 | }
23 |
--------------------------------------------------------------------------------
/Example/Example/UIKit/UIKitExampleViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewController.swift
3 | // Example
4 | //
5 | // Created by Felipe Lefèvre Marino on 4/24/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | import CurrencyUITextFieldDelegate
12 | import CurrencyFormatter
13 |
14 | /// - note: When using CocoaPods you can import the main spec, or each sub-spec as below:
15 | /// // main spec
16 | /// import CurrencyText
17 | ///
18 | /// // each sub-spec
19 | /// import CurrencyFormatter
20 | /// import CurrencyUITextField
21 |
22 | final class UIKitExampleViewController: UIViewController {
23 |
24 | private let outerStackView: UIStackView = {
25 | let stackView = UIStackView()
26 | stackView.translatesAutoresizingMaskIntoConstraints = false
27 | stackView.axis = .horizontal
28 | stackView.alignment = .center
29 |
30 | return stackView
31 | }()
32 |
33 | private let stackView: UIStackView = {
34 | let stackView = UIStackView()
35 | stackView.translatesAutoresizingMaskIntoConstraints = false
36 | stackView.axis = .vertical
37 | stackView.alignment = .center
38 | stackView.spacing = 16
39 | stackView.isLayoutMarginsRelativeArrangement = true
40 | stackView.layoutMargins = UIEdgeInsets(
41 | top: 16,
42 | left: 16,
43 | bottom: 16,
44 | right: 16
45 | )
46 |
47 | return stackView
48 | }()
49 |
50 | private let textField: UITextField = {
51 | let textField = UITextField()
52 | textField.translatesAutoresizingMaskIntoConstraints = false
53 | textField.borderStyle = .roundedRect
54 | textField.placeholder = "Play with me..."
55 |
56 | return textField
57 | }()
58 |
59 | private let formattedValueLabel: UILabel = {
60 | let label = UILabel()
61 | label.textAlignment = .center
62 | label.numberOfLines = 0
63 | label.translatesAutoresizingMaskIntoConstraints = false
64 |
65 | return label
66 | }()
67 |
68 | private let unformattedValueLabel: UILabel = {
69 | let label = UILabel()
70 | label.numberOfLines = 0
71 | label.textAlignment = .center
72 | label.translatesAutoresizingMaskIntoConstraints = false
73 |
74 | return label
75 | }()
76 |
77 | private var textFieldDelegate: CurrencyUITextFieldDelegate!
78 |
79 | override init(
80 | nibName nibNameOrNil: String?,
81 | bundle nibBundleOrNil: Bundle?
82 | ) {
83 | super.init(nibName: nil, bundle: nil)
84 |
85 | title = "UIKit"
86 | setUp()
87 | }
88 |
89 | @available(*, unavailable)
90 | required init?(coder: NSCoder) {
91 | fatalError("init(coder:) has not been implemented")
92 | }
93 |
94 | private func setUp() {
95 | view.backgroundColor = .white
96 |
97 | setUpViewHierarchy()
98 | setupTextFieldWithCurrencyDelegate()
99 | }
100 |
101 | private func setUpViewHierarchy() {
102 | [
103 | textField,
104 | formattedValueLabel,
105 | unformattedValueLabel
106 | ].forEach(stackView.addArrangedSubview)
107 |
108 | outerStackView.addArrangedSubview(stackView)
109 | view.addSubview(outerStackView)
110 |
111 | outerStackView.topAnchor
112 | .constraint(equalTo: view.layoutMarginsGuide.topAnchor)
113 | .isActive = true
114 | outerStackView.trailingAnchor
115 | .constraint(equalTo: view.layoutMarginsGuide.trailingAnchor)
116 | .isActive = true
117 | outerStackView.leadingAnchor
118 | .constraint(equalTo: view.layoutMarginsGuide.leadingAnchor)
119 | .isActive = true
120 | outerStackView.bottomAnchor
121 | .constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)
122 | .isActive = true
123 | }
124 |
125 | private func setupTextFieldWithCurrencyDelegate() {
126 | let currencyFormatter = CurrencyFormatter {
127 | $0.maxValue = 100000000
128 | $0.minValue = 5
129 | $0.currency = .euro
130 | $0.locale = CurrencyLocale.germanGermany
131 | $0.hasDecimals = true
132 | }
133 |
134 | textFieldDelegate = CurrencyUITextFieldDelegate(formatter: currencyFormatter)
135 | textFieldDelegate.clearsWhenValueIsZero = true
136 | textFieldDelegate.passthroughDelegate = self
137 |
138 | textField.delegate = textFieldDelegate
139 | textField.keyboardType = .numbersAndPunctuation
140 | }
141 |
142 | override func touchesBegan(_ touches: Set, with event: UIEvent?) {
143 | resignAnyFirstResponder()
144 | }
145 |
146 | @objc func resignAnyFirstResponder() {
147 | view.endEditing(false)
148 | }
149 | }
150 |
151 | extension UIKitExampleViewController: UITextFieldDelegate {
152 | func textFieldDidEndEditing(_ textField: UITextField) {
153 | let unformattedValue = textFieldDelegate
154 | .formatter
155 | .unformatted(
156 | string: textField.text ?? "0"
157 | ) ?? "0"
158 | formattedValueLabel.text = "Formatted value: \(textField.text ?? "0")"
159 | unformattedValueLabel.text = "Unformatted value: \(unformattedValue)"
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gem "fastlane", '~> 2.226.0'
4 | gem "xcode-install", '~> 2.8.0'
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.7)
5 | base64
6 | nkf
7 | rexml
8 | addressable (2.8.7)
9 | public_suffix (>= 2.0.2, < 7.0)
10 | artifactory (3.0.17)
11 | atomos (0.1.3)
12 | aws-eventstream (1.3.0)
13 | aws-partitions (1.1029.0)
14 | aws-sdk-core (3.214.1)
15 | aws-eventstream (~> 1, >= 1.3.0)
16 | aws-partitions (~> 1, >= 1.992.0)
17 | aws-sigv4 (~> 1.9)
18 | jmespath (~> 1, >= 1.6.1)
19 | aws-sdk-kms (1.96.0)
20 | aws-sdk-core (~> 3, >= 3.210.0)
21 | aws-sigv4 (~> 1.5)
22 | aws-sdk-s3 (1.176.1)
23 | aws-sdk-core (~> 3, >= 3.210.0)
24 | aws-sdk-kms (~> 1)
25 | aws-sigv4 (~> 1.5)
26 | aws-sigv4 (1.10.1)
27 | aws-eventstream (~> 1, >= 1.0.2)
28 | babosa (1.0.4)
29 | base64 (0.2.0)
30 | claide (1.0.3)
31 | colored (1.2)
32 | colored2 (3.1.2)
33 | commander (4.6.0)
34 | highline (~> 2.0.0)
35 | declarative (0.0.20)
36 | digest-crc (0.6.5)
37 | rake (>= 12.0.0, < 14.0.0)
38 | domain_name (0.6.20240107)
39 | dotenv (2.8.1)
40 | emoji_regex (3.2.3)
41 | excon (0.112.0)
42 | faraday (1.10.4)
43 | faraday-em_http (~> 1.0)
44 | faraday-em_synchrony (~> 1.0)
45 | faraday-excon (~> 1.1)
46 | faraday-httpclient (~> 1.0)
47 | faraday-multipart (~> 1.0)
48 | faraday-net_http (~> 1.0)
49 | faraday-net_http_persistent (~> 1.0)
50 | faraday-patron (~> 1.0)
51 | faraday-rack (~> 1.0)
52 | faraday-retry (~> 1.0)
53 | ruby2_keywords (>= 0.0.4)
54 | faraday-cookie_jar (0.0.7)
55 | faraday (>= 0.8.0)
56 | http-cookie (~> 1.0.0)
57 | faraday-em_http (1.0.0)
58 | faraday-em_synchrony (1.0.0)
59 | faraday-excon (1.1.0)
60 | faraday-httpclient (1.0.1)
61 | faraday-multipart (1.1.0)
62 | multipart-post (~> 2.0)
63 | faraday-net_http (1.0.2)
64 | faraday-net_http_persistent (1.2.0)
65 | faraday-patron (1.0.0)
66 | faraday-rack (1.0.0)
67 | faraday-retry (1.0.3)
68 | faraday_middleware (1.2.1)
69 | faraday (~> 1.0)
70 | fastimage (2.3.1)
71 | fastlane (2.226.0)
72 | CFPropertyList (>= 2.3, < 4.0.0)
73 | addressable (>= 2.8, < 3.0.0)
74 | artifactory (~> 3.0)
75 | aws-sdk-s3 (~> 1.0)
76 | babosa (>= 1.0.3, < 2.0.0)
77 | bundler (>= 1.12.0, < 3.0.0)
78 | colored (~> 1.2)
79 | commander (~> 4.6)
80 | dotenv (>= 2.1.1, < 3.0.0)
81 | emoji_regex (>= 0.1, < 4.0)
82 | excon (>= 0.71.0, < 1.0.0)
83 | faraday (~> 1.0)
84 | faraday-cookie_jar (~> 0.0.6)
85 | faraday_middleware (~> 1.0)
86 | fastimage (>= 2.1.0, < 3.0.0)
87 | fastlane-sirp (>= 1.0.0)
88 | gh_inspector (>= 1.1.2, < 2.0.0)
89 | google-apis-androidpublisher_v3 (~> 0.3)
90 | google-apis-playcustomapp_v1 (~> 0.1)
91 | google-cloud-env (>= 1.6.0, < 2.0.0)
92 | google-cloud-storage (~> 1.31)
93 | highline (~> 2.0)
94 | http-cookie (~> 1.0.5)
95 | json (< 3.0.0)
96 | jwt (>= 2.1.0, < 3)
97 | mini_magick (>= 4.9.4, < 5.0.0)
98 | multipart-post (>= 2.0.0, < 3.0.0)
99 | naturally (~> 2.2)
100 | optparse (>= 0.1.1, < 1.0.0)
101 | plist (>= 3.1.0, < 4.0.0)
102 | rubyzip (>= 2.0.0, < 3.0.0)
103 | security (= 0.1.5)
104 | simctl (~> 1.6.3)
105 | terminal-notifier (>= 2.0.0, < 3.0.0)
106 | terminal-table (~> 3)
107 | tty-screen (>= 0.6.3, < 1.0.0)
108 | tty-spinner (>= 0.8.0, < 1.0.0)
109 | word_wrap (~> 1.0.0)
110 | xcodeproj (>= 1.13.0, < 2.0.0)
111 | xcpretty (~> 0.4.0)
112 | xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
113 | fastlane-sirp (1.0.0)
114 | sysrandom (~> 1.0)
115 | gh_inspector (1.1.3)
116 | google-apis-androidpublisher_v3 (0.54.0)
117 | google-apis-core (>= 0.11.0, < 2.a)
118 | google-apis-core (0.11.3)
119 | addressable (~> 2.5, >= 2.5.1)
120 | googleauth (>= 0.16.2, < 2.a)
121 | httpclient (>= 2.8.1, < 3.a)
122 | mini_mime (~> 1.0)
123 | representable (~> 3.0)
124 | retriable (>= 2.0, < 4.a)
125 | rexml
126 | google-apis-iamcredentials_v1 (0.17.0)
127 | google-apis-core (>= 0.11.0, < 2.a)
128 | google-apis-playcustomapp_v1 (0.13.0)
129 | google-apis-core (>= 0.11.0, < 2.a)
130 | google-apis-storage_v1 (0.31.0)
131 | google-apis-core (>= 0.11.0, < 2.a)
132 | google-cloud-core (1.7.1)
133 | google-cloud-env (>= 1.0, < 3.a)
134 | google-cloud-errors (~> 1.0)
135 | google-cloud-env (1.6.0)
136 | faraday (>= 0.17.3, < 3.0)
137 | google-cloud-errors (1.4.0)
138 | google-cloud-storage (1.47.0)
139 | addressable (~> 2.8)
140 | digest-crc (~> 0.4)
141 | google-apis-iamcredentials_v1 (~> 0.1)
142 | google-apis-storage_v1 (~> 0.31.0)
143 | google-cloud-core (~> 1.6)
144 | googleauth (>= 0.16.2, < 2.a)
145 | mini_mime (~> 1.0)
146 | googleauth (1.8.1)
147 | faraday (>= 0.17.3, < 3.a)
148 | jwt (>= 1.4, < 3.0)
149 | multi_json (~> 1.11)
150 | os (>= 0.9, < 2.0)
151 | signet (>= 0.16, < 2.a)
152 | highline (2.0.3)
153 | http-cookie (1.0.8)
154 | domain_name (~> 0.5)
155 | httpclient (2.8.3)
156 | jmespath (1.6.2)
157 | json (2.9.1)
158 | jwt (2.10.1)
159 | base64
160 | mini_magick (4.13.2)
161 | mini_mime (1.1.5)
162 | multi_json (1.15.0)
163 | multipart-post (2.4.1)
164 | nanaimo (0.4.0)
165 | naturally (2.2.1)
166 | nkf (0.2.0)
167 | optparse (0.6.0)
168 | os (1.1.4)
169 | plist (3.7.2)
170 | public_suffix (6.0.1)
171 | rake (13.2.1)
172 | representable (3.2.0)
173 | declarative (< 0.1.0)
174 | trailblazer-option (>= 0.1.1, < 0.2.0)
175 | uber (< 0.2.0)
176 | retriable (3.1.2)
177 | rexml (3.4.0)
178 | rouge (3.28.0)
179 | ruby2_keywords (0.0.5)
180 | rubyzip (2.3.2)
181 | security (0.1.5)
182 | signet (0.19.0)
183 | addressable (~> 2.8)
184 | faraday (>= 0.17.5, < 3.a)
185 | jwt (>= 1.5, < 3.0)
186 | multi_json (~> 1.10)
187 | simctl (1.6.10)
188 | CFPropertyList
189 | naturally
190 | sysrandom (1.0.5)
191 | terminal-notifier (2.0.0)
192 | terminal-table (3.0.2)
193 | unicode-display_width (>= 1.1.1, < 3)
194 | trailblazer-option (0.1.2)
195 | tty-cursor (0.7.1)
196 | tty-screen (0.8.2)
197 | tty-spinner (0.9.3)
198 | tty-cursor (~> 0.7)
199 | uber (0.1.0)
200 | unicode-display_width (2.6.0)
201 | word_wrap (1.0.0)
202 | xcode-install (2.8.0)
203 | claide (>= 0.9.1, < 1.1.0)
204 | fastlane (>= 2.1.0, < 3.0.0)
205 | xcodeproj (1.27.0)
206 | CFPropertyList (>= 2.3.3, < 4.0)
207 | atomos (~> 0.1.3)
208 | claide (>= 1.0.2, < 2.0)
209 | colored2 (~> 3.1)
210 | nanaimo (~> 0.4.0)
211 | rexml (>= 3.3.6, < 4.0)
212 | xcpretty (0.4.0)
213 | rouge (~> 3.28.0)
214 | xcpretty-travis-formatter (1.0.1)
215 | xcpretty (~> 0.2, >= 0.0.7)
216 |
217 | PLATFORMS
218 | ruby
219 |
220 | DEPENDENCIES
221 | fastlane (~> 2.226.0)
222 | xcode-install (~> 2.8.0)
223 |
224 | BUNDLED WITH
225 | 2.3.9
226 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Felipe Lefèvre Marino
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.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "SnapshotTesting",
6 | "repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing",
7 | "state": {
8 | "branch": null,
9 | "revision": "f8a9c997c3c1dab4e216a8ec9014e23144cbab37",
10 | "version": "1.9.0"
11 | }
12 | }
13 | ]
14 | },
15 | "version": 1
16 | }
17 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "CurrencyText",
8 | platforms: [
9 | .iOS(.v11)
10 | ],
11 | products: [
12 | .library(
13 | name: "CurrencyTextSwiftUI",
14 | targets: [
15 | "CurrencyFormatter",
16 | "CurrencyTextField"
17 | ]
18 | ),
19 | .library(
20 | name: "CurrencyText",
21 | targets: [
22 | "CurrencyFormatter",
23 | "CurrencyUITextFieldDelegate"
24 | ]
25 | ),
26 | .library(
27 | name: "CurrencyFormatter",
28 | targets: [
29 | "CurrencyFormatter"
30 | ]
31 | )
32 | ],
33 | dependencies: [
34 | .package(
35 | name: "SnapshotTesting",
36 | url: "https://github.com/pointfreeco/swift-snapshot-testing",
37 | .upToNextMinor(
38 | from: .init(1, 9, 0)
39 | )
40 | ),
41 | .package(
42 | name: "ConcurrencyExtras",
43 | url: "https://github.com/pointfreeco/swift-concurrency-extras",
44 | .upToNextMinor(
45 | from: .init(1, 3, 1)
46 | )
47 | )
48 | ],
49 | targets: [
50 | /// Can be imported and used to have access to `CurrencyFormatter`.
51 | /// Useful to `format and represent currency values`.
52 | .target(
53 | name: "CurrencyFormatter",
54 | dependencies: [],
55 | path: "Sources/Formatter"
56 | ),
57 | .testTarget(
58 | name: "CurrencyFormatterTests",
59 | dependencies: [
60 | .target(name: "CurrencyFormatter")
61 | ],
62 | path: "Tests/Formatter"
63 | ),
64 |
65 | /// Can be imported and used to have access to `CurrencyUITextFieldDelegate`.
66 | /// Useful to `format text field inputs as currency`, based on a the settings of a CurrencyFormatter.
67 | .target(
68 | name: "CurrencyUITextFieldDelegate",
69 | dependencies: [
70 | .target(name: "CurrencyFormatter")
71 | ],
72 | path: "Sources/UITextFieldDelegate"
73 | ),
74 | .testTarget(
75 | name: "CurrencyUITextFieldDelegateTests",
76 | dependencies: [
77 | .target(name: "CurrencyUITextFieldDelegate")
78 | ],
79 | path: "Tests/UITextFieldDelegate"
80 | ),
81 |
82 | /// Can be imported and used to have access to `CurrencyTextField`, a `SwiftUI` text field that
83 | /// sanitizes user input based on a given `CurrencyFormatter`.
84 | .target(
85 | name: "CurrencyTextField",
86 | dependencies: [
87 | .target(name: "CurrencyFormatter"),
88 | .target(name: "CurrencyUITextFieldDelegate")
89 | ],
90 | path: "Sources/SwiftUI"
91 | ),
92 | .testTarget(
93 | name: "CurrencyTextFieldTests",
94 | dependencies: [
95 | .target(name: "CurrencyTextField"),
96 | .target(name: "CurrencyTextFieldTestSupport"),
97 | .product(name: "ConcurrencyExtras", package: "ConcurrencyExtras")
98 | ],
99 | path: "Tests/SwiftUI"
100 | ),
101 | .testTarget(
102 | name: "CurrencyTextFieldSnapshotTests",
103 | dependencies: [
104 | .target(name: "CurrencyTextField"),
105 | .target(name: "CurrencyTextFieldTestSupport"),
106 | .product(
107 | name: "SnapshotTesting",
108 | package: "SnapshotTesting"
109 | )
110 | ],
111 | path: "Tests/SwiftUISnapshotTests",
112 | resources: [
113 | .copy("__Snapshots__")
114 | ]
115 | ),
116 |
117 | /// Common `CurrencyTextField test helpers` that can be imported by all CurrencyText test targets.
118 | .target(
119 | name: "CurrencyTextFieldTestSupport",
120 | dependencies: [
121 | .target(name: "CurrencyTextField"),
122 | .target(name: "CurrencyFormatter")
123 | ],
124 | path: "Sources/CurrencyTextFieldTestSupport"
125 | )
126 | ]
127 | )
128 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | [](https://codecov.io/gh/marinofelipe/CurrencyText)
3 |
4 | []()
5 | [](https://swift.org/package-manager/)
6 | [](https://cocoapods.org/pods/CurrencyText)
7 | [](https://twitter.com/_marinofelipe)
8 |
9 |
10 |
11 |
12 |
13 | CurrencyText provides lightweight libraries for formating text field text as currency, available for both `UIKit` and `SwiftUI`.
14 |
15 | Its main core, the `CurrencyFormatter` class, can also be used _a part from text fields_ to format any value that can be monetary represented.
16 |
17 | If you need to present currency formatted text or allow users to input currency data, `CurrencyText` is going to help you do it in a readable and configurable matter.
18 |
19 | ## Documentation
20 |
21 | For details on how to use `CurrencyText` libraries please refer to [the docs](/documentation/Documentation.md).
22 |
23 | ## Installation
24 |
25 | ### Swift Package Manager
26 |
27 | To install it using Swift Package Manager, just add this repository through Xcode built-in `Swift Packages`, or by manually adding it to your `Package.swift` Package's dependencies:
28 |
29 | ```swift
30 | dependencies: [
31 | .package(
32 | url: "https://github.com/marinofelipe/CurrencyText.git",
33 | .upToNextMinor(from: .init(2, 1, 0)
34 | )
35 | ]
36 |
37 | .target(
38 | name: "MyTarget",
39 | dependencies: [
40 | // Can be imported to consume the formatter in insolation
41 | .target(name: "CurrencyFormatter"),
42 |
43 | // UIKit library - Provide access to "CurrencyFormatter" and "CurrencyUITextFieldDelegate" targets
44 | .target(name: "CurrencyText"),
45 |
46 | // SwiftUI library - Provide access to "CurrencyFormatter" and "CurrencyTextField" targets
47 | .target(name: "CurrencyTextSwiftUI")
48 | ],
49 | ...
50 | )
51 | ```
52 |
53 | ### Install via CocoaPods
54 |
55 | To integrate `CurrencyText` using CocoaPods, specify it, one or more of its sub-specs in your `Podfile`:
56 |
57 | ```ruby
58 | # Podfile
59 | use_frameworks!
60 |
61 | target 'YOUR_TARGET_NAME' do
62 | pod 'CurrencyText'
63 |
64 | # sub-specs
65 |
66 | # pod 'CurrencyText/CurrencyFormatter'
67 | # pod 'CurrencyText/CurrencyUITextField'
68 | # pod 'CurrencyText/CurrencyTextField'
69 | end
70 | ```
71 |
72 | ## Contributing
73 | Contributions and feedbacks are always welcome. Please feel free to fork, follow, open issues and pull requests. The issues, milestones, and what we are currently working on can be seen in the main [Project](https://github.com/marinofelipe/CurrencyText/projects/1).
74 |
75 | ## Special Thanks
76 | To [@malcommac](https://github.com/malcommac) for his awesome work with [SwiftRichString](https://github.com/malcommac/SwiftRichString) and [SwiftDate](https://github.com/malcommac/SwiftDate), that inspired me when creating this project.
77 | Also to [myanalysis](https://github.com/myanalysis) for contributing so much by finding issues and giving nice suggestions.
78 |
79 | ## Copyright
80 | CurrencyText is released under the MIT license. [See LICENSE](https://github.com/marinofelipe/CurrencyText/blob/master/LICENSE) for details.
81 |
82 | Felipe Marino: [felipemarino91@gmail.com](mailto:felipemarino91@gmail.com), [@_marinofelipe](https://twitter.com/_marinofelipe)
83 |
--------------------------------------------------------------------------------
/Sources/CurrencyTextFieldTestSupport/Fixtures/Fixtures+CurrencyFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Fixtures+CurrencyFormatter.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 25.04.21.
6 | //
7 |
8 | #if canImport(CurrencyFormatter)
9 | import CurrencyFormatter
10 | #endif
11 |
12 | public extension CurrencyFormatter {
13 | enum TestCase: String, CaseIterable {
14 | case noDecimals
15 | case withDecimals
16 | case withMinMaxValues
17 | case yenJapanese
18 | case germanEuro
19 |
20 | public var formatter: CurrencyFormatter {
21 | switch self {
22 | case .noDecimals:
23 | return .init {
24 | $0.currency = .dollar
25 | $0.locale = CurrencyLocale.englishUnitedStates
26 | $0.hasDecimals = false
27 | }
28 | case .withDecimals:
29 | return .init {
30 | $0.currency = .dollar
31 | $0.locale = CurrencyLocale.englishUnitedStates
32 | $0.hasDecimals = true
33 | }
34 | case .withMinMaxValues:
35 | return .init {
36 | $0.maxValue = 300
37 | $0.minValue = 10
38 | $0.currency = .dollar
39 | $0.locale = CurrencyLocale.englishUnitedStates
40 | $0.hasDecimals = true
41 | }
42 | case .yenJapanese:
43 | return .init {
44 | $0.currency = .yen
45 | $0.locale = CurrencyLocale.japaneseJapan
46 | $0.hasDecimals = true
47 | }
48 | case .germanEuro:
49 | return .init {
50 | $0.currency = .euro
51 | $0.locale = CurrencyLocale.germanGermany
52 | $0.hasDecimals = true
53 | }
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/CurrencyTextFieldTestSupport/Fixtures/Fixtures+CurrencyTextFieldConfiguration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Fixtures+CurrencyTextFieldConfiguration.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 25.04.21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | #if canImport(CurrencyFormatter)
11 | import CurrencyFormatter
12 | #endif
13 |
14 | #if canImport(CurrencyTextField)
15 | import CurrencyTextField
16 | #endif
17 |
18 | @available(iOS 13.0, *)
19 | public extension CurrencyTextFieldConfiguration {
20 | static func makeFixture(
21 | placeholder: String = "some",
22 | textBinding: Binding = .init(
23 | get: { "text" },
24 | set: { _ in }
25 | ),
26 | unformattedTextBinding: Binding = .init(
27 | get: { "unformatted" },
28 | set: { _ in }
29 | ),
30 | inputAmountBinding: Binding = .init(
31 | get: { .zero },
32 | set: { _ in }
33 | ),
34 | hasFocusBinding: Binding = .init(
35 | get: { true },
36 | set: { _ in }
37 | ),
38 | clearsWhenValueIsZero: Bool = true,
39 | formatter: Binding = .init(
40 | get: { .init() },
41 | set: { _ in }
42 | ),
43 | textFieldConfiguration: ((UITextField) -> Void)? = { _ in },
44 | onEditingChanged: ((Bool) -> Void)? = { _ in },
45 | onCommit: (() -> Void)? = { }
46 | ) -> Self {
47 | .init(
48 | placeholder: placeholder,
49 | text: textBinding,
50 | unformattedText: unformattedTextBinding,
51 | inputAmount: inputAmountBinding,
52 | hasFocus: hasFocusBinding,
53 | clearsWhenValueIsZero: clearsWhenValueIsZero,
54 | formatter: formatter,
55 | textFieldConfiguration: textFieldConfiguration,
56 | onEditingChanged: onEditingChanged,
57 | onCommit: onCommit
58 | )
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Sources/Formatter/Currency.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyCode.swift
3 | // CurrencyText
4 | //
5 | // Created by Felipe Lefèvre Marino on 1/26/19.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Currency wraps all availabe currencies that can represented as formatted monetary values
11 | /// A currency code is a three-letter code that is, in most cases,
12 | /// composed of a country’s two-character Internet country code plus an extra character
13 | /// to denote the currency unit. For example, the currency code for the Australian
14 | /// dollar is “AUD”. Currency codes are based on the ISO 4217 standard
15 | public enum Currency: String, CaseIterable {
16 | case afghani = "AFN",
17 | algerianDinar = "DZD",
18 | argentinePeso = "ARS",
19 | armenianDram = "AMD",
20 | arubanFlorin = "AWG",
21 | australianDollar = "AUD",
22 | azerbaijanManat = "AZN",
23 | bahamianDollar = "BSD",
24 | bahrainiDinar = "BHD",
25 | baht = "THB",
26 | balboa = "PAB",
27 | barbadosDollar = "BBD",
28 | belarusianRuble = "BYN",
29 | belizeDollar = "BZD",
30 | bermudianDollar = "BMD",
31 | boliviano = "BOB",
32 | bolívar = "VEF",
33 | brazilianReal = "BRL",
34 | bruneiDollar = "BND",
35 | bulgarianLev = "BGN",
36 | burundiFranc = "BIF",
37 | caboVerdeEscudo = "CVE",
38 | canadianDollar = "CAD",
39 | caymanIslandsDollar = "KYD",
40 | chileanPeso = "CLP",
41 | colombianPeso = "COP",
42 | comorianFranc = "KMF",
43 | congoleseFranc = "CDF",
44 | convertibleMark = "BAM",
45 | cordobaOro = "NIO",
46 | costaRicanColon = "CRC",
47 | cubanPeso = "CUP",
48 | czechKoruna = "CZK",
49 | dalasi = "GMD",
50 | danishKrone = "DKK",
51 | denar = "MKD",
52 | djiboutiFranc = "DJF",
53 | dobra = "STN",
54 | dollar = "USD",
55 | dominicanPeso = "DOP",
56 | dong = "VND",
57 | eastCaribbeanDollar = "XCD",
58 | egyptianPound = "EGP",
59 | elSalvadorColon = "SVC",
60 | ethiopianBirr = "ETB",
61 | euro = "EUR",
62 | falklandIslandsPound = "FKP",
63 | fijiDollar = "FJD",
64 | forint = "HUF",
65 | ghanaCedi = "GHS",
66 | gibraltarPound = "GIP",
67 | gourde = "HTG",
68 | guarani = "PYG",
69 | guineanFranc = "GNF",
70 | guyanaDollar = "GYD",
71 | hongKongDollar = "HKD",
72 | hryvnia = "UAH",
73 | icelandKrona = "ISK",
74 | indianRupee = "INR",
75 | iranianRial = "IRR",
76 | iraqiDinar = "IQD",
77 | jamaicanDollar = "JMD",
78 | jordanianDinar = "JOD",
79 | kenyanShilling = "KES",
80 | kina = "PGK",
81 | kuna = "HRK",
82 | kuwaitiDinar = "KWD",
83 | kwanza = "AOA",
84 | kyat = "MMK",
85 | laoKip = "LAK",
86 | lari = "GEL",
87 | lebanesePound = "LBP",
88 | lek = "ALL",
89 | lempira = "HNL",
90 | leone = "SLL",
91 | liberianDollar = "LRD",
92 | libyanDinar = "LYD",
93 | lilangeni = "SZL",
94 | loti = "LSL",
95 | malagasyAriary = "MGA",
96 | malawiKwacha = "MWK",
97 | malaysianRinggit = "MYR",
98 | mauritiusRupee = "MUR",
99 | mexicanPeso = "MXN",
100 | mexicanUnidadDeInversion = "MXV",
101 | moldovanLeu = "MDL",
102 | moroccanDirham = "MAD",
103 | mozambiqueMetical = "MZN",
104 | mvdol = "BOV",
105 | naira = "NGN",
106 | nakfa = "ERN",
107 | namibiaDollar = "NAD",
108 | nepaleseRupee = "NPR",
109 | netherlandsAntilleanGuilder = "ANG",
110 | newIsraeliSheqel = "ILS",
111 | newTaiwanDollar = "TWD",
112 | newZealandDollar = "NZD",
113 | ngultrum = "BTN",
114 | northKoreanWon = "KPW",
115 | norwegianKrone = "NOK",
116 | ouguiya = "MRU",
117 | paanga = "TOP",
118 | pakistanRupee = "PKR",
119 | pataca = "MOP",
120 | pesoConvertible = "CUC",
121 | pesoUruguayo = "UYU",
122 | philippinePiso = "PHP",
123 | poundSterling = "GBP",
124 | pula = "BWP",
125 | qatariRial = "QAR",
126 | quetzal = "GTQ",
127 | rand = "ZAR",
128 | rialOmani = "OMR",
129 | riel = "KHR",
130 | romanianLeu = "RON",
131 | rufiyaa = "MVR",
132 | rupiah = "IDR",
133 | russianRuble = "RUB",
134 | rwandaFranc = "RWF",
135 | saintHelenaPound = "SHP",
136 | saudiRiyal = "SAR",
137 | serbianDinar = "RSD",
138 | seychellesRupee = "SCR",
139 | singaporeDollar = "SGD",
140 | sol = "PEN",
141 | solomonIslandsDollar = "SBD",
142 | som = "KGS",
143 | somaliShilling = "SOS",
144 | somoni = "TJS",
145 | southSudanesePound = "SSP",
146 | sriLankaRupee = "LKR",
147 | sudanesePound = "SDG",
148 | surinamDollar = "SRD",
149 | swedishKrona = "SEK",
150 | swissFranc = "CHF",
151 | syrianPound = "SYP",
152 | taka = "BDT",
153 | tala = "WST",
154 | tanzanianShilling = "TZS",
155 | tenge = "KZT",
156 | trinidadAndTobagoDollar = "TTD",
157 | tugrik = "MNT",
158 | tunisianDinar = "TND",
159 | turkishLira = "TRY",
160 | turkmenistanNewManat = "TMT",
161 | uaeDirham = "AED",
162 | ugandaShilling = "UGX",
163 | unidadDeFomento = "CLF",
164 | unidadDeValorReal = "COU",
165 | uruguayPesoEnUnidadesIndexadas = "UYI",
166 | uzbekistanSum = "UZS",
167 | vatu = "VUV",
168 | wirEuro = "CHE",
169 | wirFranc = "CHW",
170 | won = "KRW",
171 | yemeniRial = "YER",
172 | yen = "JPY",
173 | yuanRenminbi = "CNY",
174 | zambianKwacha = "ZMW",
175 | zimbabweDollar = "ZWL",
176 | zloty = "PLN",
177 | none
178 | }
179 |
--------------------------------------------------------------------------------
/Sources/Formatter/CurrencyFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyFormatter.swift
3 | // CurrencyText
4 | //
5 | // Created by Felipe Lefèvre Marino on 1/27/19.
6 | //
7 |
8 | import Foundation
9 |
10 | // MARK: - Currency protocols
11 |
12 | public protocol CurrencyFormatting {
13 | var maxDigitsCount: Int { get }
14 | var decimalDigits: Int { get set }
15 | var maxValue: Double? { get set }
16 | var minValue: Double? { get set }
17 | var initialText: String { get }
18 | var currencySymbol: String { get set }
19 |
20 | func string(from double: Double) -> String?
21 | func unformatted(string: String) -> String?
22 | func double(from string: String) -> Double?
23 | }
24 |
25 | public protocol CurrencyAdjusting {
26 | func formattedStringWithAdjustedDecimalSeparator(from string: String) -> String?
27 | func formattedStringAdjustedToFitAllowedValues(from string: String) -> String?
28 | }
29 |
30 | // MARK: - Currency formatter
31 |
32 | public class CurrencyFormatter: CurrencyFormatting {
33 |
34 | /// Set the locale to retrieve the currency from
35 | /// You can pass a Swift type Locale or one of the
36 | /// Locales enum options - that encapsulates all available locales.
37 | public var locale: LocaleConvertible {
38 | set { self.numberFormatter.locale = newValue.locale }
39 | get { self.numberFormatter.locale }
40 | }
41 |
42 | /// Set the desired currency type
43 | /// * Note: The currency take effetcs above the displayed currency symbol,
44 | /// however details such as decimal separators, grouping separators and others
45 | /// will be set based on the defined locale. So for a precise experience, please
46 | /// preferarbly setup both, when you are setting a currency that does not match the
47 | /// default/current user locale.
48 | public var currency: Currency {
49 | set { numberFormatter.currencyCode = newValue.rawValue }
50 | get { Currency(rawValue: numberFormatter.currencyCode) ?? .dollar }
51 | }
52 |
53 | /// Define if currency symbol should be presented or not.
54 | /// Note: when set to false the current currency symbol is removed
55 | public var showCurrencySymbol: Bool = true {
56 | didSet {
57 | numberFormatter.currencySymbol = showCurrencySymbol ? numberFormatter.currencySymbol : ""
58 | }
59 | }
60 |
61 | /// The currency's symbol.
62 | /// Can be used to read or set a custom symbol.
63 | /// Note: showCurrencySymbol must be set to true for
64 | /// the currencySymbol to be correctly changed.
65 | public var currencySymbol: String {
66 | set {
67 | guard showCurrencySymbol else { return }
68 | numberFormatter.currencySymbol = newValue
69 | }
70 | get { numberFormatter.currencySymbol }
71 | }
72 |
73 | /// The lowest number allowed as input.
74 | /// This value is initially set to the text field text
75 | /// when defined.
76 | public var minValue: Double? {
77 | set {
78 | guard let newValue = newValue else { return }
79 | numberFormatter.minimum = NSNumber(value: newValue)
80 | }
81 | get {
82 | if let minValue = numberFormatter.minimum {
83 | return Double(truncating: minValue)
84 | }
85 | return nil
86 | }
87 | }
88 |
89 | /// The highest number allowed as input.
90 | /// The text field will not allow the user to increase the input
91 | /// value beyond it, when defined.
92 | public var maxValue: Double? {
93 | set {
94 | guard let newValue = newValue else { return }
95 | numberFormatter.maximum = NSNumber(value: newValue)
96 | }
97 | get {
98 | if let maxValue = numberFormatter.maximum {
99 | return Double(truncating: maxValue)
100 | }
101 | return nil
102 | }
103 | }
104 |
105 | /// The number of decimal digits shown.
106 | /// default is set to zero.
107 | /// * Example: With decimal digits set to 3, if the value to represent is "1",
108 | /// the formatted text in the fractions will be ",001".
109 | /// Other than that with the value as 1, the formatted text fractions will be ",1".
110 | public var decimalDigits: Int {
111 | set {
112 | numberFormatter.minimumFractionDigits = newValue
113 | numberFormatter.maximumFractionDigits = newValue
114 | }
115 | get { numberFormatter.minimumFractionDigits }
116 | }
117 |
118 | /// Set decimal numbers behavior.
119 | /// When set to true decimalDigits are automatically set to 2 (most currencies pattern),
120 | /// and the decimal separator is presented. Otherwise decimal digits are not shown and
121 | /// the separator gets hidden as well
122 | /// When reading it returns the current pattern based on the setup.
123 | /// Note: Setting decimal digits after, or alwaysShowsDecimalSeparator can overlap this definitios,
124 | /// and should be only done if you need specific cases
125 | public var hasDecimals: Bool {
126 | set {
127 | self.decimalDigits = newValue ? 2 : 0
128 | self.numberFormatter.alwaysShowsDecimalSeparator = newValue ? true : false
129 | }
130 | get { decimalDigits != 0 }
131 | }
132 |
133 | /// Defines the string that is the decimal separator
134 | /// Note: only presented when hasDecimals is true OR decimalDigits
135 | /// is greater than 0.
136 | public var decimalSeparator: String {
137 | set { self.numberFormatter.currencyDecimalSeparator = newValue }
138 | get { numberFormatter.currencyDecimalSeparator }
139 | }
140 |
141 | /// Can be used to set a custom currency code string
142 | public var currencyCode: String {
143 | set { self.numberFormatter.currencyCode = newValue }
144 | get { numberFormatter.currencyCode }
145 | }
146 |
147 | /// Sets if decimal separator should always be presented,
148 | /// even when decimal digits are disabled
149 | public var alwaysShowsDecimalSeparator: Bool {
150 | set { self.numberFormatter.alwaysShowsDecimalSeparator = newValue }
151 | get { numberFormatter.alwaysShowsDecimalSeparator }
152 | }
153 |
154 | /// The amount of grouped numbers. This definition is fixed for at least
155 | /// the first non-decimal group of numbers, and is applied to all other
156 | /// groups if secondaryGroupingSize does not have another value.
157 | public var groupingSize: Int {
158 | set { self.numberFormatter.groupingSize = newValue }
159 | get { numberFormatter.groupingSize }
160 | }
161 |
162 | /// The amount of grouped numbers after the first group.
163 | /// Example: for the given value of 99999999999, when grouping size
164 | /// is set to 3 and secondaryGroupingSize has 4 as value,
165 | /// the number is represented as: (9999) (9999) [999].
166 | /// Beign [] grouping size and () secondary grouping size.
167 | public var secondaryGroupingSize: Int {
168 | set { self.numberFormatter.secondaryGroupingSize = newValue }
169 | get { numberFormatter.secondaryGroupingSize }
170 | }
171 |
172 | /// Defines the string that is shown between groups of numbers
173 | /// * Example: a monetary value of a thousand (1000) with a grouping
174 | /// separator == "." is represented as `1.000` *.
175 | /// Note: It automatically sets hasGroupingSeparator to true.
176 | public var groupingSeparator: String {
177 | set {
178 | self.numberFormatter.currencyGroupingSeparator = newValue
179 | self.numberFormatter.usesGroupingSeparator = true
180 | }
181 | get { self.numberFormatter.currencyGroupingSeparator }
182 | }
183 |
184 | /// Sets if has separator between all group of numbers.
185 | /// * Example: when set to false, a bug number such as a million
186 | /// is represented by tight numbers "1000000". Otherwise if set
187 | /// to true each group is separated by the defined `groupingSeparator`. *
188 | /// Note: When set to true only works by defining a grouping separator.
189 | public var hasGroupingSeparator: Bool {
190 | set { self.numberFormatter.usesGroupingSeparator = newValue }
191 | get { self.numberFormatter.usesGroupingSeparator }
192 | }
193 |
194 | /// Value that will be presented when the text field
195 | /// text values matches zero (0)
196 | public var zeroSymbol: String? {
197 | set { numberFormatter.zeroSymbol = newValue }
198 | get { numberFormatter.zeroSymbol }
199 | }
200 |
201 | /// Value that will be presented when the text field
202 | /// is empty. The default is "" - empty string
203 | public var nilSymbol: String {
204 | set { numberFormatter.nilSymbol = newValue }
205 | get { return numberFormatter.nilSymbol }
206 | }
207 |
208 | /// Encapsulated Number formatter
209 | let numberFormatter: NumberFormatter
210 |
211 | /// Maximum allowed number of integers
212 | public var maxIntegers: Int? {
213 | set {
214 | guard let maxIntegers = newValue else { return }
215 | numberFormatter.maximumIntegerDigits = maxIntegers
216 | }
217 | get { return numberFormatter.maximumIntegerDigits }
218 | }
219 |
220 | /// Returns the maximum allowed number of numerical characters
221 | public var maxDigitsCount: Int {
222 | numberFormatter.maximumIntegerDigits + numberFormatter.maximumFractionDigits
223 | }
224 |
225 | /// The value zero formatted to serve as initial text.
226 | public var initialText: String {
227 | numberFormatter.string(from: 0) ?? "0.0"
228 | }
229 |
230 | //MARK: - INIT
231 |
232 | /// Handler to initialize a new style.
233 | public typealias InitHandler = ((CurrencyFormatter) -> (Void))
234 |
235 | /// Initialize a new currency formatter with optional configuration handler callback.
236 | ///
237 | /// - Parameter handler: configuration handler callback.
238 | public init(_ handler: InitHandler? = nil) {
239 | numberFormatter = NumberFormatter()
240 | numberFormatter.alwaysShowsDecimalSeparator = false
241 | numberFormatter.numberStyle = .currency
242 |
243 | numberFormatter.minimumFractionDigits = 2
244 | numberFormatter.maximumFractionDigits = 2
245 | numberFormatter.minimumIntegerDigits = 1
246 |
247 | handler?(self)
248 | }
249 | }
250 |
251 | // MARK: Format
252 | extension CurrencyFormatter {
253 |
254 | /// Returns a currency string from a given double value.
255 | ///
256 | /// - Parameter double: the monetary amount.
257 | /// - Returns: formatted currency string.
258 | public func string(from double: Double) -> String? {
259 | let validValue = valueAdjustedToFitAllowedValues(from: double)
260 | return numberFormatter.string(from: validValue)
261 | }
262 |
263 | /// Returns a double from a string that represents a numerical value.
264 | ///
265 | /// - Parameter string: string that describes the numerical value.
266 | /// - Returns: the value as a Double.
267 | public func double(from string: String) -> Double? {
268 | Double(string)
269 | }
270 |
271 | /// Receives a currency formatted string and returns its
272 | /// numerical/unformatted representation.
273 | ///
274 | /// - Parameter string: currency formatted string
275 | /// - Returns: numerical representation
276 | public func unformatted(string: String) -> String? {
277 | if hasDecimals {
278 | return string.trimmingCharacters(
279 | in: CharacterSet(
280 | charactersIn: "0123456789\(decimalSeparator)"
281 | )
282 | .inverted
283 | )
284 | .replacingOccurrences(of: groupingSeparator, with: "")
285 | .replacingOccurrences(of: decimalSeparator, with: ".")
286 | } else {
287 | return string.numeralFormat()
288 | }
289 | }
290 | }
291 |
292 | // MARK: - Currency adjusting conformance
293 |
294 | extension CurrencyFormatter: CurrencyAdjusting {
295 |
296 | /// Receives a currency formatted String, and returns it with its decimal separator adjusted.
297 | ///
298 | /// - note: Useful for when appending values to a currency formatted String.
299 | /// E.g. "$ 23.24" after users taps an additional number, is equal = "$ 23.247".
300 | /// Which gets updated to "$ 232.47".
301 | /// - warning: Not only decimal, but also grouping separators are adjusted/updated, based on the
302 | /// formatter's setup and the string being formatted.
303 | ///
304 | /// - Parameter string: The currency formatted String
305 | /// - Returns: The currency formatted received String with separators adjusted
306 | public func formattedStringWithAdjustedDecimalSeparator(from string: String) -> String? {
307 | let adjustedString = numeralStringWithAdjustedDecimalSeparator(from: string)
308 | guard let value = double(from: adjustedString) else { return nil }
309 |
310 | return self.numberFormatter.string(from: value)
311 | }
312 |
313 | /// Receives a currency formatted String, and returns it to fit the formatter's min and max values, when needed.
314 | ///
315 | /// - Parameter string: The currency formatted String
316 | /// - Returns: The currency formatted String, or the formatted version of its closes allowed value, min or max, depending on the closest boundary.
317 | public func formattedStringAdjustedToFitAllowedValues(from string: String) -> String? {
318 | let adjustedString = numeralStringWithAdjustedDecimalSeparator(from: string)
319 | guard let originalValue = double(from: adjustedString) else { return nil }
320 |
321 | return self.string(from: originalValue)
322 | }
323 |
324 | /// Receives a currency formatted String, and returns a numeral version of it with its decimal separator adjusted.
325 | ///
326 | /// E.g. "$ 23.24", after users taps an additional number, get equal as "$ 23.247". The returned value would be "232.47".
327 | ///
328 | /// - Parameter string: The currency formatted String
329 | /// - Returns: The received String with numeral format and with its decimal separator adjusted
330 | private func numeralStringWithAdjustedDecimalSeparator(from string: String) -> String {
331 | var updatedString = string.numeralFormat()
332 | let isNegative: Bool = string.contains(String.negativeSymbol)
333 |
334 | updatedString = isNegative ? .negativeSymbol + updatedString : updatedString
335 | updatedString.updateDecimalSeparator(decimalDigits: decimalDigits)
336 |
337 | return updatedString
338 | }
339 |
340 | /// Receives a Double value, and returns it adjusted to fit min and max allowed values, when needed.
341 | /// If the value respect number formatter's min and max, it will be returned without changes.
342 | ///
343 | /// - Parameter value: The value to be adjusted if needed
344 | /// - Returns: The value updated or not, depending on the formatter's settings
345 | private func valueAdjustedToFitAllowedValues(from value: Double) -> Double {
346 | if let minValue = minValue, value < minValue {
347 | return minValue
348 | } else if let maxValue = maxValue, value > maxValue {
349 | return maxValue
350 | }
351 |
352 | return value
353 | }
354 | }
355 |
--------------------------------------------------------------------------------
/Sources/Formatter/NumberFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NumberFormatter.swift
3 | // CurrencyText
4 | //
5 | // Created by Felipe Lefèvre Marino on 12/27/18.
6 | //
7 |
8 | import Foundation
9 |
10 | public extension NumberFormatter {
11 |
12 | func string(from doubleValue: Double?) -> String? {
13 | if let doubleValue = doubleValue {
14 | return string(from: NSNumber(value: doubleValue))
15 | }
16 | return nil
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/Formatter/String.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String.swift
3 | // CurrencyText
4 | //
5 | // Created by Felipe Lefèvre Marino on 4/3/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public protocol CurrencyString {
12 | var representsZero: Bool { get }
13 | var hasNumbers: Bool { get }
14 | var lastNumberOffsetFromEnd: Int? { get }
15 | func numeralFormat() -> String
16 | mutating func updateDecimalSeparator(decimalDigits: Int)
17 | }
18 |
19 | //Currency String Extension
20 | extension String: CurrencyString {
21 |
22 | // MARK: Properties
23 |
24 | /// Informs with the string represents the value of zero
25 | public var representsZero: Bool {
26 | return numeralFormat().replacingOccurrences(of: "0", with: "").count == 0
27 | }
28 |
29 | /// Returns if the string does have any character that represents numbers
30 | public var hasNumbers: Bool {
31 | return numeralFormat().count > 0
32 | }
33 |
34 | /// The offset from end index to the index _right after_ the last number in the String.
35 | /// e.g. For the String "123some", the last number position is 4, because from the _end index_ to the index of _3_
36 | /// there is an offset of 4, "e, m, o and s".
37 | public var lastNumberOffsetFromEnd: Int? {
38 | guard let indexOfLastNumber = lastIndex(where: { $0.isNumber }) else { return nil }
39 | let indexAfterLastNumber = index(after: indexOfLastNumber)
40 | return distance(from: endIndex, to: indexAfterLastNumber)
41 | }
42 |
43 | // MARK: Functions
44 |
45 | /// Updates a currency string decimal separator position based on
46 | /// the amount of decimal digits desired
47 | ///
48 | /// - Parameter decimalDigits: The amount of decimal digits of the currency formatted string
49 | public mutating func updateDecimalSeparator(decimalDigits: Int) {
50 | guard decimalDigits != 0 && count >= decimalDigits else { return }
51 | let decimalsRange = index(endIndex, offsetBy: -decimalDigits).. String {
61 | return replacingOccurrences(of:"[^0-9]", with: "", options: .regularExpression)
62 | }
63 | }
64 |
65 | // MARK: - Static constants
66 |
67 | extension String {
68 | public static let negativeSymbol = "-"
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/SwiftUI/CurrencyTextField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyTextField.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 12.04.21.
6 | //
7 |
8 | import Combine
9 | import SwiftUI
10 |
11 | /// A control that displays an editable currency text.
12 | ///
13 | /// You create a text field with a configuration for setting its properties and behavior.
14 | /// The configuration requires a placeholder text that is shown to describe the text field purpose
15 | /// whenever it's empty, a text binding and currency formatter that holds all the currency formatting
16 | /// related settings.
17 | ///
18 | /// The configuration also allows you to provide two closures that customize its
19 | /// behavior. The `onEditingChanged` property informs your app when the user
20 | /// begins or ends editing the text. The `onCommit` property executes when the
21 | /// user commits their edits.
22 | ///
23 | /// The following example shows a currency text field that performs and action to store the
24 | /// amount inputted by the user at the moment the user commits the entry, and that becomes first responder on appear:
25 | ///
26 | /// @State private var text: String = ""
27 | /// @State private var unformattedText: String?
28 | /// @State private var value: Double?
29 | /// @State private var hasFocus: Bool?
30 | ///
31 | /// var body: some View {
32 | /// CurrencyTextField(
33 | /// configuration: .init(
34 | /// placeholder: "Currency value",
35 | /// text: $text,
36 | /// unformattedText: $unformattedText,
37 | /// inputAmount: $value,
38 | /// hasFocus: $hasFocus,
39 | /// formatter: CurrencyFormatter.myFormatter,
40 | /// textFieldConfiguration: { uiTextField in
41 | /// uiTextField.font = UIFont.preferredFont(forTextStyle: .body)
42 | /// uiTextField.textColor = .blue
43 | /// },
44 | /// onEditingChanged: { isEditing in
45 | /// if isEditing == false {
46 | /// clearTextFieldText()
47 | /// }
48 | /// },
49 | /// onCommit: {
50 | /// storeValue()
51 | /// }
52 | /// )
53 | /// )
54 | /// .autocapitalization(.none)
55 | /// .disableAutocorrection(true)
56 | /// .border(Color(UIColor.separator))
57 | /// .onAppear {
58 | /// self.hasFocus = true
59 | /// }
60 | ///
61 | /// Text(username)
62 | /// .foregroundColor(isEditing ? .red : .blue)
63 | /// }
64 | ///
65 | /// ### Styling Currency Text Fields
66 | ///
67 | /// Given that so far SwiftUI doesn't provide API for customizing the text field selectedTextRange, CurrencyTextField bridges
68 | /// CurrencyText's UIKit implementation to SwiftUI, so it can control the selectedTextRange to provide a better experience for all
69 | /// formatter configurations, even when the currency symbol sits at the end of the formatted text.
70 | ///
71 | /// Because of that to customize the style of a currency text field,`most` SwiftUI modifiers that work on TextField `doesn't work`
72 | /// with CurrencyTextField. To overcome such limitation all styling and additional configurations can be done via the underlying UITextField
73 | /// configuration block:
74 | /// `textFieldConfiguration` block:
75 | ///
76 | /// var body: some View {
77 | /// CurrencyTextField(
78 | /// configuration: .init(
79 | /// placeholder: "Currency value",
80 | /// text: $text,
81 | /// formatter: CurrencyFormatter.myFormatter,
82 | /// textFieldConfiguration: { uiTextField in
83 | /// uiTextField.borderStyle = .roundedRect
84 | /// uiTextField.font = UIFont.preferredFont(forTextStyle: .body)
85 | /// uiTextField.textColor = .blue
86 | /// uiTextField.layer.borderColor = UIColor.red.cgColor
87 | /// uiTextField.layer.borderWidth = 1
88 | /// uiTextField.layer.cornerRadius = 4
89 | /// uiTextField.keyboardType = .numbersAndPunctuation
90 | /// uiTextField.layer.masksToBounds = true
91 | /// }
92 | /// )
93 | /// )
94 | ///
95 | @available(iOS 13.0, *)
96 | public struct CurrencyTextField: UIViewRepresentable {
97 | private let configuration: CurrencyTextFieldConfiguration
98 |
99 | /// Creates a currency text field instance with given configuration.
100 | ///
101 | /// - Parameter configuration: The configuration holding settings and properties to configure the text field with.
102 | public init(configuration: CurrencyTextFieldConfiguration) {
103 | self.configuration = configuration
104 | }
105 |
106 | public func makeUIView(
107 | context: UIViewRepresentableContext
108 | ) -> UITextField {
109 | let textField = WrappedTextField(configuration: configuration)
110 | textField.placeholder = configuration.placeholder
111 | textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
112 | textField.setContentHuggingPriority(.defaultLow, for: .horizontal)
113 | textField.keyboardType = .numberPad
114 | configuration.textFieldConfiguration?(textField)
115 |
116 | return textField
117 | }
118 |
119 | public func updateUIView(
120 | _ uiView: UITextField,
121 | context: UIViewRepresentableContext
122 | ) {
123 | guard let textField = uiView as? WrappedTextField else { return }
124 |
125 | textField.updateConfigurationIfNeeded(latest: configuration)
126 | textField.updateTextIfNeeded()
127 |
128 | if configuration.hasFocus?.wrappedValue == true && textField.isFirstResponder == false {
129 | textField.becomeFirstResponder()
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/Sources/SwiftUI/CurrencyTextFieldConfiguration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyTextField.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 11.04.21.
6 | //
7 |
8 | #if canImport(CurrencyFormatter)
9 | import CurrencyFormatter
10 | #endif
11 |
12 | import Foundation
13 | import struct SwiftUI.Binding
14 |
15 | import UIKit
16 |
17 | /// A type that hold all configuration and settings for a currency text field.
18 | @available(iOS 13.0, *)
19 | @MainActor
20 | public final class CurrencyTextFieldConfiguration {
21 | let placeholder: String
22 |
23 | @Binding
24 | var text: String
25 |
26 | @OptionalBinding
27 | private(set) var unformattedText: Binding?
28 |
29 | @OptionalBinding
30 | private(set) var inputAmount: Binding?
31 |
32 | @OptionalBinding
33 | private(set) var hasFocus: Binding?
34 |
35 | let clearsWhenValueIsZero: Bool
36 |
37 | @Binding
38 | var formatter: CurrencyFormatter
39 |
40 | let onCommit: (() -> Void)?
41 |
42 | let onEditingChanged: ((Bool) -> Void)?
43 |
44 | let textFieldConfiguration: ((UITextField) -> Void)?
45 |
46 | /// Creates a default `CurrencyTextFieldConfiguration` instance with base properties set.
47 | ///
48 | /// - Parameters:
49 | /// - text: The text to display and edit.
50 | /// - hasFocus: Binding property to keep track and drive UITextField responder state.
51 | /// - formatter: Currency formatter binding that will be used by the TextField. It holds all formatting related settings, such
52 | /// as currency, locale, hasDecimals, etc, and propagates formatting updates.
53 | /// - Returns: Initialized instance of `CurrencyTextFieldConfiguration`.
54 | ///
55 | /// - note: Only text and formatter are set. When additional configurations are needed, like performing actions on text field
56 | /// events, or configuring the underlying text field, the initializer can be used instead.
57 | public static func makeDefault(
58 | text: Binding,
59 | hasFocus: Binding? = nil,
60 | formatter: Binding
61 | ) -> Self {
62 | .init(
63 | text: text,
64 | hasFocus: hasFocus,
65 | formatter: formatter,
66 | textFieldConfiguration: nil,
67 | onEditingChanged: nil,
68 | onCommit: nil
69 | )
70 | }
71 |
72 | /// Creates a CurrencyTextField configuration with given properties.
73 | ///
74 | /// - Parameters:
75 | /// - placeholder: Text that is shown when the text field is empty, describes its purpose.
76 | /// - text: The text to display and edit.
77 | /// - unformattedText: Binding property that gives the latest unformatted text field text.
78 | /// - inputAmount: Binding property that gives the latest Double value for text field text.
79 | /// - hasFocus: Binding property to keep track and drive UITextField responder state.
80 | /// - clearsWhenValueIsZero: When `true` the text field text is cleared when user finishes editing with value as zero,
81 | /// otherwise if `false` the text field text will keep it's text when value is zero.
82 | /// - formatter: Currency formatter binding that will be used by the TextField. It holds all formatting related settings, such
83 | /// as currency, locale, hasDecimals, etc, and propagates formatting updates.
84 | /// - textFieldConfiguration: Closure to `configure the underlying UITextField`.
85 | /// Unfortunately, so far, for many things there are no APIs provided by Apple to go from SwiftUI to UIKit,
86 | /// like conversion of Font to UIFont. This configuration block allows the user to configure
87 | /// the underlying `UITextField` as they wish. Use this block to set any UITextField specific property as `.borderStyle`,
88 | /// `.keyboardType`, `.font`, `.textColor`, etc.
89 | /// - onEditingChanged: The action to perform when the user
90 | /// begins editing `text` and after the user finishes editing `text`.
91 | /// The closure receives a Boolean value that indicates the editing
92 | /// status: `true` when the user begins editing, `false` when they
93 | /// finish.
94 | /// - onCommit: An action to perform when the user performs an action
95 | /// (for example, when the user presses the Return key) while the text
96 | /// field has focus.
97 | public init(
98 | placeholder: String = "",
99 | text: Binding,
100 | unformattedText: Binding? = nil,
101 | inputAmount: Binding? = nil,
102 | hasFocus: Binding? = nil,
103 | clearsWhenValueIsZero: Bool = false,
104 | formatter: Binding,
105 | textFieldConfiguration: ((UITextField) -> Void)?,
106 | onEditingChanged: ((Bool) -> Void)? = nil,
107 | onCommit: (() -> Void)? = nil
108 | ) {
109 | self.placeholder = placeholder
110 | self._text = text
111 | self.unformattedText = unformattedText
112 | self.inputAmount = inputAmount
113 | self.hasFocus = hasFocus
114 | self._formatter = formatter
115 | self.clearsWhenValueIsZero = clearsWhenValueIsZero
116 | self.onEditingChanged = onEditingChanged
117 | self.onCommit = onCommit
118 | self.textFieldConfiguration = textFieldConfiguration
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/Sources/SwiftUI/OptionalBinding.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OptionalBinding.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 16.04.21.
6 | //
7 |
8 | import struct SwiftUI.Binding
9 |
10 | @available(iOS 13.0, *)
11 | @propertyWrapper
12 | struct OptionalBinding {
13 | var wrappedValue: Binding?
14 |
15 | init(wrappedValue: Binding?) {
16 | self.wrappedValue = wrappedValue
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/SwiftUI/WrappedTextField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WrappedTextField.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 24.04.21.
6 | //
7 |
8 | import Combine
9 | import UIKit
10 |
11 | #if canImport(CurrencyFormatter)
12 | import CurrencyFormatter
13 | #endif
14 |
15 | #if canImport(CurrencyUITextFieldDelegate)
16 | import CurrencyUITextFieldDelegate
17 | #endif
18 |
19 | @available(iOS 13.0, *)
20 | final class WrappedTextField: UITextField {
21 | private let currencyTextFieldDelegate: CurrencyUITextFieldDelegate
22 | private var configuration: CurrencyTextFieldConfiguration
23 |
24 | init(configuration: CurrencyTextFieldConfiguration) {
25 | self.configuration = configuration
26 | self.currencyTextFieldDelegate = CurrencyUITextFieldDelegate(formatter: configuration.formatter)
27 | self.currencyTextFieldDelegate.clearsWhenValueIsZero = configuration.clearsWhenValueIsZero
28 |
29 | super.init(frame: .zero)
30 |
31 | delegate = currencyTextFieldDelegate
32 | currencyTextFieldDelegate.passthroughDelegate = self
33 | updateText()
34 | }
35 |
36 | @available(*, unavailable)
37 | required init?(coder: NSCoder) {
38 | fatalError("init(coder:) has not been implemented")
39 | }
40 |
41 | func updateConfigurationIfNeeded(latest configuration: CurrencyTextFieldConfiguration) {
42 | guard configuration !== self.configuration else { return }
43 |
44 | self.configuration = configuration
45 | self.currencyTextFieldDelegate.formatter = configuration.formatter
46 | }
47 |
48 | func updateTextIfNeeded() {
49 | var updatedText: String?
50 | if let text = text, text.isEmpty == false {
51 | updatedText = configuration
52 | .formatter
53 | .formattedStringWithAdjustedDecimalSeparator(from: text)
54 | }
55 |
56 | guard configuration.text != text || (updatedText != text && text?.isEmpty == false) else {
57 | return
58 | }
59 |
60 | updateText()
61 | }
62 | }
63 |
64 | // MARK: - UITextFieldDelegate
65 |
66 | @available(iOS 13.0, *)
67 | extension WrappedTextField: UITextFieldDelegate {
68 | func textField(
69 | _ textField: UITextField,
70 | shouldChangeCharactersIn range: NSRange,
71 | replacementString string: String
72 | ) -> Bool {
73 | Task { @MainActor in
74 | self.configuration.$text.wrappedValue = textField.text ?? ""
75 | self.updateUnformattedTextAndInputValue()
76 | }
77 |
78 | return false
79 | }
80 |
81 | func textFieldDidBeginEditing(_ textField: UITextField) {
82 | configuration.onEditingChanged?(true)
83 | }
84 |
85 | func textFieldDidEndEditing(_ textField: UITextField) {
86 | configuration.hasFocus?.wrappedValue = false
87 | configuration.onEditingChanged?(false)
88 | }
89 |
90 | func textFieldShouldReturn(_ textField: UITextField) -> Bool {
91 | configuration.onCommit?()
92 | textField.resignFirstResponder()
93 |
94 | return true
95 | }
96 | }
97 |
98 | // MARK: - Private
99 |
100 | @available(iOS 13.0, *)
101 | private extension WrappedTextField {
102 | func updateText() {
103 | let nsRange: NSRange
104 | if let textRange = text?.range(of: text ?? "") {
105 | nsRange = .init(
106 | textRange,
107 | in: text ?? ""
108 | )
109 | } else {
110 | nsRange = .init(location: 0, length: 0)
111 | }
112 |
113 | _ = delegate?.textField?(
114 | self,
115 | shouldChangeCharactersIn: nsRange,
116 | replacementString: configuration.$text.wrappedValue
117 | )
118 | }
119 |
120 | func updateUnformattedTextAndInputValue() {
121 | let unformattedText = configuration.formatter.unformatted(
122 | string: text ?? ""
123 | ) ?? ""
124 | configuration.unformattedText?.wrappedValue = unformattedText
125 |
126 | configuration.inputAmount?.wrappedValue = configuration.formatter.double(
127 | from: unformattedText
128 | )
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/Sources/UITextFieldDelegate/CurrencyUITextFieldDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyUITextFieldDelegate.swift
3 | // CurrencyText
4 | //
5 | // Created by Felipe Lefèvre Marino on 12/26/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | #if canImport(CurrencyFormatter)
12 | import CurrencyFormatter
13 | #endif
14 |
15 | /// Custom text field delegate, that formats user inputs based on a given currency formatter.
16 | @MainActor
17 | public class CurrencyUITextFieldDelegate: NSObject {
18 |
19 | public var formatter: (CurrencyFormatting & CurrencyAdjusting)!
20 |
21 | /// Text field clears its text when value value is equal to zero.
22 | public var clearsWhenValueIsZero: Bool = false
23 |
24 | /// A delegate object to receive and potentially handle `UITextFieldDelegate events` that are sent to `CurrencyUITextFieldDelegate`.
25 | ///
26 | /// Note: Make sure the implementation of this object does not wrongly interfere with currency formatting.
27 | ///
28 | /// By returning `false` on`textField(textField:shouldChangeCharactersIn:replacementString:)` no currency formatting is done.
29 | public var passthroughDelegate: UITextFieldDelegate? {
30 | get { return _passthroughDelegate }
31 | set {
32 | guard newValue !== self else { return }
33 | _passthroughDelegate = newValue
34 | }
35 | }
36 | weak private(set) var _passthroughDelegate: UITextFieldDelegate?
37 |
38 | override public init() {
39 | super.init()
40 | self.formatter = CurrencyFormatter()
41 | }
42 |
43 | public init(formatter: CurrencyFormatter) {
44 | self.formatter = formatter
45 | }
46 | }
47 |
48 | // MARK: - UITextFieldDelegate
49 |
50 | extension CurrencyUITextFieldDelegate: UITextFieldDelegate {
51 |
52 | @discardableResult
53 | open func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
54 | return passthroughDelegate?.textFieldShouldBeginEditing?(textField) ?? true
55 | }
56 |
57 | public func textFieldDidBeginEditing(_ textField: UITextField) {
58 | textField.setInitialSelectedTextRange()
59 | passthroughDelegate?.textFieldDidBeginEditing?(textField)
60 | }
61 |
62 | @discardableResult
63 | public func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
64 | if let text = textField.text, text.representsZero && clearsWhenValueIsZero {
65 | textField.text = ""
66 | }
67 | else if let text = textField.text, let updated = formatter.formattedStringAdjustedToFitAllowedValues(from: text), updated != text {
68 | textField.text = updated
69 | }
70 | return passthroughDelegate?.textFieldShouldEndEditing?(textField) ?? true
71 | }
72 |
73 | open func textFieldDidEndEditing(_ textField: UITextField) {
74 | passthroughDelegate?.textFieldDidEndEditing?(textField)
75 | }
76 |
77 | @discardableResult
78 | open func textFieldShouldClear(_ textField: UITextField) -> Bool {
79 | return passthroughDelegate?.textFieldShouldClear?(textField) ?? true
80 | }
81 |
82 | @discardableResult
83 | open func textFieldShouldReturn(_ textField: UITextField) -> Bool {
84 | return passthroughDelegate?.textFieldShouldReturn?(textField) ?? true
85 | }
86 |
87 | @discardableResult
88 | public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
89 | // Store selected text range offset from end, before updating and reformatting the currency string.
90 | let lastSelectedTextRangeOffsetFromEnd = textField.selectedTextRangeOffsetFromEnd
91 |
92 | // Before leaving the scope, update selected text range,
93 | // respecting previous selected text range offset from end.
94 | defer {
95 | textField.updateSelectedTextRange(lastOffsetFromEnd: lastSelectedTextRangeOffsetFromEnd)
96 | }
97 |
98 | let returnAndCallPassThroughDelegate: () -> Bool = {
99 | self.passthroughDelegate?.textField?(
100 | textField,
101 | shouldChangeCharactersIn: range,
102 | replacementString: string
103 | ) ?? false
104 | }
105 |
106 | guard !string.isEmpty else {
107 | handleDeletion(in: textField, at: range)
108 | return returnAndCallPassThroughDelegate()
109 | }
110 | guard string.hasNumbers else {
111 | addNegativeSymbolIfNeeded(in: textField, at: range, replacementString: string)
112 | return returnAndCallPassThroughDelegate()
113 | }
114 |
115 | setFormattedText(in: textField, inputString: string, range: range)
116 | return returnAndCallPassThroughDelegate()
117 | }
118 | }
119 |
120 | // MARK: - Private
121 |
122 | extension CurrencyUITextFieldDelegate {
123 |
124 | /// Verifies if user inputed a negative symbol at the first lowest
125 | /// bound of the text field and add it.
126 | ///
127 | /// - Parameters:
128 | /// - textField: text field that user interacted with
129 | /// - range: user input range
130 | /// - string: user input string
131 | private func addNegativeSymbolIfNeeded(in textField: UITextField, at range: NSRange, replacementString string: String) {
132 | guard textField.keyboardType == .numbersAndPunctuation else { return }
133 |
134 | if string == .negativeSymbol && textField.text?.isEmpty == true {
135 | textField.text = .negativeSymbol
136 | } else if range.lowerBound == 0 && string == .negativeSymbol &&
137 | textField.text?.contains(String.negativeSymbol) == false {
138 |
139 | textField.text = .negativeSymbol + (textField.text ?? "")
140 | }
141 | }
142 |
143 | /// Correctly delete characters when user taps remove key.
144 | ///
145 | /// - Parameters:
146 | /// - textField: text field that user interacted with
147 | /// - range: range to be removed
148 | private func handleDeletion(in textField: UITextField, at range: NSRange) {
149 | if var text = textField.text {
150 | if let textRange = Range(range, in: text) {
151 | text.removeSubrange(textRange)
152 | } else {
153 | text.removeLast()
154 | }
155 |
156 | if text.isEmpty {
157 | textField.text = text
158 | } else {
159 | textField.text = formatter.formattedStringWithAdjustedDecimalSeparator(from: text)
160 | }
161 | }
162 | }
163 |
164 | /// Formats text field's text with new input string and changed range
165 | ///
166 | /// - Parameters:
167 | /// - textField: text field that user interacted with
168 | /// - inputString: typed string
169 | /// - range: range where the string should be added
170 | private func setFormattedText(in textField: UITextField, inputString: String, range: NSRange) {
171 | var updatedText = ""
172 |
173 | if let text = textField.text {
174 | if text.isEmpty {
175 | updatedText = formatter.initialText + inputString
176 | } else if let range = Range(range, in: text) {
177 | updatedText = text.replacingCharacters(in: range, with: inputString)
178 | } else {
179 | updatedText = text.appending(inputString)
180 | }
181 | }
182 |
183 | if updatedText.numeralFormat().count > formatter.maxDigitsCount {
184 | updatedText.removeLast()
185 | }
186 |
187 | textField.text = formatter.formattedStringWithAdjustedDecimalSeparator(from: updatedText)
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/Sources/UITextFieldDelegate/UITextField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITextField.swift
3 | // CurrencyText
4 | //
5 | // Created by Felipe Lefèvre Marino on 12/26/18.
6 | //
7 |
8 | import UIKit
9 |
10 | public extension UITextField {
11 |
12 | // MARK: Public
13 |
14 | var selectedTextRangeOffsetFromEnd: Int {
15 | return offset(from: endOfDocument, to: selectedTextRange?.end ?? endOfDocument)
16 | }
17 |
18 | /// Sets the selected text range when the text field is starting to be edited.
19 | /// _Should_ be called when text field start to be the first responder.
20 | func setInitialSelectedTextRange() {
21 | // update selected text range if needed
22 | adjustSelectedTextRange(lastOffsetFromEnd: 0) // at the end when first selected
23 | }
24 |
25 | /// Interface to update the selected text range as expected.
26 | /// - Parameter lastOffsetFromEnd: The last stored selected text range offset from end. Used to keep it concise with pre-formatting.
27 | func updateSelectedTextRange(lastOffsetFromEnd: Int) {
28 | adjustSelectedTextRange(lastOffsetFromEnd: lastOffsetFromEnd)
29 | }
30 |
31 | // MARK: Private
32 |
33 | /// Adjust the selected text range to match the best position.
34 | private func adjustSelectedTextRange(lastOffsetFromEnd: Int) {
35 | /// If text is empty the offset is set to zero, the selected text range does need to be changed.
36 | if let text = text, text.isEmpty {
37 | return
38 | }
39 |
40 | var offsetFromEnd = lastOffsetFromEnd
41 |
42 | /// Adjust offset if needed. When the last number character offset from end is less than the current offset,
43 | /// or in other words, is more distant to the end of the string, the offset is readjusted to it,
44 | /// so the selected text range is correctly set to the last index with a number.
45 | if let lastNumberOffsetFromEnd = text?.lastNumberOffsetFromEnd,
46 | case let shouldOffsetBeAdjusted = lastNumberOffsetFromEnd < offsetFromEnd,
47 | shouldOffsetBeAdjusted {
48 |
49 | offsetFromEnd = lastNumberOffsetFromEnd
50 | }
51 |
52 | updateSelectedTextRange(offsetFromEnd: offsetFromEnd)
53 | }
54 |
55 | /// Update the selected text range with given offset from end.
56 | private func updateSelectedTextRange(offsetFromEnd: Int) {
57 | if let updatedCursorPosition = position(from: endOfDocument, offset: offsetFromEnd) {
58 | selectedTextRange = textRange(from: updatedCursorPosition, to: updatedCursorPosition)
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Tests/Formatter/CurrencyFormatterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyFormatterTests.swift
3 | // ExampleTests
4 | //
5 | // Created by Felipe Lefèvre Marino on 2/11/19.
6 | // Copyright © 2019 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CurrencyFormatter
11 |
12 | class CurrencyFormatterTests: XCTestCase {
13 |
14 | var formatter: CurrencyFormatter!
15 |
16 | override func setUp() {
17 | super.setUp()
18 | formatter = CurrencyFormatter()
19 | formatter.locale = CurrencyLocale.englishIreland
20 | formatter.currency = .euro
21 | }
22 |
23 | override func tearDown() {
24 | formatter = nil
25 | super.tearDown()
26 | }
27 |
28 | func testComposing() {
29 | formatter = CurrencyFormatter {
30 | $0.locale = CurrencyLocale.italianItaly
31 | $0.currency = .euro
32 | $0.hasDecimals = false
33 | }
34 |
35 | XCTAssertEqual(formatter.decimalDigits, 0)
36 | XCTAssertEqual(formatter.hasDecimals, false)
37 | XCTAssertEqual(formatter.locale.locale, CurrencyLocale.italianItaly.locale)
38 | XCTAssertEqual(formatter.currencySymbol, "€")
39 |
40 | formatter.decimalSeparator = ";"
41 | XCTAssertEqual(formatter.numberFormatter.currencyDecimalSeparator, ";")
42 |
43 | formatter.currencyCode = "^"
44 | XCTAssertEqual(formatter.numberFormatter.currencyCode, "^")
45 |
46 | formatter.alwaysShowsDecimalSeparator = true
47 | XCTAssertEqual(formatter.numberFormatter.alwaysShowsDecimalSeparator, true)
48 |
49 | formatter.groupingSize = 4
50 | XCTAssertEqual(formatter.numberFormatter.groupingSize, 4)
51 |
52 | formatter.secondaryGroupingSize = 1
53 | XCTAssertEqual(formatter.numberFormatter.secondaryGroupingSize, 1)
54 |
55 | formatter.groupingSeparator = "-"
56 | XCTAssertEqual(formatter.numberFormatter.currencyGroupingSeparator, "-")
57 |
58 | formatter.hasGroupingSeparator = false
59 | XCTAssertEqual(formatter.numberFormatter.usesGroupingSeparator, false)
60 |
61 | formatter.currencySymbol = "%"
62 | formatter.showCurrencySymbol = false
63 | XCTAssertEqual(formatter.showCurrencySymbol, false)
64 | XCTAssertEqual(formatter.numberFormatter.currencySymbol, "")
65 |
66 | formatter.showCurrencySymbol = true
67 | formatter.currencySymbol = "%"
68 | XCTAssertEqual(formatter.showCurrencySymbol, true)
69 | XCTAssertEqual(formatter.numberFormatter.currencySymbol, "%")
70 | }
71 |
72 | func testMinAndMaxValues() {
73 | formatter.minValue = nil
74 | formatter.maxValue = nil
75 |
76 | var formattedString = formatter.string(from: 300000.54)
77 | XCTAssertEqual(formattedString, "€300,000.54")
78 |
79 | formatter.minValue = 10
80 | formatter.maxValue = 100.31
81 |
82 | formattedString = formatter.formattedStringAdjustedToFitAllowedValues(from: "€300,000.54")
83 | XCTAssertEqual(formattedString, "€100.31")
84 |
85 | formattedString = formatter.formattedStringAdjustedToFitAllowedValues(from: "€2.03")
86 | XCTAssertEqual(formattedString, "€10.00")
87 |
88 | formattedString = formatter.string(from: 88888888)
89 | XCTAssertEqual(formattedString, "€100.31")
90 |
91 | formattedString = formatter.string(from: 1)
92 | XCTAssertEqual(formattedString, "€10.00")
93 |
94 | formatter.minValue = -351
95 | formattedString = formatter.string(from: -24)
96 | XCTAssertEqual(formattedString, "-€24.00")
97 |
98 | formattedString = formatter.string(from: -400)
99 | XCTAssertEqual(formattedString, "-€351.00")
100 | }
101 |
102 | func testFormatting() {
103 | formatter.locale = CurrencyLocale.portugueseBrazil
104 | formatter.currency = .euro
105 | formatter.hasDecimals = true
106 |
107 | let formattedString = formatter.string(from: 300000.54)
108 | XCTAssertEqual(formattedString, "€ 300.000,54")
109 |
110 | let unformattedString = formatter.unformatted(string: formattedString!)
111 | XCTAssertEqual(unformattedString, "300000.54")
112 |
113 | let doubleValue = formatter.double(from: "300000.54")
114 | XCTAssertEqual(doubleValue, 300000.54)
115 | }
116 |
117 | func testUnformattedValueWhenHasDecimal() {
118 | formatter.locale = CurrencyLocale.portugueseBrazil
119 | formatter.currency = .euro
120 | formatter.hasDecimals = true
121 |
122 | XCTAssertEqual(
123 | formatter.unformatted(string: "€ 300.000,54"),
124 | "300000.54"
125 | )
126 | XCTAssertEqual(
127 | formatter.unformatted(string: "¥ 0,99"),
128 | "0.99"
129 | )
130 | XCTAssertEqual(
131 | formatter.unformatted(string: "$333,84"),
132 | "333.84"
133 | )
134 | }
135 |
136 | func testUnformattedValueWhenDecimalsAreDisabled() {
137 | formatter.hasDecimals = false
138 |
139 | XCTAssertEqual(
140 | formatter.unformatted(string: "€ 300.000"),
141 | "300000"
142 | )
143 | XCTAssertEqual(
144 | formatter.unformatted(string: "¥3.953"),
145 | "3953"
146 | )
147 | XCTAssertEqual(
148 | formatter.unformatted(string: "$999"),
149 | "999"
150 | )
151 | }
152 |
153 | func testDoubleFromStringForDifferentFormatters() {
154 | formatter.locale = CurrencyLocale.portugueseBrazil
155 | formatter.currency = .euro
156 | formatter.hasDecimals = true
157 |
158 | var doubleValue = formatter.double(from: "00.02")
159 | XCTAssertEqual(doubleValue, 0.02)
160 |
161 | formatter.locale = CurrencyLocale.dutchBelgium
162 | formatter.currency = .dollar
163 | formatter.hasDecimals = false
164 |
165 | doubleValue = formatter.double(from: "00.02")
166 | XCTAssertEqual(doubleValue, 0.02)
167 |
168 | formatter.locale = CurrencyLocale.zarma
169 | formatter.hasDecimals = false
170 |
171 | doubleValue = formatter.double(from: "100.12")
172 | XCTAssertEqual(doubleValue, 100.12)
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/Tests/Formatter/NumberFormatterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NumberFormatterTests.swift
3 | // ExampleTests
4 | //
5 | // Created by Felipe Lefèvre Marino on 12/27/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import XCTest
10 |
11 | class NumberFormatterTests: XCTestCase {
12 |
13 | func testStringFromDouble() {
14 | let formatter = NumberFormatter()
15 | formatter.numberStyle = .currency
16 | formatter.locale = Locale(identifier: "en_US")
17 |
18 | // nil double
19 | XCTAssertNil(formatter.string(from: nil))
20 |
21 | // double
22 | XCTAssertEqual(formatter.string(from: 3500.32), "$3,500.32")
23 | }
24 | }
25 |
26 | // MARK: All Tests
27 | extension NumberFormatterTests {
28 | static var allTests = {
29 | return [
30 | ("testStringFromDouble", testStringFromDouble),
31 | ]
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Tests/Formatter/StringTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String.swift
3 | // GroceryListTests
4 | //
5 | // Created by Felipe Lefèvre Marino on 4/5/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CurrencyFormatter
11 |
12 | class StringTests: XCTestCase {
13 |
14 | func testNumeralFormat() {
15 | let nonNumeralString = "*235&Q@6634(355$Q9_-$0_Q8$*64_!@1:'/.,.a"
16 | XCTAssertEqual(nonNumeralString.numeralFormat(), "2356634355908641", "numeralFormat() should restrict string to only numerals")
17 | }
18 |
19 | func testAddingDecimalSeparator() {
20 | var text = "14349"
21 | text.updateDecimalSeparator(decimalDigits: 2)
22 | XCTAssertEqual(text, "143.49", "Text format should be 143.49")
23 |
24 | text = "349"
25 | text.updateDecimalSeparator(decimalDigits: 1)
26 | XCTAssertEqual(text, "34.9", "Text format should be 34.9")
27 |
28 | text = "99"
29 | text.updateDecimalSeparator(decimalDigits: 0)
30 | XCTAssertEqual(text, "99", "Text format should be 99")
31 |
32 | text = "9"
33 | text.updateDecimalSeparator(decimalDigits: 2)
34 | XCTAssertEqual(text, "9", "When there aren't enough characters the text should stay the same")
35 |
36 | text = "9"
37 | text.updateDecimalSeparator(decimalDigits: 1)
38 | XCTAssertEqual(text, ".9", "When there aren't enough characters the text should stay the same")
39 | }
40 |
41 | func testRepresentsZero() {
42 | var currencyValue = "R$ 34.00"
43 |
44 | XCTAssertFalse(currencyValue.representsZero, "value \(currencyValue) should not represent zero")
45 |
46 | currencyValue = "00,34"
47 | XCTAssertFalse(currencyValue.representsZero, "value \(currencyValue) should not represent zero")
48 |
49 | currencyValue = "0.000,00"
50 | XCTAssertTrue(currencyValue.representsZero, "value \(currencyValue) should represent zero")
51 | }
52 |
53 | func testHasNumbers() {
54 | var string = "R$"
55 | XCTAssertFalse(string.hasNumbers)
56 |
57 | string = ","
58 | XCTAssertFalse(string.hasNumbers)
59 |
60 | string = "#$a"
61 | XCTAssertFalse(string.hasNumbers)
62 |
63 | string = "34164"
64 | XCTAssertTrue(string.hasNumbers)
65 |
66 | string = "sa12"
67 | XCTAssertTrue(string.hasNumbers)
68 | }
69 | }
70 |
71 | // MARK: All Tests
72 | extension StringTests {
73 | static var allTests = {
74 | return [
75 | ("testNumeralFormat", testNumeralFormat),
76 | ("testAddingDecimalSeparator", testAddingDecimalSeparator),
77 | ("testRepresentsZero", testRepresentsZero),
78 | ("testHasNumbers", testHasNumbers),
79 | ]
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Tests/SwiftUI/CurrencyTextFieldConfigurationTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyTextFieldConfigurationTests.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 24.04.21.
6 | //
7 |
8 | import SwiftUI
9 | import XCTest
10 |
11 | import CurrencyFormatter
12 | import CurrencyTextFieldTestSupport
13 |
14 | @testable import CurrencyTextField
15 |
16 | @available(iOS 13.0, *)
17 | @MainActor
18 | final class CurrencyTextFieldConfigurationTests: XCTestCase {
19 | func testMakeDefault() {
20 | let textBinding = Binding(
21 | get: { "" },
22 | set: { _ in }
23 | )
24 | let hasFocusBinding = Binding(
25 | get: { false },
26 | set: { _ in }
27 | )
28 | let formatter = CurrencyFormatter()
29 | let sut = CurrencyTextFieldConfiguration.makeDefault(
30 | text: textBinding,
31 | hasFocus: hasFocusBinding,
32 | formatter: .init(
33 | get: { formatter },
34 | set: { _ in }
35 | )
36 | )
37 |
38 | XCTAssertEqual(sut.placeholder, "")
39 | XCTAssertEqual(sut.text, "")
40 | XCTAssertEqual(sut.hasFocus?.wrappedValue, false)
41 | XCTAssertTrue(sut.formatter === formatter)
42 | XCTAssertFalse(sut.clearsWhenValueIsZero)
43 | XCTAssertNil(sut.unformattedText)
44 | XCTAssertNil(sut.inputAmount)
45 | XCTAssertNil(sut.textFieldConfiguration)
46 | XCTAssertNil(sut.onEditingChanged)
47 | XCTAssertNil(sut.onCommit)
48 | }
49 |
50 | func testInit() {
51 | let formatter = CurrencyFormatter()
52 | let sut = CurrencyTextFieldConfiguration.makeFixture(
53 | formatter: .init(
54 | get: { formatter },
55 | set: { _ in }
56 | )
57 | )
58 |
59 | XCTAssertEqual(sut.placeholder, "some")
60 | XCTAssertEqual(sut.text, "text")
61 | XCTAssertEqual(sut.unformattedText?.wrappedValue, "unformatted")
62 | XCTAssertEqual(sut.inputAmount?.wrappedValue, .zero)
63 | XCTAssertEqual(sut.hasFocus?.wrappedValue, true)
64 | XCTAssertTrue(sut.formatter === formatter)
65 | XCTAssertTrue(sut.clearsWhenValueIsZero)
66 | XCTAssertNotNil(sut.textFieldConfiguration)
67 | XCTAssertNotNil(sut.onEditingChanged)
68 | XCTAssertNotNil(sut.onCommit)
69 | }
70 |
71 | func testText() {
72 | var textSetCalls: [String] = []
73 | var textValue = ""
74 | let textBinding = Binding(
75 | get: { textValue },
76 | set: { value in textSetCalls.append(value) }
77 | )
78 |
79 | let sut = CurrencyTextFieldConfiguration.makeFixture(textBinding: textBinding)
80 |
81 | sut.text = "val"
82 | sut.text = "anotherVal"
83 |
84 | XCTAssertEqual(textSetCalls, ["val", "anotherVal"])
85 |
86 | textValue = "new"
87 | XCTAssertEqual(sut.text, "new")
88 | }
89 |
90 | func testUnformattedText() {
91 | var unformattedText = "unformatted"
92 | let unformattedTextBinding = Binding(
93 | get: { unformattedText },
94 | set: { _ in }
95 | )
96 |
97 | let sut = CurrencyTextFieldConfiguration.makeFixture(
98 | unformattedTextBinding: unformattedTextBinding
99 | )
100 |
101 | XCTAssertEqual(sut.unformattedText?.wrappedValue, "unformatted")
102 |
103 | unformattedText = "newValue"
104 |
105 | XCTAssertEqual(sut.unformattedText?.wrappedValue, "newValue")
106 | }
107 |
108 | func testInputAmount() {
109 | var inputAmount: Double? = .zero
110 | let inputAmountBinding = Binding(
111 | get: { inputAmount },
112 | set: { _ in }
113 | )
114 |
115 | let sut = CurrencyTextFieldConfiguration.makeFixture(
116 | inputAmountBinding: inputAmountBinding
117 | )
118 |
119 | XCTAssertEqual(sut.inputAmount?.wrappedValue, .zero)
120 |
121 | inputAmount = nil
122 |
123 | XCTAssertNil(sut.inputAmount?.wrappedValue)
124 | }
125 |
126 | func testHasFocus() {
127 | var hasFocus: Bool? = true
128 | let hasFocusBinding = Binding(
129 | get: { hasFocus },
130 | set: { _ in }
131 | )
132 |
133 | let sut = CurrencyTextFieldConfiguration.makeFixture(
134 | hasFocusBinding: hasFocusBinding
135 | )
136 |
137 | XCTAssertEqual(sut.hasFocus?.wrappedValue, true)
138 |
139 | hasFocus = false
140 |
141 | XCTAssertEqual(sut.hasFocus?.wrappedValue, false)
142 | }
143 |
144 | func testTextFieldConfiguration() {
145 | var textFieldConfigurationReceivedValues: [UITextField] = []
146 | let textFieldConfiguration: ((UITextField) -> Void)? = { textField in
147 | textFieldConfigurationReceivedValues.append(textField)
148 | }
149 |
150 | let sut = CurrencyTextFieldConfiguration.makeFixture(
151 | textFieldConfiguration: textFieldConfiguration
152 | )
153 |
154 | let textField = UITextField()
155 | sut.textFieldConfiguration?(textField)
156 |
157 | XCTAssertEqual(textFieldConfigurationReceivedValues, [textField])
158 | }
159 |
160 | func testOnEditingChanged() {
161 | var onEditingChangedReceivedValues: [Bool] = []
162 | let onEditingChanged: ((Bool) -> Void)? = { isEditing in
163 | onEditingChangedReceivedValues.append(isEditing)
164 | }
165 |
166 | let sut = CurrencyTextFieldConfiguration.makeFixture(
167 | onEditingChanged: onEditingChanged
168 | )
169 |
170 | sut.onEditingChanged?(true)
171 | sut.onEditingChanged?(false)
172 | sut.onEditingChanged?(true)
173 |
174 | XCTAssertEqual(
175 | onEditingChangedReceivedValues,
176 | [true, false, true]
177 | )
178 | }
179 |
180 | func testOnCommit() {
181 | var onCommitCallsCount = 0
182 | let onCommit: (() -> Void)? = {
183 | onCommitCallsCount += 1
184 | }
185 |
186 | let sut = CurrencyTextFieldConfiguration.makeFixture(
187 | onCommit: onCommit
188 | )
189 |
190 | sut.onCommit?()
191 | sut.onCommit?()
192 |
193 | XCTAssertEqual(onCommitCallsCount, 2)
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/Tests/SwiftUI/WrappedTextFieldTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WrappedTextFieldTests.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 24.04.21.
6 | //
7 |
8 | import SwiftUI
9 | import XCTest
10 |
11 | import CurrencyFormatter
12 | import CurrencyTextFieldTestSupport
13 |
14 | @testable import CurrencyTextField
15 |
16 | import ConcurrencyExtras
17 |
18 | @available(iOS 13.0, *)
19 | private final class ViewModel: ObservableObject {
20 | @Published var text: String = "56"
21 | }
22 |
23 | @available(iOS 13.0, *)
24 | @MainActor
25 | final class WrappedTextFieldTests: XCTestCase {
26 | private var sut: WrappedTextField!
27 | private var formatter: CurrencyFormatter!
28 |
29 | @ObservedObject
30 | private var viewModel: ViewModel = .init()
31 |
32 | // MARK: - Accessors
33 |
34 | private var textSetValues: [String]!
35 | private var unformattedTextSetValues: [String?]!
36 | private var inputAmountSetValues: [Double?]!
37 | private var hasFocusSetValues: [Bool?]!
38 | private var textFieldConfigurationReceivedValues: [UITextField]!
39 | private var onEditingChangedReceivedValues: [Bool]!
40 | private var onCommitCallsCount: Int!
41 |
42 | // MARK: - Life cycle
43 |
44 | override func invokeTest() {
45 | withMainSerialExecutor {
46 | super.invokeTest()
47 | }
48 | }
49 |
50 | override func setUpWithError() throws {
51 | try super.setUpWithError()
52 |
53 | textSetValues = []
54 | unformattedTextSetValues = []
55 | inputAmountSetValues = []
56 | hasFocusSetValues = []
57 | textFieldConfigurationReceivedValues = []
58 | onEditingChangedReceivedValues = []
59 | onCommitCallsCount = 0
60 |
61 | formatter = .init()
62 | // Define currency and locale so test is deterministic. To be converted to a test plan
63 | formatter.currency = .dollar
64 | formatter.locale = CurrencyLocale.englishUnitedStates
65 |
66 | assembleSut()
67 | }
68 |
69 | override func tearDown() async throws {
70 | await Task.megaYield()
71 |
72 | textSetValues = nil
73 | unformattedTextSetValues = nil
74 | inputAmountSetValues = nil
75 | hasFocusSetValues = nil
76 | textFieldConfigurationReceivedValues = nil
77 | onEditingChangedReceivedValues = nil
78 | onCommitCallsCount = nil
79 | formatter = nil
80 | sut = nil
81 |
82 | try super.tearDownWithError()
83 | }
84 |
85 | private func assembleSut(text: String = "34") {
86 | sut = WrappedTextField(
87 | configuration: .makeFixture(
88 | textBinding: Binding(
89 | get: { text },
90 | set: { text in
91 | self.textSetValues.append(text)
92 | }
93 | ),
94 | unformattedTextBinding: Binding(
95 | get: { "unformatted" },
96 | set: { text in
97 | self.unformattedTextSetValues.append(text)
98 | }
99 | ),
100 | inputAmountBinding: Binding(
101 | get: { .zero },
102 | set: { value in
103 | self.inputAmountSetValues.append(value)
104 | }
105 | ),
106 | hasFocusBinding: Binding(
107 | get: { false },
108 | set: { value in
109 | self.hasFocusSetValues.append(value)
110 | }
111 | ),
112 | formatter: Binding(
113 | get: { self.formatter },
114 | set: { _ in }
115 | ),
116 | textFieldConfiguration: { textField in
117 | self.textFieldConfigurationReceivedValues.append(textField)
118 | },
119 | onEditingChanged: { isEditing in
120 | self.onEditingChangedReceivedValues.append(isEditing)
121 | },
122 | onCommit: {
123 | self.onCommitCallsCount += 1
124 | }
125 | )
126 | )
127 | }
128 |
129 | // MARK: - Tests
130 |
131 | func testDelegate() {
132 | XCTAssertNotNil(sut.delegate)
133 | }
134 |
135 | func testInitialTextWhenValueIsInvalid() {
136 | assembleSut(text: "some")
137 |
138 | XCTAssertEqual(sut.text?.isEmpty, true)
139 | }
140 |
141 | func testInitialTextWhenValueIsValid() async {
142 | XCTAssertEqual(sut.text, "$0.34")
143 | }
144 |
145 | func testShouldChangeCharactersInRange() async {
146 | // await withMainSerialExecutor {
147 | _ = sut.delegate?.textField?(
148 | sut,
149 | shouldChangeCharactersIn: NSRange(location: 5, length: 1),
150 | replacementString: "3"
151 | )
152 | await Task.megaYield()
153 |
154 | XCTAssertEqual(
155 | textSetValues,
156 | [
157 | "$0.34",
158 | "$3.43"
159 | ]
160 | )
161 | XCTAssertEqual(
162 | unformattedTextSetValues,
163 | [
164 | "0.34",
165 | "3.43"
166 | ]
167 | )
168 | XCTAssertEqual(
169 | inputAmountSetValues,
170 | [
171 | 0.34,
172 | 3.43
173 | ]
174 | )
175 | XCTAssertTrue(textFieldConfigurationReceivedValues.isEmpty)
176 | XCTAssertTrue(onEditingChangedReceivedValues.isEmpty)
177 | XCTAssertEqual(onCommitCallsCount, 0)
178 | XCTAssertTrue(hasFocusSetValues.isEmpty)
179 | // }
180 | }
181 |
182 | func testTextFieldDidBeginEditing() async {
183 | await withMainSerialExecutor {
184 | sut.becomeFirstResponder()
185 | _ = sut.delegate?.textFieldDidBeginEditing?(sut)
186 |
187 | await Task.megaYield()
188 |
189 | XCTAssertEqual(textSetValues.count, 1)
190 | XCTAssertEqual(unformattedTextSetValues.count, 1)
191 | XCTAssertEqual(inputAmountSetValues.count, 1)
192 | XCTAssertTrue(textFieldConfigurationReceivedValues.isEmpty)
193 | XCTAssertEqual(onEditingChangedReceivedValues, [true])
194 | XCTAssertEqual(onCommitCallsCount, 0)
195 | XCTAssertFalse(sut.isFirstResponder)
196 | XCTAssertTrue(hasFocusSetValues.isEmpty)
197 | }
198 | }
199 |
200 | func testTextFieldDidEndEditing() async {
201 | await withMainSerialExecutor {
202 | sut.becomeFirstResponder()
203 | _ = sut.delegate?.textFieldDidEndEditing?(sut)
204 |
205 | await Task.megaYield()
206 |
207 | XCTAssertEqual(textSetValues.count, 1)
208 | XCTAssertEqual(unformattedTextSetValues.count, 1)
209 | XCTAssertEqual(inputAmountSetValues.count, 1)
210 | XCTAssertTrue(textFieldConfigurationReceivedValues.isEmpty)
211 | XCTAssertEqual(onEditingChangedReceivedValues, [false])
212 | XCTAssertEqual(onCommitCallsCount, 0)
213 | XCTAssertFalse(sut.isFirstResponder)
214 | XCTAssertEqual(
215 | hasFocusSetValues,
216 | [false],
217 | "hasFocus is false on end editing"
218 | )
219 | }
220 | }
221 |
222 | func testTextFieldShouldReturn() async {
223 | await withMainSerialExecutor {
224 | sut.becomeFirstResponder()
225 | let shouldReturn = sut.delegate?.textFieldShouldReturn?(sut)
226 |
227 | await Task.megaYield()
228 |
229 | XCTAssertEqual(shouldReturn, true)
230 | XCTAssertEqual(textSetValues.count, 1)
231 | XCTAssertEqual(unformattedTextSetValues.count, 1)
232 | XCTAssertEqual(inputAmountSetValues.count, 1)
233 | XCTAssertTrue(textFieldConfigurationReceivedValues.isEmpty)
234 | XCTAssertTrue(onEditingChangedReceivedValues.isEmpty)
235 | XCTAssertEqual(onCommitCallsCount, 1)
236 | XCTAssertFalse(sut.isFirstResponder)
237 | XCTAssertTrue(hasFocusSetValues.isEmpty)
238 | }
239 | }
240 |
241 | func testUpdateConfigurationAndUpdateTextIfNeeded() async {
242 | await withMainSerialExecutor {
243 | formatter.hasDecimals = false
244 | sut.updateConfigurationIfNeeded(
245 | latest: .makeFixture(
246 | textBinding: Binding(
247 | get: { "56" },
248 | set: { text in
249 | self.textSetValues.append(text)
250 | }
251 | ),
252 | unformattedTextBinding: Binding(
253 | get: { "unformatted" },
254 | set: { text in
255 | self.unformattedTextSetValues.append(text)
256 | }
257 | ),
258 | inputAmountBinding: Binding(
259 | get: { .zero },
260 | set: { value in
261 | self.inputAmountSetValues.append(value)
262 | }
263 | ),
264 | formatter: Binding(
265 | get: { self.formatter },
266 | set: {_ in }
267 | )
268 | )
269 | )
270 |
271 | sut.updateTextIfNeeded()
272 |
273 | await Task.megaYield()
274 |
275 | XCTAssertEqual(
276 | textSetValues,
277 | [
278 | "$0.34",
279 | "$56"
280 | ]
281 | )
282 | XCTAssertEqual(
283 | unformattedTextSetValues,
284 | [
285 | "0.34",
286 | "56"
287 | ]
288 | )
289 | XCTAssertEqual(
290 | inputAmountSetValues,
291 | [
292 | 0.34,
293 | 56.0
294 | ]
295 | )
296 | XCTAssertTrue(textFieldConfigurationReceivedValues.isEmpty)
297 | XCTAssertTrue(onEditingChangedReceivedValues.isEmpty)
298 | XCTAssertEqual(onCommitCallsCount, 0)
299 | XCTAssertTrue(hasFocusSetValues.isEmpty)
300 | }
301 | }
302 |
303 | func testUpdateConfigurationWithDifferentFormatterInstances() async {
304 | await withMainSerialExecutor {
305 | let callUpdateConfigurationIfNeeded: (CurrencyFormatter) -> Void = { [unowned self] formatter in
306 | self.sut.updateConfigurationIfNeeded(
307 | latest: .makeFixture(
308 | textBinding: Binding(
309 | get: { "56" },
310 | set: { text in
311 | self.textSetValues.append(text)
312 | }
313 | ),
314 | unformattedTextBinding: Binding(
315 | get: { "unformatted" },
316 | set: { text in
317 | self.unformattedTextSetValues.append(text)
318 | }
319 | ),
320 | inputAmountBinding: Binding(
321 | get: { .zero },
322 | set: { value in
323 | self.inputAmountSetValues.append(value)
324 | }
325 | ),
326 | formatter: .init(
327 | get: { formatter },
328 | set: { _ in }
329 | )
330 | )
331 | )
332 | }
333 |
334 | formatter.hasDecimals = false
335 | callUpdateConfigurationIfNeeded(self.formatter)
336 | sut.updateTextIfNeeded()
337 |
338 | await Task.megaYield()
339 |
340 | let otherFormatter = CurrencyFormatter {
341 | $0.currency = .euro
342 | $0.locale = CurrencyLocale.german
343 | $0.hasDecimals = false
344 | }
345 | callUpdateConfigurationIfNeeded(otherFormatter)
346 | sut.updateTextIfNeeded()
347 |
348 | await Task.megaYield()
349 |
350 | XCTAssertEqual(
351 | textSetValues,
352 | [
353 | "$0.34",
354 | "$56",
355 | "56 €"
356 | ]
357 | )
358 |
359 | let yetAnotherFormatter = CurrencyFormatter {
360 | $0.currency = .brazilianReal
361 | $0.locale = CurrencyLocale.portugueseBrazil
362 | $0.hasDecimals = false
363 | }
364 | callUpdateConfigurationIfNeeded(yetAnotherFormatter)
365 | sut.updateTextIfNeeded()
366 |
367 | await Task.megaYield()
368 |
369 | XCTAssertEqual(
370 | textSetValues,
371 | [
372 | "$0.34",
373 | "$56",
374 | "56 €",
375 | "R$ 56"
376 | ]
377 | )
378 |
379 | XCTAssertEqual(
380 | sut.text,
381 | "R$ 56"
382 | )
383 | }
384 | }
385 |
386 | func testUpdateTextIfNeededWhenFormatterChangesAndStatefulTextBinding() async {
387 | await withMainSerialExecutor {
388 | assembleSut()
389 |
390 | formatter = CurrencyFormatter {
391 | $0.currency = .euro
392 | $0.locale = CurrencyLocale.german
393 | $0.hasDecimals = false
394 | }
395 |
396 | let configuration = CurrencyTextFieldConfiguration.makeFixture(
397 | textBinding: $viewModel.text,
398 | formatter: .init(
399 | get: { self.formatter },
400 | set: { _ in }
401 | )
402 | )
403 |
404 | let callUpdateFunctions: (CurrencyTextFieldConfiguration) -> Void = { [unowned self] configuration in
405 | self.sut.updateConfigurationIfNeeded(latest: configuration)
406 | self.sut.updateTextIfNeeded()
407 | }
408 |
409 | callUpdateFunctions(configuration)
410 |
411 | await Task.megaYield()
412 |
413 | XCTAssertEqual(sut.text, "56 €")
414 | XCTAssertEqual(viewModel.text, "56 €")
415 |
416 | formatter.currency = .dollar
417 | formatter.locale = CurrencyLocale.englishUnitedStates
418 | callUpdateFunctions(configuration)
419 | await Task.megaYield()
420 |
421 | XCTAssertEqual(sut.text, "$56")
422 | XCTAssertEqual(viewModel.text, "$56")
423 |
424 | formatter.currency = .brazilianReal
425 | formatter.locale = CurrencyLocale.portugueseBrazil
426 | callUpdateFunctions(configuration)
427 | await Task.megaYield()
428 |
429 | XCTAssertEqual(sut.text, "R$ 56")
430 | XCTAssertEqual(viewModel.text, "R$ 56")
431 | }
432 | }
433 | }
434 |
--------------------------------------------------------------------------------
/Tests/SwiftUISnapshotTests/CurrencyTextFieldSnapshotTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyTextFieldSnapshotTests.swift
3 | //
4 | //
5 | // Created by Marino Felipe on 24.04.21.
6 | //
7 |
8 | import Combine
9 | import SwiftUI
10 | import XCTest
11 |
12 | import CurrencyFormatter
13 | import CurrencyTextFieldTestSupport
14 |
15 | import SnapshotTesting
16 |
17 | @testable import CurrencyTextField
18 |
19 | @available(iOS 13.0, *)
20 | final class CurrencyTextFieldSnapshotTests: XCTestCase {
21 | final class FakeViewModel: ObservableObject {
22 | @Published
23 | var text: String = ""
24 | }
25 |
26 | @ObservedObject
27 | private var fakeViewModel = FakeViewModel()
28 |
29 | func test() {
30 | fakeViewModel.text = "2345569"
31 | CurrencyFormatter.TestCase.allCases.forEach { testCase in
32 | let sut = CurrencyTextField(
33 | configuration: .makeFixture(
34 | textBinding: $fakeViewModel.text,
35 | formatter: .init(
36 | get: { testCase.formatter },
37 | set: { _ in }
38 | )
39 | )
40 | ).frame(width: 300, height: 80)
41 |
42 | assertSnapshot(
43 | matching: sut,
44 | as: .image,
45 | named: "\(testCase.rawValue)"
46 | )
47 | }
48 | }
49 |
50 | func testWithCustomTextFiledConfiguration() {
51 | fakeViewModel.text = "2345569"
52 | let sut = CurrencyTextField(
53 | configuration: .makeFixture(
54 | textBinding: $fakeViewModel.text,
55 | formatter: .init(
56 | get: { CurrencyFormatter.TestCase.withDecimals.formatter },
57 | set: { _ in }
58 | ),
59 | textFieldConfiguration: { textField in
60 | textField.borderStyle = .roundedRect
61 | textField.textAlignment = .center
62 | textField.font = .preferredFont(forTextStyle: .body)
63 | textField.textColor = .brown
64 | }
65 | )
66 | ).frame(width: 300, height: 80)
67 |
68 | assertSnapshot(
69 | matching: sut,
70 | as: .image(precision: 0.99)
71 | )
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.germanEuro.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/CurrencyText/03d08a028a32547e26f50751660c7c0b3cfa3279/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.germanEuro.png
--------------------------------------------------------------------------------
/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.noDecimals.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/CurrencyText/03d08a028a32547e26f50751660c7c0b3cfa3279/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.noDecimals.png
--------------------------------------------------------------------------------
/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.withDecimals.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/CurrencyText/03d08a028a32547e26f50751660c7c0b3cfa3279/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.withDecimals.png
--------------------------------------------------------------------------------
/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.withMinMaxValues.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/CurrencyText/03d08a028a32547e26f50751660c7c0b3cfa3279/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.withMinMaxValues.png
--------------------------------------------------------------------------------
/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.yenJapanese.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/CurrencyText/03d08a028a32547e26f50751660c7c0b3cfa3279/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/test.yenJapanese.png
--------------------------------------------------------------------------------
/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/testWithCustomTextFiledConfiguration.1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/CurrencyText/03d08a028a32547e26f50751660c7c0b3cfa3279/Tests/SwiftUISnapshotTests/__Snapshots__/CurrencyTextFieldSnapshotTests/testWithCustomTextFiledConfiguration.1.png
--------------------------------------------------------------------------------
/Tests/UITextFieldDelegate/CurrencyTextFieldDelegateTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyTextFieldDelegateTests.swift
3 | // CurrencyTextFieldTests
4 | //
5 | // Created by Felipe Lefèvre Marino on 3/21/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CurrencyUITextFieldDelegate
11 | @testable import CurrencyFormatter
12 |
13 | @MainActor
14 | final class CurrencyTextFieldDelegateTests: XCTestCase {
15 | // MARK: - Properties
16 |
17 | // system under test
18 | private var delegate: CurrencyUITextFieldDelegate!
19 |
20 | private var textField: UITextField! = .init()
21 | private var formatter: CurrencyFormatter!
22 | private var passthroughDelegateMock: PassthroughDelegateMock! = .init()
23 |
24 | // MARK: - Life cycle
25 |
26 | override func setUp() {
27 | super.setUp()
28 |
29 | formatter = CurrencyFormatter {
30 | $0.currency = .dollar
31 | $0.locale = CurrencyLocale.englishUnitedStates
32 | $0.hasDecimals = true
33 | }
34 |
35 | delegate = CurrencyUITextFieldDelegate(formatter: formatter)
36 | delegate.clearsWhenValueIsZero = true
37 |
38 | textField.delegate = delegate
39 | textField.keyboardType = .numberPad
40 | }
41 |
42 | override func tearDown() {
43 | textField = nil
44 | delegate = nil
45 | formatter = nil
46 | passthroughDelegateMock = nil
47 |
48 | super.tearDown()
49 | }
50 |
51 | // MARK: - Tests - Init
52 |
53 | func testInit() {
54 | XCTAssertNotNil(delegate.formatter, "formatter should not be nil")
55 | XCTAssertNil(delegate.passthroughDelegate, "passthroughDelegate should be nil")
56 |
57 | delegate = CurrencyUITextFieldDelegate()
58 | XCTAssertNotNil(delegate.formatter, "formatter should not be nil")
59 | }
60 |
61 | // MARK: - Tests - Max digits
62 |
63 | func testMaxDigitsCount() {
64 | formatter.maxIntegers = 5
65 |
66 | for _ in 0...20 {
67 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: textField.textLength, length: 0), replacementString: "1")
68 | }
69 |
70 | if let onlyDigitsText = textField.text?.numeralFormat() {
71 | XCTAssertEqual(onlyDigitsText.count, formatter.maxDigitsCount, "text count should not be more than maxDigitsCount")
72 | }
73 | }
74 |
75 | // MARK: - Tests - Deletion
76 |
77 | func testDeleting() {
78 | // simulates keyboard actions - expected to set textField text to "$11,111,111.11"
79 | for _ in 0...9 {
80 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: textField.textLength, length: 0), replacementString: "1")
81 | }
82 |
83 | textField.sendDeleteKeyboardAction()
84 | textField.sendDeleteKeyboardAction()
85 |
86 | XCTAssertEqual(textField.text, formatter.currencySymbol + "111,111.11", "deleting digits should keep formating and count as expected")
87 |
88 | // removing beyond decimal separator
89 | textField.text = "$0.19"
90 |
91 | textField.sendDeleteKeyboardAction()
92 |
93 | XCTAssertEqual(textField.text, formatter.currencySymbol + "0.01", "deleting digits should keep formating and count as expected")
94 |
95 | textField.sendDeleteKeyboardAction()
96 | XCTAssertEqual(textField.text, formatter.currencySymbol + "0.00", "deleting digits should keep formating and count as expected")
97 | }
98 |
99 | func testDeletingNotAtEndIndex() {
100 | for position in 0...9 {
101 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: textField.textLength, length: 0), replacementString: String(position))
102 | }
103 |
104 | // inputed string = $1,234,567.89, deleting location at 4 = "3"
105 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: 4, length: 1), replacementString: "")
106 | XCTAssertEqual(textField.text, formatter.currencySymbol + "124,567.89", "deleting digits should keep formating and count as expected")
107 | }
108 |
109 | func testSelectingAndDeletingAll() {
110 | for position in 0...9 {
111 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: textField.textLength, length: 0), replacementString: String(position))
112 | }
113 |
114 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: 0, length: textField.textLength), replacementString: "")
115 |
116 | XCTAssertNotNil(textField)
117 | if let text = textField.text {
118 | XCTAssertEqual(text.count, 0)
119 | }
120 | }
121 |
122 | // MARK: - Tests - Input/paste
123 |
124 | func testAddingNegativeSymbol() {
125 | // testing with numeric pad
126 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: textField.textLength, length: 0), replacementString: .negativeSymbol)
127 |
128 | XCTAssertEqual(textField.text, "", "after first input the text should be correctly formated")
129 |
130 | // testing with numbersAndPunctuation pad
131 | textField.keyboardType = .numbersAndPunctuation
132 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: textField.textLength, length: 0), replacementString: .negativeSymbol)
133 | XCTAssertEqual(textField.text, .negativeSymbol, "after first input the text should be correctly formated")
134 |
135 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: textField.textLength, length: 0), replacementString: "3451")
136 | XCTAssertEqual(textField.text, .negativeSymbol + formatter.currencySymbol + "34.51")
137 |
138 | //delete negative symbol
139 | textField.sendDeleteKeyboardAction(at: 0)
140 | XCTAssertEqual(textField.text, formatter.currencySymbol + "34.51")
141 |
142 | //try to add negative symbol to non negative value
143 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: .negativeSymbol)
144 | XCTAssertEqual(textField.text, .negativeSymbol + formatter.currencySymbol + "34.51")
145 |
146 | // add negative symbol with range selected - should be added when range contains first index
147 | textField.sendDeleteKeyboardAction(at: 0)
148 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: 0, length: 3), replacementString: .negativeSymbol)
149 | XCTAssertEqual(textField.text, .negativeSymbol + formatter.currencySymbol + "34.51")
150 |
151 | // add negative symbol with range selected - should not be added when range does contains first index
152 | textField.sendDeleteKeyboardAction(at: 0)
153 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: 1, length: 2), replacementString: .negativeSymbol)
154 | XCTAssertEqual(textField.text, formatter.currencySymbol + "34.51")
155 | }
156 |
157 | func testFormatAfterFirstNumber() {
158 | delegate.textField(textField, shouldChangeCharactersIn: NSRange(location: textField.textLength, length: 0), replacementString: "1")
159 |
160 | XCTAssertEqual(textField.text, formatter.currencySymbol + "0.01", "after first input the text should be correctly formated")
161 | }
162 |
163 | private func sendTextFieldChanges(at range: NSRange, inputString: String) {
164 | delegate.textField(textField, shouldChangeCharactersIn: range, replacementString: inputString)
165 | }
166 |
167 | func testPastingNonNumeralValues() {
168 | //without content
169 | sendTextFieldChanges(at: NSRange(location: textField.textLength, length: 0), inputString: ",,,,")
170 |
171 | XCTAssertEqual(textField.text, "")
172 |
173 | //middle of the string - at location 4 = "5"
174 | textField.text = formatter.currencySymbol + "3,456.45"
175 | sendTextFieldChanges(at: NSRange(location: 4, length: 4), inputString: ",,,,")
176 |
177 | XCTAssertEqual(textField.text, formatter.currencySymbol + "3,456.45")
178 |
179 | //end of the string
180 | sendTextFieldChanges(at: NSRange(location: textField.textLength, length: 4), inputString: ",,,,")
181 | XCTAssertEqual(textField.text, formatter.currencySymbol + "3,456.45")
182 | }
183 |
184 | func testInputingNotAtEndIndex() {
185 | for position in 0...9 {
186 | sendTextFieldChanges(at: NSRange(location: textField.textLength, length: 0), inputString: String(position))
187 | }
188 |
189 | // expected string = "$1,234,567.89". input at location 3 = "2"
190 | sendTextFieldChanges(at: NSRange(location: 3, length: 2), inputString: "15")
191 |
192 | XCTAssertEqual(textField.text, formatter.currencySymbol + "1,154,567.89", "deleting digits should keep formating and count as expected")
193 | }
194 |
195 | func testInputingNotAtEndIndexSurpassingIntegersLimit() {
196 | formatter.maxIntegers = 7
197 |
198 | // value should be adjusted overlapping digits to the right once a the range had a comma that was changed by a new number. So this numbers gets the next space at right and so on with the next numbers, until the last decimal digit is overlapped
199 | for position in 0...9 {
200 | sendTextFieldChanges(at: NSRange(location: textField.textLength, length: 0), inputString: String(position))
201 | }
202 |
203 | // expected string = "$1,234,567.89". input at location 5 = "4"
204 | sendTextFieldChanges(at: NSRange(location: 5, length: 3), inputString: "150")
205 |
206 | XCTAssertEqual(textField.text, formatter.currencySymbol + "1,231,506.78", "deleting digits should keep formating and count as expected")
207 | }
208 |
209 | // MARK: - Tests - End of editing
210 |
211 | func testClearsWhenValueIsZero() {
212 | delegate.clearsWhenValueIsZero = true
213 |
214 | for _ in 0...2 {
215 | sendTextFieldChanges(at: NSRange(location: textField.textLength, length: 0), inputString: "0")
216 | }
217 |
218 | delegate.textFieldShouldEndEditing(textField)
219 | XCTAssertTrue(textField.textLength == 0, "Text field text count should be zero because hasAutoclear is enabled")
220 |
221 |
222 | delegate.clearsWhenValueIsZero = false
223 | for _ in 0...2 {
224 | sendTextFieldChanges(at: NSRange(location: textField.textLength, length: 0), inputString: "0")
225 | }
226 |
227 | delegate.textFieldShouldEndEditing(textField)
228 | XCTAssertFalse(textField.textLength == 0, "Text field text count should not be zero when autoclear is disabled")
229 | }
230 |
231 | // MARK: - Tests - Cursor
232 |
233 | func testSelectedTextRange() {
234 | sendTextFieldChanges(at: NSRange(location: textField.textLength, length: 0), inputString: "3")
235 |
236 | XCTAssertEqual(textField.selectedTextRange, textField.textRange(from: textField.endOfDocument, to: textField.endOfDocument), "After first input selected range should be endOfDocument")
237 |
238 | textField.text = "3523623623"
239 | textField.selectedTextRange = textField.textRange(from: textField.position(from: textField.endOfDocument, offset: -5)!, to: textField.position(from: textField.endOfDocument, offset: -5)!)
240 |
241 | sendTextFieldChanges(at: NSRange(location: textField.textLength, length: 0), inputString: "3")
242 | XCTAssertEqual(textField.selectedTextRangeOffsetFromEnd, -5, "Selected text range offset from end should not change after inputs")
243 | }
244 |
245 | // MARK: - Tests - Passthrough delegate
246 |
247 | func testSettingPassthroughDelegate() {
248 | delegate.passthroughDelegate = passthroughDelegateMock
249 |
250 | XCTAssert(delegate.passthroughDelegate === passthroughDelegateMock, "It has the correct passthroughDelegate")
251 | XCTAssert(delegate._passthroughDelegate === passthroughDelegateMock, "It has the correct private passthroughDelegate")
252 | }
253 |
254 | // MARK: - Tests - UITextFieldDelegate
255 |
256 | func testTextFieldShouldBeginEditing() {
257 | delegate.passthroughDelegate = passthroughDelegateMock
258 | delegate.textFieldShouldBeginEditing(textField)
259 |
260 | XCTAssertTrue(passthroughDelegateMock.didCallTextFieldShouldBeginEditing, "It has called the correct passthrough delegate mock function")
261 | XCTAssertEqual(passthroughDelegateMock.lastTextField, textField, "It has passed the correct text field")
262 | }
263 |
264 | func testTextFieldDidBeginEditing() {
265 | delegate.passthroughDelegate = passthroughDelegateMock
266 | delegate.textFieldDidBeginEditing(textField)
267 |
268 | XCTAssertTrue(passthroughDelegateMock.didCallTextFieldDidBeginEditing, "It has called the correct passthrough delegate mock function")
269 | XCTAssertEqual(passthroughDelegateMock.lastTextField, textField, "It has passed the correct text field")
270 | }
271 |
272 | func testTextFieldShouldEndEditing() {
273 | delegate.passthroughDelegate = passthroughDelegateMock
274 | delegate.textFieldShouldEndEditing(textField)
275 |
276 | XCTAssertTrue(passthroughDelegateMock.didCallTextFieldShouldEndEditing, "It has called the correct passthrough delegate mock function")
277 | XCTAssertEqual(passthroughDelegateMock.lastTextField, textField, "It has passed the correct text field")
278 | }
279 |
280 | func testTextFieldDidEndEditing() {
281 | delegate.passthroughDelegate = passthroughDelegateMock
282 | delegate.textFieldDidEndEditing(textField)
283 |
284 | XCTAssertTrue(passthroughDelegateMock.didCallTextFieldDidEndEditing, "It has called the correct passthrough delegate mock function")
285 | XCTAssertEqual(passthroughDelegateMock.lastTextField, textField, "It has passed the correct text field")
286 | }
287 |
288 | func testTextFieldShouldClear() {
289 | delegate.passthroughDelegate = passthroughDelegateMock
290 | delegate.textFieldShouldClear(textField)
291 |
292 | XCTAssertTrue(passthroughDelegateMock.didCallTextFieldShouldClear, "It has called the correct passthrough delegate mock function")
293 | XCTAssertEqual(passthroughDelegateMock.lastTextField, textField, "It has passed the correct text field")
294 | }
295 |
296 | func testTextFieldShouldReturn() {
297 | delegate.passthroughDelegate = passthroughDelegateMock
298 | delegate.textFieldShouldReturn(textField)
299 |
300 | XCTAssertTrue(passthroughDelegateMock.didCallTextFieldShouldReturn, "It has called the correct passthrough delegate mock function")
301 | XCTAssertEqual(passthroughDelegateMock.lastTextField, textField, "It has passed the correct text field")
302 | }
303 |
304 | func testTextFieldShouldChangeCharacters() {
305 | let expectedRange = NSRange(location: textField.textLength, length: 0)
306 | let expectedReplacementString = "string"
307 |
308 | delegate.passthroughDelegate = passthroughDelegateMock
309 | delegate.textField(textField,
310 | shouldChangeCharactersIn: expectedRange,
311 | replacementString: expectedReplacementString)
312 |
313 | XCTAssertTrue(passthroughDelegateMock.didCallTextFieldShouldChangeCharacters, "It has called the correct passthrough delegate mock function")
314 | XCTAssertEqual(passthroughDelegateMock.lastTextField, textField, "It has passed the correct text field")
315 | XCTAssertEqual(passthroughDelegateMock.lastRange, expectedRange, "It has passed the correct range")
316 | XCTAssertEqual(passthroughDelegateMock.lastReplacementString, expectedReplacementString,
317 | "It has passed the correct replacement string")
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/Tests/UITextFieldDelegate/Mocks/PassthroughDelegateMock.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyTextFieldDelegateTests.swift
3 | // CurrencyTextFieldTests
4 | //
5 | // Created by Felipe Lefèvre Marino on 3/15/20.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | @testable import CurrencyUITextFieldDelegate
11 | @testable import CurrencyFormatter
12 |
13 | final class PassthroughDelegateMock: NSObject, UITextFieldDelegate {
14 |
15 | private(set) var lastTextField: UITextField?
16 | private(set) var didCallTextFieldShouldBeginEditing: Bool = false
17 | private(set) var didCallTextFieldDidBeginEditing: Bool = false
18 | private(set) var didCallTextFieldShouldEndEditing: Bool = false
19 | private(set) var didCallTextFieldDidEndEditing: Bool = false
20 | private(set) var didCallTextFieldShouldClear: Bool = false
21 | private(set) var didCallTextFieldShouldReturn: Bool = false
22 | private(set) var didCallTextFieldShouldChangeCharacters: Bool = false
23 | private(set) var lastRange: NSRange?
24 | private(set) var lastReplacementString: String?
25 |
26 | @discardableResult
27 | func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
28 | didCallTextFieldShouldBeginEditing = true
29 | lastTextField = textField
30 |
31 | return true
32 | }
33 |
34 | public func textFieldDidBeginEditing(_ textField: UITextField) {
35 | didCallTextFieldDidBeginEditing = true
36 | lastTextField = textField
37 | }
38 |
39 | @discardableResult
40 | func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
41 | didCallTextFieldShouldEndEditing = true
42 | lastTextField = textField
43 |
44 | return true
45 | }
46 |
47 | func textFieldDidEndEditing(_ textField: UITextField) {
48 | didCallTextFieldDidEndEditing = true
49 | lastTextField = textField
50 | }
51 |
52 | func textFieldShouldClear(_ textField: UITextField) -> Bool {
53 | didCallTextFieldShouldClear = true
54 | lastTextField = textField
55 |
56 | return true
57 | }
58 |
59 | func textFieldShouldReturn(_ textField: UITextField) -> Bool {
60 | didCallTextFieldShouldReturn = true
61 | lastTextField = textField
62 |
63 | return true
64 | }
65 |
66 | func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
67 | didCallTextFieldShouldChangeCharacters = true
68 | lastTextField = textField
69 | lastRange = range
70 | lastReplacementString = string
71 |
72 | return true
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Tests/UITextFieldDelegate/UITextField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITextField.swift
3 | // ExampleTests
4 | //
5 | // Created by Felipe Lefèvre Marino on 12/29/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UITextField {
12 |
13 | var textLength: Int {
14 | return text?.count ?? 0
15 | }
16 |
17 | func sendDeleteKeyboardAction(at location: Int? = nil) {
18 | let _ = delegate?.textField?(self, shouldChangeCharactersIn: NSRange(location: location ?? textLength, length: 1), replacementString: "")
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Tests/UITextFieldDelegate/UITextFieldTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITextFieldTests.swift
3 | // ExampleTests
4 | //
5 | // Created by Felipe Lefèvre Marino on 12/27/18.
6 | // Copyright © 2018 Felipe Lefèvre Marino. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CurrencyUITextFieldDelegate
11 |
12 | class UITextFieldTests: XCTestCase {
13 |
14 | var textField: UITextField!
15 |
16 | override func setUp() {
17 | super.setUp()
18 | textField = UITextField()
19 | }
20 |
21 | override func tearDown() {
22 | textField = nil
23 | super.tearDown()
24 | }
25 |
26 | func testUpdatingSelectedTextRange() {
27 | textField.text?.append("352450260")
28 |
29 | textField.updateSelectedTextRange(lastOffsetFromEnd: 0)
30 | XCTAssertEqual(textField.selectedTextRange?.end, textField.position(from: textField.endOfDocument, offset: 0))
31 |
32 | textField.updateSelectedTextRange(lastOffsetFromEnd: -5)
33 | XCTAssertEqual(textField.selectedTextRange?.end, textField.position(from: textField.endOfDocument, offset: -5))
34 | }
35 |
36 | func testGettingOffsetFromEnd() {
37 | textField.text?.append("450")
38 |
39 | var position = textField.position(from: textField.endOfDocument, offset: 0)
40 | textField.selectedTextRange = textField.textRange(from: position!, to: position!)
41 |
42 | XCTAssertEqual(textField.selectedTextRangeOffsetFromEnd, 0)
43 |
44 | textField.text?.append("35")
45 | position = textField.position(from: textField.endOfDocument, offset: -4)
46 | textField.selectedTextRange = textField.textRange(from: position!, to: position!)
47 | XCTAssertEqual(textField.selectedTextRangeOffsetFromEnd, -4)
48 |
49 | textField.text?.removeLast()
50 | position = textField.position(from: textField.endOfDocument, offset: -1)
51 | textField.selectedTextRange = textField.textRange(from: position!, to: position!)
52 | XCTAssertEqual(textField.selectedTextRangeOffsetFromEnd, -1)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/UICurrencyTextField.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = "UICurrencyTextField"
3 | s.version = "1.0.1"
4 | s.summary = "Formats text field text as currency"
5 |
6 | s.homepage = "https://github.com/marinofelipe/UICurrencyTextField"
7 | s.license = { :type => 'MIT', :file => 'LICENSE' }
8 | s.author = { "Felipe Lefèvre Marino" => "felipemarino91@gmail.com" }
9 |
10 | s.source = { :git => "https://github.com/marinofelipe/UICurrencyTextField.git", :tag => "#{s.version}" }
11 |
12 | s.ios.deployment_target = '8.0'
13 |
14 | s.swift_version = "5.0"
15 | s.source_files = "Sources/**/*.swift"
16 | end
17 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | codecov:
2 | status:
3 | project:
4 | default:
5 | target: 95%
6 | threshold: 5%
7 | require_ci_to_pass: false
--------------------------------------------------------------------------------
/documentation/Documentation.md:
--------------------------------------------------------------------------------
1 | # Documentation
2 |
3 | - [Introduction to `CurrencyFormatter`](#currencyformatter)
4 | - [Basic Setup](#basics)
5 | - [`Currency and locale` - easily defining style](#currencyandlocale)
6 | - [`Locale` - setting currency's locale](#locale)
7 | - [`Currency` - how to choose a specific currency from it's name](#currency)
8 | - [Advanced setup](#advancedsetup)
9 | - [UIKit](#uikit)
10 | - [The `CurrencyTextFieldDelegate`](#delegate)
11 | - [Setting your text field to format the inputs](#setting)
12 | - [Using the passthrough delegate](#passthrough)
13 | - [Allowing users to input `negative values`](#negative)
14 | - [All properties of `CurrencyFormatter`](#properties)
15 | - [SwiftUI](#swiftui)
16 | - [`CurrencyTextField` - how to configure and use it](#currencytextfield)
17 | - [`CurrencyTextField` - focus](#currencytextfield_focus)
18 | - [Why do I have to import UIKit?](#whyuikit)
19 | - [Why is it only available for iOS?](#whyonlyios)
20 |
21 |
22 |
23 | ## Introduction to `CurrencyFormatter`
24 |
25 | `CurrencyFormatter` is `CurrencyText`'s core. It is a wrapper around `NumberFormatter` with currency number style, that provides API for formatting content as/from currency, and can be used both in isolation, injected into `CurrencyUITextFieldDelegate` for UIKit text field, or passed in to a `CurrencyTextField` in `SwiftUI`.
26 |
27 |
28 |
29 | ### Basic setup
30 |
31 | Creating a `CurrencyFormatter` instance is pretty simple; you can use the builder pattern approach where the init class require a callback in which the self instance is passed, allowing you to configure your properties by keeping the code clean and readable ([Inspired by SwiftRichString](https://github.com/malcommac/SwiftRichString)):
32 |
33 | ```swift
34 | let formatter = CurrencyFormatter {
35 | $0.currency = .euro
36 | // set any other attribute available on CurrencyFormatter public API
37 | }
38 |
39 | let formattedString = formatter.string(from: 30.0) //€30.00
40 | ```
41 |
42 |
43 |
44 | ### `Currency and locale` - defining formatting style
45 |
46 | To change the currency style (symbol, formatting, separators) you must make use of `.currency` and `.locale` properties.
47 | By default such properties are configured based on the user's system configurations, deriving the currency format from the user current locale (`Locale.autoUpdating`).
48 | Therefore only set these properties in cases where you want granular control over how the currency is formatted, e.g. always in `currency.dollar` with `CurrencyLocale.englishUnitedStates`.
49 |
50 |
51 |
52 | #### `Locale` - setting currency's locale
53 | ###### All locales were extracted from [jacobbubu - ioslocaleidentifiers](https://gist.github.com/jacobbubu/1836273)
54 | `CurrencyLocale` is a String backed enum that wraps all available Locale identifiers.
55 | `CurrencyFormatter`'s `locale` property can be set by passing a common system Locale or one of CurrencyLocale's cases, such as .italian, .danish or .chinese.
56 | Note that you can set locale and compose it with a different currency of your choice, what is going to change is generally only the currency symbol.
57 |
58 | ```swift
59 | public enum CurrencyLocale: String, LocaleConvertible {
60 |
61 | case current = "current"
62 | case autoUpdating = "currentAutoUpdating"
63 |
64 | case afrikaans = "af"
65 | case afrikaansNamibia = "af_NA"
66 | case afrikaansSouthAfrica = "af_ZA"
67 | //...
68 | }
69 | ```
70 |
71 |
72 |
73 | #### `Currency` - how to choose a specific currency from it's name
74 | ###### encapsulates the cases of [ISO 4217 international standard for currency codes](https://www.iso.org/iso-4217-currency-codes.html)
75 | `Currency` type is also a String backed enum that matches all available currency codes, while keeping a type safe / simple API for setting currencies (e.g. _.euro, .dollar or .brazilianReal_).
76 |
77 | ```swift
78 | public enum Currency: String {
79 | case afghani = "AFN",
80 | algerianDinar = "DZD",
81 | argentinePeso = "ARS"
82 | //...
83 | }
84 | ```
85 |
86 | Note that defining currency does not always goes as planned, because the most part of the format generally changes accordingly to user locale. For example, setting .euro as currency but with default user locale (Brazil), has the euro's currency symbol with separators and remaining style as used in Brazil.
87 |
88 | ```swift
89 | let formatter = CurrencyFormatter {
90 | $0.currency = .dollar
91 | $0.locale = CurrencyLocale.englishUnitedStates
92 | }
93 |
94 | let formattedString = formatter.string(from: 30.0) //$30.00
95 | ```
96 |
97 | Therefor prioritize setting locale in case you have a custom setup, and only update currency whenever you want to have full control of the format configuration.
98 |
99 |
100 |
101 | ### Advanced setup
102 |
103 | For those cases where your design requires a custom presentation don't worry because the formatter is heavily customizable.
104 | For example decimals can be removed, maximum and minimum allowed values can be set, grouping size can be customized or even a hole new currency symbol can be defined. It is all up to you and your use case:
105 |
106 | ```swift
107 | let formatter = CurrencyFormatter {
108 | $0.hasDecimals = false
109 | $0.maxValue = 999999
110 | $0.groupingSize = 2
111 | $0.groupingSeparator = ";"
112 | $0.currencySymbol = "💶"
113 | }
114 |
115 | let formattedString = formatter.string(from: 100000000) //💶99;99;99
116 | ```
117 |
118 |
119 |
120 | ## UIKit
121 |
122 | The UIKit library provides an _easy to use_ and _extendable_ `UITextFieldDelegate` that can be plugged to _any_ text field without the need to use a specific `UITextField` subclass 😉.
123 |
124 |
125 |
126 | ## The `CurrencyTextFieldDelegate` - formatting user input as currency
127 |
128 | `CurrencyTextFieldDelegate` is a type that inherits from `UITextFieldDelegate`, and provide a simple interface to configure how the inputs are configured as currency.
129 |
130 |
131 |
132 | ### Setting your text field to format the inputs
133 | To start formatting user's input as currency, you need to initialize a `CurrencyTextFieldDelegate` instance passing in a currency formatter configured as you wish, and then set it as the text field's delegate.
134 |
135 | ```Swift
136 | let currencyFormatter = CurrencyFormatter()
137 | textFieldDelegate = CurrencyUITextFieldDelegate(formatter: currencyFormatter)
138 | textFieldDelegate.clearsWhenValueIsZero = true
139 |
140 | textField.delegate = textFieldDelegate
141 | ```
142 |
143 | Just by setting a currency text field delegate object to your text field, with given formatter behavior, the user inputs are going to be formatted as expected.
144 |
145 |
146 |
147 | ### Using the passthrough delegate
148 | The `passthroughDelegate` property availble on `CurrencyTextFieldDelegate` instances, can be used to
149 | listen and pottentially handle `UITextFieldDelegate events` that are sent to `CurrencyUITextFieldDelegate`.
150 | It can be useful to intercept the delegate calls when `e.g.` for when tracking analytics events.
151 |
152 | But be **aware** and **make sure** the implementation of this object _does not wrongly interfere with currency formatting_, `e.g.` by returning `false` on`textField(textField:shouldChangeCharactersIn:replacementString:)` no currency formatting is done.
153 |
154 | ```Swift
155 | let currencyTextFieldDelegate = CurrencyUITextFieldDelegate(formatter: currencyFormatter)
156 | currencyTextFieldDelegate.passthroughDelegate = selfTextFieldDelegateListener
157 |
158 | textField.delegate = currencyTextFieldDelegate
159 |
160 | // all call to `currencyTextFieldDelegate` `UITextField` delegate methods will be forwarded to `selfTextFieldDelegateListener`.
161 | ```
162 |
163 |
164 |
165 | ### Allowing users to input `negative values`
166 | If you want your users to be able to input negative values just set textField's `keyboardType` to `.numbersAndPunctuation`, so whenever users tap the negative symbol it will be correctly presented and handled.
167 |
168 | ```Swift
169 | textField.keyboardType = .numbersAndPunctuation
170 |
171 | // users inputs "-3456"
172 | // -R$ 34.56
173 | ```
174 |
175 |
176 |
177 | ## Properties available via `CurrencyFormatter` class
178 | The following properties are available:
179 |
180 | | PROPERTY | TYPE | DESCRIPTION |
181 | |-------------------------------|---------------------------------------|--------------------------------------------|
182 | | locale | `LocaleConvertible` | Locale of the currency |
183 | | currency | `Currency` | Currency used to format |
184 | | currencySymbol | `String` | String shown as currency symbol |
185 | | showCurrencySymbol | `Bool` | Show/hide currency symbol |
186 | | minValue | `Double?` | The lowest number allowed as input |
187 | | maxValue | `Double?` | The highest number allowed as input |
188 | | decimalDigits | `Int` | The number of decimal digits shown |
189 | | hasDecimals | `Bool?` | Decimal digits are shown or not |
190 | | decimalSeparator | `String` | Text used to separate the decimal digits |
191 | | currencyCode | `String` | Currency raw code value |
192 | | alwaysShowsDecimalSeparator | `Bool` | Shows decimal separator even when there are no decimal digits |
193 | | groupingSize | `Int` | The amount of grouped numbers |
194 | | secondaryGroupingSize | `Int` | The amount of grouped numbers after the first group |
195 | | groupingSeparator | `String` | String that is shown between groups of numbers |
196 | | hasGroupingSeparator | `Bool` | Adds separator between all group of numbers |
197 | | maxIntegers | `Int` | Maximum allowed number of integers |
198 | | maxDigitsCount | `Int` | Returns the maximum amount of digits (integers + decimals) |
199 | | zeroSymbol | `String` | Text shown when string's value is equal zero |
200 |
201 |
202 |
203 | ## SwiftUI
204 |
205 | `CurrencyText` can also be used on `SwiftUI` via library introduced from the version `2.2.0`.
206 | Due to limitations on `SwiftUI` SDKs, like defining the selected text range, the `UIKit` version of the library was brigded to `SwiftUI` via `UIViewRepresentable` - more on the limitations can be seen in the [implementation PR description](https://github.com/marinofelipe/CurrencyText/pull/78).
207 | This may change in the future whenever the same functionality can be provided on vanilla `SwiftUI`.
208 |
209 |
210 |
211 | ### `CurrencyTextField` - how to configure and use it
212 |
213 | `CurrencyTextField` is a `SwiftUI.View` that formats user inputs as currency based on a given `CurrencyTextFieldConfiguration`.
214 | The configuration holds a `CurrencyFormatter` with all format related setup, bindings for text, closures for reacting to key text field events, and a configuration block for setting the looks and behavior of the underlying `UITextField`.
215 |
216 | ```swift
217 | var body: some View {
218 | CurrencyTextField(
219 | configuration: .init(
220 | placeholder: "Play with me...",
221 | text: $viewModel.data.text,
222 | unformattedText: $viewModel.data.unformatted,
223 | inputAmount: $viewModel.data.input,
224 | clearsWhenValueIsZero: true,
225 | formatter: .default,
226 |
227 | // The configuration block allows defining the looks
228 | // and doing additional configuration to
229 | // the underlying UITextField.
230 | // This is needed given that for most `SwiftUI`
231 | // modifiers there's no API for converting
232 | // back to UIKit - e.g. `Font` is not transformable to `UIFont`.
233 | textFieldConfiguration: { uiTextField in
234 | uiTextField.borderStyle = .roundedRect
235 | uiTextField.font = UIFont.preferredFont(forTextStyle: .body)
236 | uiTextField.textColor = .blue
237 | uiTextField.layer.borderColor = UIColor.red.cgColor
238 | uiTextField.layer.borderWidth = 1
239 | uiTextField.layer.cornerRadius = 4
240 | uiTextField.keyboardType = .numbersAndPunctuation
241 | uiTextField.layer.masksToBounds = true
242 | },
243 | onEditingChanged: { isEditing in
244 | if isEditing == false {
245 | // How to programmatically clear the text of CurrencyTextField:
246 | // The Binding.text that is passed
247 | // into CurrencyTextField.configuration can
248 | // manually cleared / updated with an empty String
249 | clearTextFieldText()
250 | }
251 | },
252 | onCommit: {
253 | // do something when users have committed their inputs
254 | }
255 | )
256 | )
257 | }
258 | ```
259 |
260 | For more details on specifics please refer to the code documentation and `SwiftUIExampleView` in the [ExampleApp](https://github.com/marinofelipe/CurrencyText/Example/Example/SwiftUI/SwiftUIExampleView.swift).
261 |
262 |
263 |
264 | ### `CurrencyTextField` - focus
265 |
266 | `CurrencyTextField` is bridged from `UIKit` to `SwiftUI` via `UIViewRepresentable`, it isn't a vanilla `SwiftUI.TextField` and thus does not have native [focused](https://developer.apple.com/documentation/swiftui/menu/focused(_:)) support.
267 |
268 | In order to control and observe the focus/isFirstResponder one can pass a `Biniding` when configuring a `CurrencyTextField`, which can be initially set as true if the text field should appear selected.
269 |
270 | ```swift
271 | struct MyView: View {
272 | @State private var hasFocus: Bool?
273 | @State private var text = ""
274 |
275 | var body: some View {
276 | CurrencyTextField(
277 | configuration: .init(
278 | text: $text,
279 | hasFocus: $hasFocus,
280 | // ...
281 | )
282 | )
283 | .onAppear {
284 | hasFocus = true
285 | }
286 | }
287 | }
288 | ```
289 |
290 | For more details on specifics please refer to the code documentation and `SwiftUIExampleView` in the [ExampleApp](https://github.com/marinofelipe/CurrencyText/Example/Example/SwiftUI/SwiftUIExampleView.swift).
291 |
292 |
293 |
294 | ### Why do I have to import UIKit?
295 |
296 | As a matter of context, the strategy of bridging the `UIKit` implementation to `SwiftUI` via `UIViewRepresentable` was chosen since there's no API yet for controlling a `SwiftUI.TextField`s `.selectedTextRange`, which is needed in `CurrencyText` for cases e.g. where the currency symbol is at the end and to provide the best user experience the text field has to auto update the `.selectedTextRange` to be before the currency symbol.
297 |
298 | With that scenario in mind, and understanding that `CurrencyTextField` uses `UITextField` internally, the easiest way
299 | to allow users to fully control the component was to give them access to the underlying `UITextField` instance so it could be configured and setup accordingly to their needs.
300 |
301 | As alternative it was considered wrapping `UIKit.UITextField`s API, but that layer would be both extra work to develop and confusing for users, given that most of us (Apple third-party developers) are already familiar with `UIKit.UITextField`'s API.
302 | Besides that, the framework would never build for other platforms given that
303 |
304 |
305 |
306 | ### Why is it only available for iOS?
307 |
308 | Connected to what was mentioned above, the `SwiftUI` library currently bridges the `UIKit` implementation and that limits the framework on building only for iOS.
309 |
310 | ### Thoughts for the future
311 |
312 | https://github.com/marinofelipe/CurrencyText/issues/79
313 |
--------------------------------------------------------------------------------
/fastlane/Appfile:
--------------------------------------------------------------------------------
1 | # app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app
2 | # apple_id("[[APPLE_ID]]") # Your Apple email address
3 |
4 |
5 | # For more information about the Appfile, see:
6 | # https://docs.fastlane.tools/advanced/#appfile
7 |
--------------------------------------------------------------------------------
/fastlane/Fastfile:
--------------------------------------------------------------------------------
1 | def branches
2 | {
3 | dev: "develop",
4 | master: "master"
5 | }
6 | end
7 |
8 | def cocoapods
9 | {
10 | podspec: "../CurrencyText.podspec"
11 | }
12 | end
13 |
14 | default_platform(:ios)
15 |
16 | platform :ios do
17 |
18 | def is_ci
19 | ENV['CI'] == 'true'
20 | end
21 |
22 | ###-------------------------------- Tests ---------------------------###
23 | desc "Run tests"
24 | lane :test do
25 | scan(
26 | package_path: "", # root
27 | code_coverage: true,
28 | scheme: "CurrencyText-Package",
29 | device: "iPhone 15 Pro (17.5)",
30 | result_bundle: true,
31 | output_directory: "fastlane/test_output",
32 | xcodebuild_formatter: is_ci ? 'xcbeautify -q --is-ci --renderer github-actions' : 'xcbeautify -q',
33 | output_types: 'junit',
34 | )
35 | end
36 |
37 | ###-------------------------------- Release ---------------------------###
38 |
39 | desc "Release the framework next version. Available bump types are: [patch, minor, major]."
40 | lane :release do |options|
41 | bump_type = options[:bump_type] || "patch"
42 | tag bump_type:bump_type
43 | publish_pod
44 | end
45 |
46 | desc "Tag the next release. Available bump types are: patch, minor, major"
47 | lane :tag do |options|
48 | ensure_git_status_clean
49 |
50 | sh "git fetch --tags"
51 | last_tag = last_git_tag
52 |
53 | bump_type = options[:bump_type] || "patch"
54 | bump_podspec bump_type:bump_type
55 |
56 | spec = read_podspec(path: "#{cocoapods[:podspec]}")
57 | version_number = spec["version"]
58 |
59 | if (Gem::Version.new(version_number) >= Gem::Version.new(last_tag))
60 | UI.success "All good! New tag is valid: #{version_number} 💪".green
61 | else
62 | raise "New version: #{version_number} is <= last tag: #{last_tag}".yellow
63 | end
64 |
65 | sh "git commit -am \"Bump podspec version: #{version_number}\""
66 | add_git_tag(tag: version_number)
67 | push_git_tags
68 | push_to_git_remote
69 | end
70 |
71 | desc "Bump the podspec version. Available bump types are: patch, minor, major."
72 | lane :bump_podspec do |options|
73 | bump_type = options[:bump_type] || "patch"
74 | version_bump_podspec(path: "#{cocoapods[:podspec]}", bump_type: bump_type)
75 | end
76 |
77 | desc "Publish the new pod version!"
78 | lane :publish_pod do
79 | pod_push(path: "#{cocoapods[:podspec]}")
80 | end
81 | end
--------------------------------------------------------------------------------
/fastlane/README.md:
--------------------------------------------------------------------------------
1 | fastlane documentation
2 | ----
3 |
4 | # Installation
5 |
6 | Make sure you have the latest version of the Xcode command line tools installed:
7 |
8 | ```sh
9 | xcode-select --install
10 | ```
11 |
12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
13 |
14 | # Available Actions
15 |
16 | ## iOS
17 |
18 | ### ios test
19 |
20 | ```sh
21 | [bundle exec] fastlane ios test
22 | ```
23 |
24 | Run tests
25 |
26 | ### ios create_test_sim
27 |
28 | ```sh
29 | [bundle exec] fastlane ios create_test_sim
30 | ```
31 |
32 |
33 |
34 | ### ios release
35 |
36 | ```sh
37 | [bundle exec] fastlane ios release
38 | ```
39 |
40 | Release the framework next version. Available bump types are: [patch, minor, major].
41 |
42 | ### ios tag
43 |
44 | ```sh
45 | [bundle exec] fastlane ios tag
46 | ```
47 |
48 | Tag the next release. Available bump types are: patch, minor, major
49 |
50 | ### ios bump_podspec
51 |
52 | ```sh
53 | [bundle exec] fastlane ios bump_podspec
54 | ```
55 |
56 | Bump the podspec version. Available bump types are: patch, minor, major.
57 |
58 | ### ios publish_pod
59 |
60 | ```sh
61 | [bundle exec] fastlane ios publish_pod
62 | ```
63 |
64 | Publish the new pod version!
65 |
66 | ----
67 |
68 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
69 |
70 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
71 |
72 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
73 |
--------------------------------------------------------------------------------
/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marinofelipe/CurrencyText/03d08a028a32547e26f50751660c7c0b3cfa3279/images/logo.png
--------------------------------------------------------------------------------