├── .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 | ![Build status](https://github.com/marinofelipe/CurrencyText/actions/workflows/ci.yml/badge.svg) 2 | [![Coverate status](https://codecov.io/gh/marinofelipe/CurrencyText/branch/main/graph/badge.svg?token=K4VOS8NH7A)](https://codecov.io/gh/marinofelipe/CurrencyText) 3 | Swift 4 | [![Platform](https://img.shields.io/cocoapods/p/CurrencyText.svg?style=flat)]() 5 | [![Swift Package Manager](https://rawgit.com/jlyonsmith/artwork/master/SwiftPackageManager/swiftpackagemanager-compatible.svg)](https://swift.org/package-manager/) 6 | [![CocoaPods Compatible](https://img.shields.io/badge/pod-v2.2.0-blue.svg)](https://cocoapods.org/pods/CurrencyText) 7 | [![Twitter](https://img.shields.io/badge/twitter-@_marinofelipe-blue.svg?style=flat)](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 --------------------------------------------------------------------------------