├── .github └── workflows │ └── documentation.yml ├── .gitignore ├── .swift-version ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── IconSelector.podspec ├── IconSelector.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── IconSelector.xcscheme ├── LICENSE ├── Makefile ├── Package.swift ├── README.md ├── resources ├── banner.svg ├── screenshot-gifwrapped.jpeg └── screenshot-slopes.jpeg ├── scripts └── documentation.sh └── src ├── IconSelector ├── Icon.swift ├── IconSelector.h ├── IconSelector.swift ├── IconSelectorViewController.swift ├── IconView.swift ├── Info.plist └── UIApplication.swift └── IconSelectorTests ├── IconSelectorTests.swift └── Info.plist /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Generate Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: macOS-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v1 16 | 17 | - name: Cache RubyGems 18 | uses: actions/cache@v1 19 | with: 20 | path: vendor/bundle 21 | key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} 22 | restore-keys: ${{ runner.os }}-gem- 23 | 24 | - name: Generate Documentation 25 | run: make documentation 26 | 27 | - name: Deploy 28 | uses: peaceiris/actions-gh-pages@v3 29 | with: 30 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} 31 | publish_branch: gh-pages 32 | publish_dir: ./docs 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Build generated 2 | .build/ 3 | build/ 4 | DerivedData 5 | docs/ 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 | *.xccheckout 20 | *.moved-aside 21 | *.xcuserstate 22 | *.xcscmblueprint 23 | .DS_Store 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | .swiftpm 29 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.0 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hello@jellystyle.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | gem "jazzy" 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.2) 5 | activesupport (4.2.11.1) 6 | i18n (~> 0.7) 7 | minitest (~> 5.1) 8 | thread_safe (~> 0.3, >= 0.3.4) 9 | tzinfo (~> 1.1) 10 | algoliasearch (1.27.1) 11 | httpclient (~> 2.8, >= 2.8.3) 12 | json (>= 1.5.1) 13 | atomos (0.1.3) 14 | claide (1.0.3) 15 | cocoapods (1.8.4) 16 | activesupport (>= 4.0.2, < 5) 17 | claide (>= 1.0.2, < 2.0) 18 | cocoapods-core (= 1.8.4) 19 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 20 | cocoapods-downloader (>= 1.2.2, < 2.0) 21 | cocoapods-plugins (>= 1.0.0, < 2.0) 22 | cocoapods-search (>= 1.0.0, < 2.0) 23 | cocoapods-stats (>= 1.0.0, < 2.0) 24 | cocoapods-trunk (>= 1.4.0, < 2.0) 25 | cocoapods-try (>= 1.1.0, < 2.0) 26 | colored2 (~> 3.1) 27 | escape (~> 0.0.4) 28 | fourflusher (>= 2.3.0, < 3.0) 29 | gh_inspector (~> 1.0) 30 | molinillo (~> 0.6.6) 31 | nap (~> 1.0) 32 | ruby-macho (~> 1.4) 33 | xcodeproj (>= 1.11.1, < 2.0) 34 | cocoapods-core (1.8.4) 35 | activesupport (>= 4.0.2, < 6) 36 | algoliasearch (~> 1.0) 37 | concurrent-ruby (~> 1.1) 38 | fuzzy_match (~> 2.0.4) 39 | nap (~> 1.0) 40 | cocoapods-deintegrate (1.0.4) 41 | cocoapods-downloader (1.3.0) 42 | cocoapods-plugins (1.0.0) 43 | nap 44 | cocoapods-search (1.0.0) 45 | cocoapods-stats (1.1.0) 46 | cocoapods-trunk (1.4.1) 47 | nap (>= 0.8, < 2.0) 48 | netrc (~> 0.11) 49 | cocoapods-try (1.1.0) 50 | colored2 (3.1.2) 51 | concurrent-ruby (1.1.5) 52 | escape (0.0.4) 53 | ffi (1.12.1) 54 | fourflusher (2.3.1) 55 | fuzzy_match (2.0.4) 56 | gh_inspector (1.1.3) 57 | httpclient (2.8.3) 58 | i18n (0.9.5) 59 | concurrent-ruby (~> 1.0) 60 | jazzy (0.13.1) 61 | cocoapods (~> 1.5) 62 | mustache (~> 1.1) 63 | open4 64 | redcarpet (~> 3.4) 65 | rouge (>= 2.0.6, < 4.0) 66 | sassc (~> 2.1) 67 | sqlite3 (~> 1.3) 68 | xcinvoke (~> 0.3.0) 69 | json (2.3.0) 70 | liferaft (0.0.6) 71 | minitest (5.14.0) 72 | molinillo (0.6.6) 73 | mustache (1.1.1) 74 | nanaimo (0.2.6) 75 | nap (1.1.0) 76 | netrc (0.11.0) 77 | open4 (1.3.4) 78 | redcarpet (3.5.1) 79 | rouge (3.15.0) 80 | ruby-macho (1.4.0) 81 | sassc (2.2.1) 82 | ffi (~> 1.9) 83 | sqlite3 (1.4.2) 84 | thread_safe (0.3.6) 85 | tzinfo (1.2.6) 86 | thread_safe (~> 0.1) 87 | xcinvoke (0.3.0) 88 | liferaft (~> 0.0.6) 89 | xcodeproj (1.14.0) 90 | CFPropertyList (>= 2.3.3, < 4.0) 91 | atomos (~> 0.1.3) 92 | claide (>= 1.0.2, < 2.0) 93 | colored2 (~> 3.1) 94 | nanaimo (~> 0.2.6) 95 | 96 | PLATFORMS 97 | ruby 98 | 99 | DEPENDENCIES 100 | jazzy 101 | 102 | BUNDLED WITH 103 | 2.1.4 104 | -------------------------------------------------------------------------------- /IconSelector.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "IconSelector" 3 | s.version = "1.0" 4 | s.summary = "A drop-in UI component to allow easy selection of alternate icons on iOS." 5 | s.homepage = "https://github.com/jellybeansoup/ios-icon-selector" 6 | s.license = { :type => 'BSD', :file => 'LICENSE' } 7 | s.author = { "Daniel Farrelly" => "daniel@jellystyle.com" } 8 | s.source = { :git => "https://github.com/jellybeansoup/ios-icon-selector.git", :tag => "v#{s.version}" } 9 | s.ios.deployment_target = '10.3' 10 | s.source_files = "src/IconSelector/*.{swift,h}" 11 | end 12 | -------------------------------------------------------------------------------- /IconSelector.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A168F66B243C8203005F25F0 /* IconSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A168F66A243C8203005F25F0 /* IconSelectorViewController.swift */; }; 11 | A18AD24C21F8A07000F4359F /* Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A18AD24921F8A07000F4359F /* Icon.swift */; }; 12 | A18AD24D21F8A07000F4359F /* IconSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A18AD24A21F8A07000F4359F /* IconSelector.swift */; }; 13 | A18AD24E21F8A07000F4359F /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = A18AD24B21F8A07000F4359F /* UIApplication.swift */; }; 14 | A18AD25021F8A1BA00F4359F /* IconSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A18AD24F21F8A1BA00F4359F /* IconSelectorTests.swift */; }; 15 | A1CF09FF1DF8D65400D13E21 /* IconSelector.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1A787381C7BED9C0095A0EF /* IconSelector.framework */; }; 16 | A1E666151C9793700005CE51 /* IconSelector.h in Headers */ = {isa = PBXBuildFile; fileRef = A1E666141C9793700005CE51 /* IconSelector.h */; settings = {ATTRIBUTES = (Public, ); }; }; 17 | A1FFE7A0243AA7AE00975B88 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1FFE79F243AA7AE00975B88 /* IconView.swift */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXContainerItemProxy section */ 21 | A1CF0A001DF8D65400D13E21 /* PBXContainerItemProxy */ = { 22 | isa = PBXContainerItemProxy; 23 | containerPortal = A17134FD185007F800E56C4D /* Project object */; 24 | proxyType = 1; 25 | remoteGlobalIDString = A1A787371C7BED9C0095A0EF; 26 | remoteInfo = Sherpa; 27 | }; 28 | /* End PBXContainerItemProxy section */ 29 | 30 | /* Begin PBXFileReference section */ 31 | A168F66A243C8203005F25F0 /* IconSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorViewController.swift; sourceTree = ""; }; 32 | A171352E185008F400E56C4D /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 33 | A171352F185008F400E56C4D /* README.md */ = {isa = PBXFileReference; lastKnownFileType = text; path = README.md; sourceTree = ""; }; 34 | A17135301850090C00E56C4D /* IconSelector.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = IconSelector.podspec; sourceTree = ""; }; 35 | A18AD24921F8A07000F4359F /* Icon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Icon.swift; sourceTree = ""; }; 36 | A18AD24A21F8A07000F4359F /* IconSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IconSelector.swift; sourceTree = ""; }; 37 | A18AD24B21F8A07000F4359F /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; 38 | A18AD24F21F8A1BA00F4359F /* IconSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorTests.swift; sourceTree = ""; }; 39 | A1A787381C7BED9C0095A0EF /* IconSelector.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = IconSelector.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 40 | A1A7873C1C7BED9C0095A0EF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 41 | A1CF09FA1DF8D65400D13E21 /* IconSelectorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IconSelectorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 42 | A1CF09FE1DF8D65400D13E21 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43 | A1E666141C9793700005CE51 /* IconSelector.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = IconSelector.h; sourceTree = ""; }; 44 | A1FFE79F243AA7AE00975B88 /* IconView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; 45 | C961138B23DFD2A600B190E7 /* Package.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 46 | /* End PBXFileReference section */ 47 | 48 | /* Begin PBXFrameworksBuildPhase section */ 49 | A1A787341C7BED9C0095A0EF /* Frameworks */ = { 50 | isa = PBXFrameworksBuildPhase; 51 | buildActionMask = 2147483647; 52 | files = ( 53 | ); 54 | runOnlyForDeploymentPostprocessing = 0; 55 | }; 56 | A1CF09F71DF8D65400D13E21 /* Frameworks */ = { 57 | isa = PBXFrameworksBuildPhase; 58 | buildActionMask = 2147483647; 59 | files = ( 60 | A1CF09FF1DF8D65400D13E21 /* IconSelector.framework in Frameworks */, 61 | ); 62 | runOnlyForDeploymentPostprocessing = 0; 63 | }; 64 | /* End PBXFrameworksBuildPhase section */ 65 | 66 | /* Begin PBXGroup section */ 67 | A17134FC185007F800E56C4D = { 68 | isa = PBXGroup; 69 | children = ( 70 | A171350A185007F800E56C4D /* IconSelector */, 71 | A1CF09FB1DF8D65400D13E21 /* IconSelectorTests */, 72 | A1713506185007F800E56C4D /* Products */, 73 | A171352E185008F400E56C4D /* LICENSE */, 74 | A171352F185008F400E56C4D /* README.md */, 75 | C961138B23DFD2A600B190E7 /* Package.swift */, 76 | A17135301850090C00E56C4D /* IconSelector.podspec */, 77 | ); 78 | sourceTree = ""; 79 | usesTabs = 1; 80 | }; 81 | A1713506185007F800E56C4D /* Products */ = { 82 | isa = PBXGroup; 83 | children = ( 84 | A1A787381C7BED9C0095A0EF /* IconSelector.framework */, 85 | A1CF09FA1DF8D65400D13E21 /* IconSelectorTests.xctest */, 86 | ); 87 | name = Products; 88 | sourceTree = ""; 89 | }; 90 | A171350A185007F800E56C4D /* IconSelector */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | A1E666141C9793700005CE51 /* IconSelector.h */, 94 | A18AD24A21F8A07000F4359F /* IconSelector.swift */, 95 | A168F66A243C8203005F25F0 /* IconSelectorViewController.swift */, 96 | A18AD24921F8A07000F4359F /* Icon.swift */, 97 | A1FFE79F243AA7AE00975B88 /* IconView.swift */, 98 | A18AD24B21F8A07000F4359F /* UIApplication.swift */, 99 | A1A7873C1C7BED9C0095A0EF /* Info.plist */, 100 | ); 101 | name = IconSelector; 102 | path = src/IconSelector; 103 | sourceTree = ""; 104 | }; 105 | A1CF09FB1DF8D65400D13E21 /* IconSelectorTests */ = { 106 | isa = PBXGroup; 107 | children = ( 108 | A18AD24F21F8A1BA00F4359F /* IconSelectorTests.swift */, 109 | A1CF09FE1DF8D65400D13E21 /* Info.plist */, 110 | ); 111 | name = IconSelectorTests; 112 | path = src/IconSelectorTests; 113 | sourceTree = ""; 114 | }; 115 | /* End PBXGroup section */ 116 | 117 | /* Begin PBXHeadersBuildPhase section */ 118 | A1A787351C7BED9C0095A0EF /* Headers */ = { 119 | isa = PBXHeadersBuildPhase; 120 | buildActionMask = 2147483647; 121 | files = ( 122 | A1E666151C9793700005CE51 /* IconSelector.h in Headers */, 123 | ); 124 | runOnlyForDeploymentPostprocessing = 0; 125 | }; 126 | /* End PBXHeadersBuildPhase section */ 127 | 128 | /* Begin PBXNativeTarget section */ 129 | A1A787371C7BED9C0095A0EF /* IconSelector */ = { 130 | isa = PBXNativeTarget; 131 | buildConfigurationList = A1A7873D1C7BED9C0095A0EF /* Build configuration list for PBXNativeTarget "IconSelector" */; 132 | buildPhases = ( 133 | A1A787331C7BED9C0095A0EF /* Sources */, 134 | A1A787341C7BED9C0095A0EF /* Frameworks */, 135 | A1A787351C7BED9C0095A0EF /* Headers */, 136 | A1A787361C7BED9C0095A0EF /* Resources */, 137 | ); 138 | buildRules = ( 139 | ); 140 | dependencies = ( 141 | ); 142 | name = IconSelector; 143 | productName = DropboxAuth; 144 | productReference = A1A787381C7BED9C0095A0EF /* IconSelector.framework */; 145 | productType = "com.apple.product-type.framework"; 146 | }; 147 | A1CF09F91DF8D65400D13E21 /* IconSelectorTests */ = { 148 | isa = PBXNativeTarget; 149 | buildConfigurationList = A1CF0A041DF8D65400D13E21 /* Build configuration list for PBXNativeTarget "IconSelectorTests" */; 150 | buildPhases = ( 151 | A1CF09F61DF8D65400D13E21 /* Sources */, 152 | A1CF09F71DF8D65400D13E21 /* Frameworks */, 153 | A1CF09F81DF8D65400D13E21 /* Resources */, 154 | ); 155 | buildRules = ( 156 | ); 157 | dependencies = ( 158 | A1CF0A011DF8D65400D13E21 /* PBXTargetDependency */, 159 | ); 160 | name = IconSelectorTests; 161 | productName = SherpaTests; 162 | productReference = A1CF09FA1DF8D65400D13E21 /* IconSelectorTests.xctest */; 163 | productType = "com.apple.product-type.bundle.unit-test"; 164 | }; 165 | /* End PBXNativeTarget section */ 166 | 167 | /* Begin PBXProject section */ 168 | A17134FD185007F800E56C4D /* Project object */ = { 169 | isa = PBXProject; 170 | attributes = { 171 | LastSwiftUpdateCheck = 0820; 172 | LastUpgradeCheck = 1010; 173 | ORGANIZATIONNAME = "Daniel Farrelly"; 174 | TargetAttributes = { 175 | A1A787371C7BED9C0095A0EF = { 176 | CreatedOnToolsVersion = 7.2.1; 177 | LastSwiftMigration = 1020; 178 | }; 179 | A1CF09F91DF8D65400D13E21 = { 180 | CreatedOnToolsVersion = 8.2; 181 | DevelopmentTeam = SGWXH8G2WP; 182 | LastSwiftMigration = 1010; 183 | ProvisioningStyle = Automatic; 184 | }; 185 | }; 186 | }; 187 | buildConfigurationList = A1713500185007F800E56C4D /* Build configuration list for PBXProject "IconSelector" */; 188 | compatibilityVersion = "Xcode 3.2"; 189 | developmentRegion = en; 190 | hasScannedForEncodings = 0; 191 | knownRegions = ( 192 | en, 193 | Base, 194 | ); 195 | mainGroup = A17134FC185007F800E56C4D; 196 | productRefGroup = A1713506185007F800E56C4D /* Products */; 197 | projectDirPath = ""; 198 | projectRoot = ""; 199 | targets = ( 200 | A1A787371C7BED9C0095A0EF /* IconSelector */, 201 | A1CF09F91DF8D65400D13E21 /* IconSelectorTests */, 202 | ); 203 | }; 204 | /* End PBXProject section */ 205 | 206 | /* Begin PBXResourcesBuildPhase section */ 207 | A1A787361C7BED9C0095A0EF /* Resources */ = { 208 | isa = PBXResourcesBuildPhase; 209 | buildActionMask = 2147483647; 210 | files = ( 211 | ); 212 | runOnlyForDeploymentPostprocessing = 0; 213 | }; 214 | A1CF09F81DF8D65400D13E21 /* Resources */ = { 215 | isa = PBXResourcesBuildPhase; 216 | buildActionMask = 2147483647; 217 | files = ( 218 | ); 219 | runOnlyForDeploymentPostprocessing = 0; 220 | }; 221 | /* End PBXResourcesBuildPhase section */ 222 | 223 | /* Begin PBXSourcesBuildPhase section */ 224 | A1A787331C7BED9C0095A0EF /* Sources */ = { 225 | isa = PBXSourcesBuildPhase; 226 | buildActionMask = 2147483647; 227 | files = ( 228 | A18AD24E21F8A07000F4359F /* UIApplication.swift in Sources */, 229 | A1FFE7A0243AA7AE00975B88 /* IconView.swift in Sources */, 230 | A18AD24C21F8A07000F4359F /* Icon.swift in Sources */, 231 | A168F66B243C8203005F25F0 /* IconSelectorViewController.swift in Sources */, 232 | A18AD24D21F8A07000F4359F /* IconSelector.swift in Sources */, 233 | ); 234 | runOnlyForDeploymentPostprocessing = 0; 235 | }; 236 | A1CF09F61DF8D65400D13E21 /* Sources */ = { 237 | isa = PBXSourcesBuildPhase; 238 | buildActionMask = 2147483647; 239 | files = ( 240 | A18AD25021F8A1BA00F4359F /* IconSelectorTests.swift in Sources */, 241 | ); 242 | runOnlyForDeploymentPostprocessing = 0; 243 | }; 244 | /* End PBXSourcesBuildPhase section */ 245 | 246 | /* Begin PBXTargetDependency section */ 247 | A1CF0A011DF8D65400D13E21 /* PBXTargetDependency */ = { 248 | isa = PBXTargetDependency; 249 | target = A1A787371C7BED9C0095A0EF /* IconSelector */; 250 | targetProxy = A1CF0A001DF8D65400D13E21 /* PBXContainerItemProxy */; 251 | }; 252 | /* End PBXTargetDependency section */ 253 | 254 | /* Begin XCBuildConfiguration section */ 255 | A1713526185007F800E56C4D /* Debug */ = { 256 | isa = XCBuildConfiguration; 257 | buildSettings = { 258 | ALWAYS_SEARCH_USER_PATHS = NO; 259 | APPLICATION_EXTENSION_API_ONLY = YES; 260 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 261 | CLANG_CXX_LIBRARY = "libc++"; 262 | CLANG_ENABLE_MODULES = YES; 263 | CLANG_ENABLE_OBJC_ARC = YES; 264 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 265 | CLANG_WARN_BOOL_CONVERSION = YES; 266 | CLANG_WARN_COMMA = YES; 267 | CLANG_WARN_CONSTANT_CONVERSION = YES; 268 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 269 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 270 | CLANG_WARN_EMPTY_BODY = YES; 271 | CLANG_WARN_ENUM_CONVERSION = YES; 272 | CLANG_WARN_INFINITE_RECURSION = YES; 273 | CLANG_WARN_INT_CONVERSION = YES; 274 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 275 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 276 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 277 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 278 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 279 | CLANG_WARN_STRICT_PROTOTYPES = YES; 280 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 281 | CLANG_WARN_UNREACHABLE_CODE = YES; 282 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 283 | COPY_PHASE_STRIP = NO; 284 | ENABLE_STRICT_OBJC_MSGSEND = YES; 285 | ENABLE_TESTABILITY = YES; 286 | GCC_C_LANGUAGE_STANDARD = gnu99; 287 | GCC_DYNAMIC_NO_PIC = NO; 288 | GCC_NO_COMMON_BLOCKS = YES; 289 | GCC_OPTIMIZATION_LEVEL = 0; 290 | GCC_PREPROCESSOR_DEFINITIONS = ( 291 | "DEBUG=1", 292 | "$(inherited)", 293 | ); 294 | GCC_SYMBOLS_PRIVATE_EXTERN = NO; 295 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 296 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 297 | GCC_WARN_UNDECLARED_SELECTOR = YES; 298 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 299 | GCC_WARN_UNUSED_FUNCTION = YES; 300 | GCC_WARN_UNUSED_VARIABLE = YES; 301 | IPHONEOS_DEPLOYMENT_TARGET = 10.3; 302 | ONLY_ACTIVE_ARCH = YES; 303 | SDKROOT = iphoneos; 304 | SWIFT_VERSION = 4.2; 305 | }; 306 | name = Debug; 307 | }; 308 | A1713527185007F800E56C4D /* Release */ = { 309 | isa = XCBuildConfiguration; 310 | buildSettings = { 311 | ALWAYS_SEARCH_USER_PATHS = NO; 312 | APPLICATION_EXTENSION_API_ONLY = YES; 313 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 314 | CLANG_CXX_LIBRARY = "libc++"; 315 | CLANG_ENABLE_MODULES = YES; 316 | CLANG_ENABLE_OBJC_ARC = YES; 317 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 318 | CLANG_WARN_BOOL_CONVERSION = YES; 319 | CLANG_WARN_COMMA = YES; 320 | CLANG_WARN_CONSTANT_CONVERSION = YES; 321 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 322 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 323 | CLANG_WARN_EMPTY_BODY = YES; 324 | CLANG_WARN_ENUM_CONVERSION = YES; 325 | CLANG_WARN_INFINITE_RECURSION = YES; 326 | CLANG_WARN_INT_CONVERSION = YES; 327 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 328 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 329 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 330 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 331 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 332 | CLANG_WARN_STRICT_PROTOTYPES = YES; 333 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 334 | CLANG_WARN_UNREACHABLE_CODE = YES; 335 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 336 | COPY_PHASE_STRIP = YES; 337 | ENABLE_NS_ASSERTIONS = NO; 338 | ENABLE_STRICT_OBJC_MSGSEND = YES; 339 | GCC_C_LANGUAGE_STANDARD = gnu99; 340 | GCC_NO_COMMON_BLOCKS = YES; 341 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 342 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 343 | GCC_WARN_UNDECLARED_SELECTOR = YES; 344 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 345 | GCC_WARN_UNUSED_FUNCTION = YES; 346 | GCC_WARN_UNUSED_VARIABLE = YES; 347 | IPHONEOS_DEPLOYMENT_TARGET = 10.3; 348 | SDKROOT = iphoneos; 349 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 350 | SWIFT_VERSION = 4.2; 351 | VALIDATE_PRODUCT = YES; 352 | }; 353 | name = Release; 354 | }; 355 | A1A7873E1C7BED9C0095A0EF /* Debug */ = { 356 | isa = XCBuildConfiguration; 357 | buildSettings = { 358 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; 359 | APPLICATION_EXTENSION_API_ONLY = NO; 360 | CLANG_ENABLE_MODULES = YES; 361 | CLANG_WARN_UNREACHABLE_CODE = YES; 362 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; 363 | CURRENT_PROJECT_VERSION = 1; 364 | DEBUG_INFORMATION_FORMAT = dwarf; 365 | DEFINES_MODULE = YES; 366 | DYLIB_COMPATIBILITY_VERSION = 1; 367 | DYLIB_CURRENT_VERSION = 1; 368 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 369 | ENABLE_STRICT_OBJC_MSGSEND = YES; 370 | ENABLE_TESTABILITY = YES; 371 | GCC_NO_COMMON_BLOCKS = YES; 372 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 373 | INFOPLIST_FILE = "$(SRCROOT)/src/IconSelector/Info.plist"; 374 | INSTALL_PATH = "@executable_path/Frameworks"; 375 | IPHONEOS_DEPLOYMENT_TARGET = 10.3; 376 | LD_DYLIB_INSTALL_NAME = "$(DYLIB_INSTALL_NAME_BASE:standardizepath)/$(EXECUTABLE_PATH)"; 377 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 378 | MTL_ENABLE_DEBUG_INFO = YES; 379 | PRODUCT_BUNDLE_IDENTIFIER = "com.jellystyle.${PRODUCT_NAME:rfc1034identifier}"; 380 | PRODUCT_NAME = IconSelector; 381 | SKIP_INSTALL = YES; 382 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 383 | SWIFT_VERSION = 5.0; 384 | TARGETED_DEVICE_FAMILY = "1,2"; 385 | VERSIONING_SYSTEM = "apple-generic"; 386 | VERSION_INFO_PREFIX = ""; 387 | }; 388 | name = Debug; 389 | }; 390 | A1A7873F1C7BED9C0095A0EF /* Release */ = { 391 | isa = XCBuildConfiguration; 392 | buildSettings = { 393 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; 394 | APPLICATION_EXTENSION_API_ONLY = NO; 395 | CLANG_ENABLE_MODULES = YES; 396 | CLANG_WARN_UNREACHABLE_CODE = YES; 397 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; 398 | COPY_PHASE_STRIP = NO; 399 | CURRENT_PROJECT_VERSION = 1; 400 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 401 | DEFINES_MODULE = YES; 402 | DYLIB_COMPATIBILITY_VERSION = 1; 403 | DYLIB_CURRENT_VERSION = 1; 404 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 405 | ENABLE_STRICT_OBJC_MSGSEND = YES; 406 | GCC_NO_COMMON_BLOCKS = YES; 407 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 408 | INFOPLIST_FILE = "$(SRCROOT)/src/IconSelector/Info.plist"; 409 | INSTALL_PATH = "@executable_path/Frameworks"; 410 | IPHONEOS_DEPLOYMENT_TARGET = 10.3; 411 | LD_DYLIB_INSTALL_NAME = "$(DYLIB_INSTALL_NAME_BASE:standardizepath)/$(EXECUTABLE_PATH)"; 412 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 413 | MTL_ENABLE_DEBUG_INFO = NO; 414 | PRODUCT_BUNDLE_IDENTIFIER = "com.jellystyle.${PRODUCT_NAME:rfc1034identifier}"; 415 | PRODUCT_NAME = IconSelector; 416 | SKIP_INSTALL = YES; 417 | SWIFT_VERSION = 5.0; 418 | TARGETED_DEVICE_FAMILY = "1,2"; 419 | VERSIONING_SYSTEM = "apple-generic"; 420 | VERSION_INFO_PREFIX = ""; 421 | }; 422 | name = Release; 423 | }; 424 | A1CF0A021DF8D65400D13E21 /* Debug */ = { 425 | isa = XCBuildConfiguration; 426 | buildSettings = { 427 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 428 | APPLICATION_EXTENSION_API_ONLY = NO; 429 | CLANG_ANALYZER_NONNULL = YES; 430 | CLANG_ENABLE_MODULES = YES; 431 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 432 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 433 | DEBUG_INFORMATION_FORMAT = dwarf; 434 | DEVELOPMENT_TEAM = SGWXH8G2WP; 435 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 436 | INFOPLIST_FILE = src/IconSelectorTests/Info.plist; 437 | IPHONEOS_DEPLOYMENT_TARGET = 10.2; 438 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 439 | MTL_ENABLE_DEBUG_INFO = YES; 440 | PRODUCT_BUNDLE_IDENTIFIER = com.jellystyle.IconSelectorTests; 441 | PRODUCT_NAME = "$(TARGET_NAME)"; 442 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 443 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 444 | }; 445 | name = Debug; 446 | }; 447 | A1CF0A031DF8D65400D13E21 /* Release */ = { 448 | isa = XCBuildConfiguration; 449 | buildSettings = { 450 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 451 | APPLICATION_EXTENSION_API_ONLY = NO; 452 | CLANG_ANALYZER_NONNULL = YES; 453 | CLANG_ENABLE_MODULES = YES; 454 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 455 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 456 | COPY_PHASE_STRIP = NO; 457 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 458 | DEVELOPMENT_TEAM = SGWXH8G2WP; 459 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 460 | INFOPLIST_FILE = src/IconSelectorTests/Info.plist; 461 | IPHONEOS_DEPLOYMENT_TARGET = 10.2; 462 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 463 | MTL_ENABLE_DEBUG_INFO = NO; 464 | PRODUCT_BUNDLE_IDENTIFIER = com.jellystyle.IconSelectorTests; 465 | PRODUCT_NAME = "$(TARGET_NAME)"; 466 | }; 467 | name = Release; 468 | }; 469 | /* End XCBuildConfiguration section */ 470 | 471 | /* Begin XCConfigurationList section */ 472 | A1713500185007F800E56C4D /* Build configuration list for PBXProject "IconSelector" */ = { 473 | isa = XCConfigurationList; 474 | buildConfigurations = ( 475 | A1713526185007F800E56C4D /* Debug */, 476 | A1713527185007F800E56C4D /* Release */, 477 | ); 478 | defaultConfigurationIsVisible = 0; 479 | defaultConfigurationName = Release; 480 | }; 481 | A1A7873D1C7BED9C0095A0EF /* Build configuration list for PBXNativeTarget "IconSelector" */ = { 482 | isa = XCConfigurationList; 483 | buildConfigurations = ( 484 | A1A7873E1C7BED9C0095A0EF /* Debug */, 485 | A1A7873F1C7BED9C0095A0EF /* Release */, 486 | ); 487 | defaultConfigurationIsVisible = 0; 488 | defaultConfigurationName = Release; 489 | }; 490 | A1CF0A041DF8D65400D13E21 /* Build configuration list for PBXNativeTarget "IconSelectorTests" */ = { 491 | isa = XCConfigurationList; 492 | buildConfigurations = ( 493 | A1CF0A021DF8D65400D13E21 /* Debug */, 494 | A1CF0A031DF8D65400D13E21 /* Release */, 495 | ); 496 | defaultConfigurationIsVisible = 0; 497 | defaultConfigurationName = Release; 498 | }; 499 | /* End XCConfigurationList section */ 500 | }; 501 | rootObject = A17134FD185007F800E56C4D /* Project object */; 502 | } 503 | -------------------------------------------------------------------------------- /IconSelector.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /IconSelector.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /IconSelector.xcodeproj/xcshareddata/xcschemes/IconSelector.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 50 | 51 | 52 | 53 | 54 | 55 | 65 | 66 | 72 | 73 | 74 | 75 | 76 | 77 | 83 | 84 | 90 | 91 | 92 | 93 | 95 | 96 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2021 Daniel Farrelly & Curtis Herbert 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list 7 | of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright notice, this 9 | list of conditions and the following disclaimer in the documentation and/or 10 | other materials provided with the distribution. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 13 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 14 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 15 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 16 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 17 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 18 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 19 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 20 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 21 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | documentation: 2 | @sh ./scripts/documentation.sh -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 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: "IconSelector", 8 | platforms: [.iOS("10.3")], 9 | products: [ 10 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 11 | .library( 12 | name: "IconSelector", 13 | targets: ["IconSelector"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 22 | .target( 23 | name: "IconSelector", 24 | dependencies: [], 25 | path: "src/IconSelector"), 26 | .testTarget( 27 | name: "IconSelectorTests", 28 | dependencies: ["IconSelector"], 29 | path: "src/IconSelectorTests"), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![IconSelector](resources/banner.svg) 2 | 3 | # IconSelector 4 | 5 | A drop-in UI component to allow easy selection of alternate icons on iOS. 6 | 7 | ## Why? 8 | 9 | Adding alternative icons to an iOS app isn't entirely straightforward, but it should be. It's tricky enough to add the entries to your Info.plist, let alone implement UI that you can display on both iPhone and iPad. This library takes care of that last part, and leaves you the much easier task of choosing the icons you want to add! 10 | 11 | ## Features 12 | 13 | - Build on top of `UIControl`. 14 | - Adjustable borders, padding, labels, etc. 15 | - Drop in as-is, or use it to power custom UI. 16 | - Compatible with iOS 10.3 and above. 17 | 18 | IconSelector in Slopes 19 | IconSelector in GIFwrapped 20 | 21 | ## Installation 22 | 23 | ### [Swift Package Manager](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app) (for Apple platforms only) 24 | 25 | In Xcode, select _File_ > _Swift Packages_ > _Add Package Dependency_ and enter the repository URL: 26 | 27 | ``` 28 | https://github.com/jellybeansoup/ios-icon-selector 29 | ``` 30 | 31 | ### [Carthage](https://github.com/Carthage/Carthage) 32 | 33 | Add the following line to your `Cartfile`: 34 | 35 | ``` 36 | github "jellybeansoup/ios-icon-selector" 37 | ``` 38 | 39 | ## Getting Started 40 | 41 | Begin by defining your alternate icons under the [`CFBundleIcons`](https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleicons) key within your app's `Info.plist` file. Don't forget to include an entry for your app's primary icon! 42 | 43 | ``` xml 44 | CFBundleIcons 45 | 46 | CFBundleAlternateIcons 47 | 48 | blue 49 | 50 | CFBundleIconFiles 51 | 52 | blue-20 53 | 54 | 55 | 56 | 57 | CFBundlePrimaryIcon 58 | 59 | CFBundleIconFiles 60 | 61 | green-83.5 62 | 63 | 64 | 65 | 66 | ``` 67 | 68 | Next, instantiate the IconSelector and add it to your view heirarchy. The `IconSelector` class inherits from `UIControl`, so you'll also need to add a target/action pair to be notified when the user selects a different icon. 69 | 70 | ``` swift 71 | // Goes at the top of the file. 72 | import IconSelector 73 | 74 | // Retrieve all the icons defined in your app's main bundle. 75 | let icons = Icon.main 76 | 77 | // Instantiate the IconSelector with a target/action combo, and add it to your view hierarchy. 78 | let iconSelector = IconSelector(icons: icons) 79 | iconSelector.addTarget(self, action: #selector(iconSelectorDidChange(_:)), for: .valueChanged) 80 | view.addSubview(iconSelector) 81 | ``` 82 | 83 | Finally, implement the action needed to change the app's icon in response to the user's selection. 84 | 85 | ``` swift 86 | @objc func iconSelectorDidChange(_ iconSelector: IconSelector) { 87 | guard UIApplication.shared.supportsAlternateIcons, let selectedIcon = iconSelector.selectedIcon else { 88 | return 89 | } 90 | 91 | UIApplication.shared.setAlternateIcon(selectedIcon, completionHandler: nil) 92 | } 93 | ``` 94 | 95 | ## Documentation 96 | 97 | You can [find complete documentation for this project here](https://jellybeansoup.github.io/ios-icon-selector/). This documentation is automatically generated with [jazzy](https://github.com/realm/jazzy) from a [GitHub Action](.github/workflows/documentation.yml) and hosted with [GitHub Pages](https://pages.github.com/). 98 | 99 | To generate documentation locally, run `make documentation` or `sh ./scripts/documentation.sh` from the repository's root directory. The output will be generated in the `docs` folder, and should _not_ be included with commits (as the online documentation is automatically generated and updated). 100 | 101 | ## Released under the BSD License 102 | 103 | Copyright © 2021 Daniel Farrelly & Curtis Herbert 104 | 105 | Redistribution and use in source and binary forms, with or without modification, 106 | are permitted provided that the following conditions are met: 107 | 108 | * Redistributions of source code must retain the above copyright notice, this list 109 | of conditions and the following disclaimer. 110 | * Redistributions in binary form must reproduce the above copyright notice, this 111 | list of conditions and the following disclaimer in the documentation and/or 112 | other materials provided with the distribution. 113 | 114 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 115 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 116 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 117 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 118 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 119 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 120 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 121 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 122 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 123 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 124 | -------------------------------------------------------------------------------- /resources/banner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/screenshot-gifwrapped.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellybeansoup/ios-icon-selector/002f7cefa47c9f18716d7be54829d15dec954b1e/resources/screenshot-gifwrapped.jpeg -------------------------------------------------------------------------------- /resources/screenshot-slopes.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellybeansoup/ios-icon-selector/002f7cefa47c9f18716d7be54829d15dec954b1e/resources/screenshot-slopes.jpeg -------------------------------------------------------------------------------- /scripts/documentation.sh: -------------------------------------------------------------------------------- 1 | if ! which bundle &> /dev/null; then 2 | gem install bundler --no-document || echo "failed to install bundle"; 3 | fi 4 | 5 | if ! bundle info jazzy &> /dev/null; then 6 | bundle config set deployment 'true'; 7 | bundle install || echo "failed to install bundle"; 8 | fi 9 | 10 | bundle exec jazzy \ 11 | --module IconSelector \ 12 | --min-acl public \ 13 | --hide-documentation-coverage \ 14 | --title "IconSelector" \ 15 | --author_url https://jellystyle.com \ 16 | --github_url https://github.com/jellybeansoup/ios-icon-selector \ 17 | --theme fullwidth \ 18 | --output ./docs 19 | -------------------------------------------------------------------------------- /src/IconSelector/Icon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Daniel Farrelly & Curtis Herbert 3 | // 4 | // Redistribution and use in source and binary forms, with or without modification, 5 | // are permitted provided that the following conditions are met: 6 | // 7 | // * Redistributions of source code must retain the above copyright notice, this list 8 | // of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above copyright notice, this 10 | // list of conditions and the following disclaimer in the documentation and/or 11 | // other materials provided with the distribution. 12 | // 13 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 16 | // IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 17 | // INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 18 | // BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 20 | // LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 21 | // OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 22 | // ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | // 24 | 25 | import Foundation 26 | import UIKit 27 | 28 | /// An app Icon, as defined in the `Info.plist` 29 | public struct Icon { 30 | 31 | // MARK: Public Properties 32 | 33 | /// Gets the name of this icon 34 | /// - Note: If the name is `nil`, it is safe to assume 35 | /// this is the default icon. 36 | public let name: String? 37 | 38 | /// The user-visible display name for the icon, which is displayed under the icon (and used as the spoken 39 | /// accessibility text) if `IconSelector.shouldDisplayLabels` is set to `true`. 40 | /// - Note: The default value of this property reflects the Info.plist value with the key `CFBundleIconName`. 41 | public var localizedName: String? 42 | 43 | /// Gets an array of the files associated with this icon 44 | public let files: [String] 45 | 46 | /// Gets a flag indicating if this icon has been prerendered or not 47 | public let isPrerendered: Bool 48 | 49 | /// The `Bundle` this icon is found in 50 | private weak var bundle: Bundle? 51 | 52 | /// Gets a flag indicating whether or not this is the currently selected icon 53 | @available(iOSApplicationExtension, unavailable) 54 | public var isCurrent: Bool { 55 | let application = UIApplication.shared 56 | 57 | guard application.supportsAlternateIcons else { 58 | return name == nil 59 | } 60 | 61 | return name == application.alternateIconName 62 | } 63 | 64 | /// Creates an `Icon` using the given name and `Bundle` 65 | /// - Parameters: 66 | /// - named: Name of the icon to create 67 | /// - bundle: Optional `Bundle` to find the icon within; 68 | /// defaults to the `main` bundle. 69 | public init?(named: String, bundle: Bundle = .main) { 70 | let icons: [Icon] 71 | if bundle == .main { 72 | icons = Icon.main 73 | } 74 | else { 75 | icons = Icon.options(for: bundle) 76 | } 77 | 78 | guard let icon = icons.first(where: { $0.name == named }) else { 79 | return nil 80 | } 81 | 82 | self = icon 83 | } 84 | 85 | // MARK: Parsing the Info.plist 86 | 87 | /// Gets the `Icon`s for the `main` `Bundle` 88 | public static let main = options(for: .main) 89 | 90 | /// Gets the default `Icon`, if possible 91 | public static var `default`: Icon? = { 92 | let bundle = Bundle.main 93 | 94 | guard let iconDictionary = bundle.infoDictionary?["CFBundleIcons"] as? [String: Any] else { 95 | return nil 96 | } 97 | 98 | guard let primary = iconDictionary["CFBundlePrimaryIcon"] as? [String: Any] else { 99 | return nil 100 | } 101 | 102 | return Icon(key: nil, dictionary: primary, bundle: bundle) 103 | }() 104 | 105 | /// Gets the `Icon`s defined in the given `Bundle` 106 | /// - Parameter bundle: The `Bundle` to load from 107 | public static func options(for bundle: Bundle) -> [Icon] { 108 | guard let iconDictionary = bundle.infoDictionary?["CFBundleIcons"] as? [String: Any] else { 109 | return [] 110 | } 111 | 112 | var icons: [Icon] = [] 113 | 114 | if let primary = iconDictionary["CFBundlePrimaryIcon"] as? [String: Any] { 115 | icons.append(Icon(key: nil, dictionary: primary, bundle: bundle)) 116 | } 117 | 118 | if let alternate = iconDictionary["CFBundleAlternateIcons"] as? [String: [String: Any]] { 119 | icons.append(contentsOf: alternate.sorted(by: { $0.key > $1.key }).map { Icon(key: $0, dictionary: $1, bundle: bundle) }) 120 | } 121 | 122 | return icons 123 | } 124 | 125 | private init(key: String?, dictionary: [String: Any], bundle: Bundle) { 126 | self.name = key 127 | self.localizedName = dictionary["CFBundleIconName"] as? String 128 | self.files = dictionary["CFBundleIconFiles"] as? [String] ?? [] 129 | self.isPrerendered = dictionary["UIPrerenderedIcon"] as? Bool ?? false 130 | self.bundle = bundle 131 | } 132 | 133 | // MARK: Accessing images 134 | 135 | private static let numberFormatter: NumberFormatter = { 136 | let formatter = NumberFormatter() 137 | formatter.minimumFractionDigits = 0 138 | formatter.maximumFractionDigits = 1 139 | return formatter 140 | }() 141 | 142 | public subscript(_ name: String) -> UIImage? { 143 | guard let bundle = bundle, files.contains(name) else { 144 | return nil 145 | } 146 | 147 | for scale in stride(from: UIScreen.main.scale, through: 1, by: -1) { 148 | if let path = bundle.path(forResource: "\(name)@\(Int(scale))x", ofType: "png") { 149 | let url = URL(fileURLWithPath: path) 150 | if let data = try? Data(contentsOf: url) { 151 | return UIImage(data: data) 152 | } 153 | } 154 | } 155 | 156 | return nil 157 | } 158 | 159 | public subscript(_ size: CGFloat) -> UIImage? { 160 | guard let string = Icon.numberFormatter.string(from: NSNumber(value: Double(size))) else { 161 | return nil 162 | } 163 | 164 | guard let file = files.first(where: { $0.hasSuffix("\(string)x\(string)") || $0.hasSuffix("-\(string)") }) else { 165 | return nil 166 | } 167 | 168 | return self[file] 169 | } 170 | 171 | // MARK: Making copies 172 | 173 | /// Creates a copy of the receiver with the given `localizedName`. 174 | /// - Parameter localizedName: The user-visible display name to give the icon. 175 | public func with(localizedName: String) -> Icon { 176 | var icon = self 177 | icon.localizedName = localizedName 178 | return icon 179 | } 180 | 181 | } 182 | 183 | extension Icon: Equatable { 184 | 185 | public static func == (lhs: Icon, rhs: Icon) -> Bool { 186 | return lhs.name == rhs.name 187 | } 188 | 189 | } 190 | 191 | extension Icon: CustomDebugStringConvertible { 192 | 193 | public var debugDescription: String { 194 | let prerendered = isPrerendered ? "; prerendered": "" 195 | 196 | if let key = name { 197 | return "" 198 | } 199 | else { 200 | return "" 201 | } 202 | } 203 | 204 | } 205 | 206 | -------------------------------------------------------------------------------- /src/IconSelector/IconSelector.h: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Daniel Farrelly & Curtis Herbert 3 | // 4 | // Redistribution and use in source and binary forms, with or without modification, 5 | // are permitted provided that the following conditions are met: 6 | // 7 | // * Redistributions of source code must retain the above copyright notice, this list 8 | // of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above copyright notice, this 10 | // list of conditions and the following disclaimer in the documentation and/or 11 | // other materials provided with the distribution. 12 | // 13 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 16 | // IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 17 | // INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 18 | // BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 20 | // LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 21 | // OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 22 | // ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | // 24 | 25 | @import UIKit; 26 | 27 | //! Project version number for Test. 28 | extern double IconSelectorVersionNumber; 29 | 30 | //! Project version string for Test. 31 | extern const unsigned char IconSelectorVersionString[]; 32 | 33 | -------------------------------------------------------------------------------- /src/IconSelector/IconSelector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Daniel Farrelly & Curtis Herbert 3 | // 4 | // Redistribution and use in source and binary forms, with or without modification, 5 | // are permitted provided that the following conditions are met: 6 | // 7 | // * Redistributions of source code must retain the above copyright notice, this list 8 | // of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above copyright notice, this 10 | // list of conditions and the following disclaimer in the documentation and/or 11 | // other materials provided with the distribution. 12 | // 13 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 16 | // IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 17 | // INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 18 | // BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 20 | // LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 21 | // OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 22 | // ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | // 24 | 25 | import UIKit 26 | 27 | /// A control that presents available icons and allows selection. 28 | /// 29 | /// This control will **not** actually perform any updates based on the user's selection. It is the responsibility of 30 | /// the parent `UIViewController` to perform the update. There are two methods to perform the actual updates. 31 | /// - Read the `selectedIcon` upon the user indicating they're done. 32 | /// - Use `addTarget(_:action:for:)` to enroll in updates for the `.valueChanged` event. 33 | @available(iOSApplicationExtension, unavailable) 34 | public class IconSelector: UIControl, UIGestureRecognizerDelegate { 35 | 36 | public let icons: [Icon] 37 | 38 | internal let scrollView = UIScrollView() 39 | 40 | private let containerView = UIView() 41 | 42 | private var iconViews: [IconView] = [] 43 | 44 | private let gestureRecognizer = GestureRecognizer() 45 | 46 | /// Creates an `IconSelector` in the given frame, with the given icons. 47 | /// - Parameters: 48 | /// - frame: Frame to put this control within 49 | /// - icons: Icons to display 50 | public init(frame: CGRect, icons: [Icon]) { 51 | self.icons = icons 52 | super.init(frame: frame) 53 | initialize() 54 | } 55 | 56 | /// Creates an `IconSelector` in the given frame, with the given `Bundle`. 57 | /// - Parameters: 58 | /// - frame: Frame to put this control within 59 | /// - bundle: `Bundle` to pull icons from; defaults to the `main` bundle. 60 | public convenience init(frame: CGRect, bundle: Bundle = .main) { 61 | self.init(frame: frame, icons: Icon.options(for: bundle)) 62 | } 63 | 64 | /// Creates an `IconSelector` with the given icons 65 | /// - Parameter icons: Icons to display 66 | public convenience init(icons: [Icon]) { 67 | self.init(frame: .zero, icons: icons) 68 | } 69 | 70 | /// Creates an `IconSelector` for the given `Bundle` 71 | /// - Parameter bundle: `Bundle` to load the icons from; defaults to the `main` bundle. 72 | public convenience init(bundle: Bundle = .main) { 73 | self.init(frame: .zero, bundle: bundle) 74 | } 75 | 76 | required init?(coder aDecoder: NSCoder) { 77 | icons = Icon.options(for: Bundle.main) 78 | super.init(coder: aDecoder) 79 | initialize() 80 | } 81 | 82 | private func initialize() { 83 | addSubview(scrollView) 84 | scrollView.addSubview(containerView) 85 | 86 | scrollView.translatesAutoresizingMaskIntoConstraints = false 87 | containerView.translatesAutoresizingMaskIntoConstraints = false 88 | containerView.setContentHuggingPriority(.defaultHigh, for: .vertical) 89 | containerView.setContentHuggingPriority(.defaultHigh, for: .horizontal) 90 | 91 | let viewsDictionary: [String: UIView] = ["rootView": self, "scrollView": scrollView, "containerView": containerView] 92 | addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[scrollView]|", options: [], metrics: nil, views: viewsDictionary)) 93 | addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[scrollView]|", options: [], metrics: nil, views: viewsDictionary)) 94 | scrollView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[containerView]|", options: [], metrics: nil, views: viewsDictionary)) 95 | scrollView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[containerView]|", options: [], metrics: nil, views: viewsDictionary)) 96 | scrollView.addConstraint(scrollView.widthAnchor.constraint(equalTo: containerView.widthAnchor)) 97 | 98 | scrollView.panGestureRecognizer.addTarget(self, action: #selector(handlePreferredGestureRecognizer(_:))) 99 | containerView.layoutMargins = .zero 100 | 101 | gestureRecognizer.delaysTouchesBegan = true 102 | gestureRecognizer.isEnabled = true 103 | gestureRecognizer.delegate = self 104 | gestureRecognizer.addTarget(self, action: #selector(handleGestureRecognizer(_:))) 105 | containerView.addGestureRecognizer(gestureRecognizer) 106 | 107 | // This is not ideal. The control should probably not initialize its own state, but in order to avoid a breaking 108 | // change I feel like its probably best to allow this for the time being. At least by doing it this way, the 109 | // value of the `selectedIcon` property is respected. 110 | selectedIcon = UIApplication.shared.alternateIcon 111 | 112 | prepareIconViews() 113 | } 114 | 115 | public override func didMoveToSuperview() { 116 | registerPreferredGestureRecognizers() 117 | } 118 | 119 | /// Gets the currently selected icon. 120 | public var selectedIcon: Icon? { 121 | didSet { 122 | for iconView in iconViews { 123 | if iconView.icon == selectedIcon { 124 | iconView.isSelected = true 125 | } 126 | else if iconView.isSelected { 127 | iconView.isSelected = false 128 | } 129 | } 130 | } 131 | } 132 | 133 | override public var isEnabled: Bool { 134 | didSet { 135 | containerView.alpha = isEnabled ? 1 : 0.5 136 | containerView.isUserInteractionEnabled = isEnabled 137 | 138 | iconViews.forEach { 139 | if isEnabled { 140 | $0.accessibilityTraits.remove(.notEnabled) 141 | } 142 | else { 143 | $0.accessibilityTraits.insert(.notEnabled) 144 | } 145 | } 146 | } 147 | } 148 | 149 | override public var layoutMargins: UIEdgeInsets { 150 | get { return containerView.layoutMargins } 151 | set { 152 | containerView.layoutMargins = newValue 153 | setNeedsUpdateConstraints() 154 | } 155 | } 156 | 157 | // MARK: Tracking interaction 158 | 159 | private func iconView(at point: CGPoint) -> IconView? { 160 | for subview in containerView.subviews { 161 | let locationInRow = subview.convert(point, from: containerView) 162 | 163 | guard subview.bounds.contains(locationInRow), let iconView = subview as? IconView else { 164 | continue 165 | } 166 | 167 | return iconView 168 | } 169 | 170 | return nil 171 | } 172 | 173 | @objc private func handleGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) { 174 | let location = gestureRecognizer.location(in: containerView) 175 | let highlighted = iconViews.first(where: { $0.isHighlighted }) 176 | 177 | guard isEnabled, bounds.contains(containerView.convert(location, to: self)), let iconView = iconView(at: location) else { 178 | highlighted?.isHighlighted = false 179 | gestureRecognizer.isEnabled = false 180 | gestureRecognizer.isEnabled = true 181 | 182 | return 183 | } 184 | 185 | switch gestureRecognizer.state { 186 | case .began, .changed: 187 | guard highlighted == nil || highlighted!.icon.name == iconView.icon.name else { 188 | highlighted?.isHighlighted = false 189 | gestureRecognizer.isEnabled = false 190 | gestureRecognizer.isEnabled = true 191 | return 192 | } 193 | 194 | guard !iconView.isSelected else { 195 | return 196 | } 197 | 198 | if highlighted?.icon.name != iconView.icon.name { 199 | highlighted?.isHighlighted = false 200 | iconView.isHighlighted = true 201 | UISelectionFeedbackGenerator().selectionChanged() 202 | } 203 | 204 | case .possible, .cancelled, .failed: 205 | highlighted?.isHighlighted = false 206 | 207 | case .ended: 208 | highlighted?.isHighlighted = false 209 | 210 | guard selectedIcon != iconView.icon else { 211 | return 212 | } 213 | 214 | selectedIcon = iconView.icon 215 | sendActions(for: .valueChanged) 216 | 217 | UISelectionFeedbackGenerator().selectionChanged() 218 | 219 | @unknown default: 220 | return 221 | } 222 | } 223 | 224 | class GestureRecognizer: UIGestureRecognizer { 225 | 226 | override func touchesBegan(_ touches: Set, with event: UIEvent) { 227 | state = .began 228 | } 229 | 230 | override func touchesMoved(_ touches: Set, with event: UIEvent) { 231 | state = .changed 232 | } 233 | 234 | override func touchesEnded(_ touches: Set, with event: UIEvent) { 235 | state = .ended 236 | } 237 | 238 | override func touchesCancelled(_ touches: Set, with event: UIEvent) { 239 | state = .cancelled 240 | } 241 | 242 | } 243 | 244 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { 245 | guard let gestureRecognizers = scrollView.gestureRecognizers else { 246 | return false 247 | } 248 | 249 | let gestureRecognizerType = type(of: otherGestureRecognizer) 250 | return gestureRecognizers.lazy.map { type(of: $0) }.contains { $0 == gestureRecognizerType } 251 | } 252 | 253 | /// Gesture recognizers for which we want to cancel our custom gesture recognizer upon any of them becoming active. 254 | /// This collection is used to remember and detach when the icon selector is moved to a different view hierarchy (in 255 | /// `didMoveToSuperview()`). 256 | /// - SeeAlso: `registerPreferredGestureRecognizers()` 257 | private var preferredGestureRecognizers: [UIGestureRecognizer] = [] 258 | 259 | /// Travels up the view hierarchy and registers a target/action pair with any gesture recognisers that should 260 | /// receive precedence over our internal gesture recogniser, causing it to be cancelled when they become active. 261 | private func registerPreferredGestureRecognizers() { 262 | preferredGestureRecognizers.forEach { 263 | $0.removeTarget(self, action: #selector(handlePreferredGestureRecognizer(_:))) 264 | } 265 | 266 | preferredGestureRecognizers.removeAll() 267 | 268 | for ancestor in sequence(first: self, next: { $0.superview }) { 269 | guard let scrollView = ancestor as? UIScrollView else { 270 | continue 271 | } 272 | 273 | scrollView.panGestureRecognizer.addTarget(self, action: #selector(handlePreferredGestureRecognizer(_:))) 274 | preferredGestureRecognizers.append(scrollView.panGestureRecognizer) 275 | } 276 | } 277 | 278 | /// Method added as a target/action pair on preferred gesture recognizers, which cancels our custom 279 | /// `gestureRecogniser` if the preferred gesture's state is (or becomes) active. 280 | /// - Parameter gestureRecognizer: The gesture recogniser calling the method; typically a `UIScrollView`'s 281 | /// `panGestureRecognizer` that we want to avoid conflicting with. 282 | @objc private func handlePreferredGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) { 283 | switch gestureRecognizer.state { 284 | case .began, .changed: 285 | self.gestureRecognizer.state = .cancelled 286 | 287 | default: break 288 | } 289 | } 290 | 291 | // MARK: Laying out content 292 | 293 | /// Gets or sets the size of the icons to display 294 | public var iconSize: CGFloat = 60.0 { 295 | didSet { setNeedsUpdateConstraints() } 296 | } 297 | 298 | /// Gets or sets the width of the selection stroke 299 | public var selectionStrokeWidth: CGFloat = 2.0 { 300 | didSet { setNeedsUpdateConstraints() } 301 | } 302 | 303 | /// Gets or sets the stroke color for **un**selected icons 304 | public var unselectedStrokeColor: UIColor? { 305 | didSet { iconViews.forEach({ $0.unselectedStrokeColor = unselectedStrokeColor}) } 306 | } 307 | 308 | /// Flag to indicate if icon labels should be displayed. Defaults to `false`. 309 | public var shouldDisplayLabels: Bool = false { 310 | didSet { iconViews.forEach({ $0.shouldDisplayLabel = shouldDisplayLabels}) } 311 | } 312 | 313 | /// Flag to indicate if the icons are lined up with the leading and trailing edges of the `IconSelector` view. 314 | /// 315 | /// When `true`, the spacing between the horizontal edges of the parent view is fixed, pinning the respective edges 316 | /// of the icons within the first and last columns to the `IconSelector` view. This is useful when lining the 317 | /// horizontal edges of the `IconSelector` up with other edges, such as displaying within a table view, as the 318 | /// visual edges are fixed. 319 | /// 320 | /// When `false` the spacing between the horizontal edges of the parent view is flexible, and sized to match the 321 | /// space between the icons themselves. This is useful when pinning the `IconSelector` itself to the edges of the 322 | /// device's screen, as it ensures even spacing for the icons, replicating the look of the iOS springboard layout. 323 | /// 324 | /// - Note: The width of icon labels (if enabled) cannot exceed the width of the icons themselves if this setting is 325 | /// enabled, and will be truncated as required. If edges are not anchored, icon labels can fill the available space 326 | /// as needed, similar to how they would be displayed on the iOS springboard. 327 | public var anchorHorizontalEdges: Bool = true { 328 | didSet { setNeedsUpdateConstraints() } 329 | } 330 | 331 | /// Gets or sets a flag to have this control adjust 332 | /// its height to fit the content it is displaying. 333 | public var adjustHeightToFitContent: Bool = false { 334 | didSet { setNeedsUpdateConstraints() } 335 | } 336 | 337 | private var minimumSpacing: CGFloat = 20.0 338 | 339 | private var iconsPerRow = 4 340 | 341 | private var internalConstraints: [NSLayoutConstraint]? 342 | 343 | override public func layoutSubviews() { 344 | prepareIconViews() 345 | 346 | scrollView.clipsToBounds = !adjustHeightToFitContent 347 | scrollView.isScrollEnabled = !adjustHeightToFitContent 348 | 349 | let width = bounds.size.width - (containerView.layoutMargins.left + containerView.layoutMargins.right) 350 | minimumSpacing = iconSize / 3 351 | iconsPerRow = max(1, Int(floor(width / (iconSize + minimumSpacing)))) 352 | 353 | setNeedsUpdateConstraints() 354 | 355 | super.layoutSubviews() 356 | } 357 | 358 | private func prepareIconViews() { 359 | if let first = iconViews.first, first.size == iconSize, first.borderWidth == selectionStrokeWidth { 360 | return 361 | } 362 | 363 | iconViews = icons.map { icon in 364 | let view = IconView(icon: icon, size: iconSize, borderWidth: selectionStrokeWidth) 365 | view.unselectedStrokeColor = unselectedStrokeColor 366 | view.shouldDisplayLabel = shouldDisplayLabels 367 | view.isSelected = (selectedIcon == icon) 368 | return view 369 | } 370 | 371 | setNeedsUpdateConstraints() 372 | } 373 | 374 | private func prepareConstraints() { 375 | var newConstraints: [NSLayoutConstraint] = [] 376 | 377 | if adjustHeightToFitContent { 378 | newConstraints.append(contentsOf: [ 379 | containerView.topAnchor.constraint(equalTo: topAnchor), 380 | containerView.bottomAnchor.constraint(equalTo: bottomAnchor), 381 | ]) 382 | } 383 | 384 | var currentXAnchors: [Int: NSLayoutXAxisAnchor] = [:] 385 | var currentYAnchors: (top: NSLayoutYAxisAnchor, bottom: NSLayoutYAxisAnchor)? 386 | var previousXAnchor: NSLayoutXAxisAnchor? 387 | var previousYAnchor: NSLayoutYAxisAnchor? 388 | var spacerXDimension: NSLayoutDimension? 389 | var spacerYDimension: NSLayoutDimension? 390 | var iconXDimension: NSLayoutDimension? 391 | 392 | containerView.subviews.forEach { $0.removeFromSuperview() } 393 | 394 | let edgeXAnchors: (leading: NSLayoutXAxisAnchor, trailing: NSLayoutXAxisAnchor) 395 | if anchorHorizontalEdges { 396 | edgeXAnchors = (containerView.layoutMarginsGuide.leadingAnchor, containerView.layoutMarginsGuide.trailingAnchor) 397 | } 398 | else if let firstIconView = iconViews.first { 399 | let widthSpacer = UIView() 400 | widthSpacer.alpha = 0 401 | widthSpacer.translatesAutoresizingMaskIntoConstraints = false 402 | containerView.addSubview(widthSpacer) 403 | 404 | let leadingSpacer = UIView() 405 | leadingSpacer.alpha = 0 406 | leadingSpacer.translatesAutoresizingMaskIntoConstraints = false 407 | containerView.addSubview(leadingSpacer) 408 | 409 | let trailingSpacer = UIView() 410 | trailingSpacer.alpha = 0 411 | trailingSpacer.translatesAutoresizingMaskIntoConstraints = false 412 | containerView.addSubview(trailingSpacer) 413 | 414 | newConstraints.append(widthSpacer.leadingAnchor.constraint(equalTo: firstIconView.leadingAnchor)) 415 | newConstraints.append(widthSpacer.trailingAnchor.constraint(equalTo: firstIconView.imageView.leadingAnchor)) 416 | newConstraints.append(widthSpacer.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor)) 417 | newConstraints.append(widthSpacer.heightAnchor.constraint(equalToConstant: 0)) 418 | 419 | newConstraints.append(leadingSpacer.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor)) 420 | newConstraints.append(leadingSpacer.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor)) 421 | newConstraints.append(leadingSpacer.heightAnchor.constraint(equalToConstant: 0)) 422 | newConstraints.append(leadingSpacer.widthAnchor.constraint(equalTo: widthSpacer.widthAnchor)) 423 | 424 | newConstraints.append(trailingSpacer.trailingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor)) 425 | newConstraints.append(trailingSpacer.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor)) 426 | newConstraints.append(trailingSpacer.heightAnchor.constraint(equalToConstant: 0)) 427 | newConstraints.append(trailingSpacer.widthAnchor.constraint(equalTo: widthSpacer.widthAnchor)) 428 | 429 | edgeXAnchors = (leadingSpacer.trailingAnchor, trailingSpacer.leadingAnchor) 430 | } 431 | else { 432 | return // No need to continue, we don't have any icons anyway. 433 | } 434 | 435 | for (i, iconView) in iconViews.enumerated() { 436 | containerView.addSubview(iconView) 437 | 438 | if let (topAnchor, bottomAnchor) = currentYAnchors { 439 | // Vertical constraints for subsequent (_not_ first/leading) icons within the current row 440 | 441 | newConstraints.append(iconView.topAnchor.constraint(equalTo: topAnchor)) 442 | newConstraints.append(iconView.bottomAnchor.constraint(equalTo: bottomAnchor)) 443 | } 444 | else if let anchor = previousYAnchor { 445 | // Vertical constraints for first (leading) icon in subsequent (_not_ first/top) rows 446 | 447 | let spacer = UIView() // Vertical spacer 448 | spacer.alpha = 0 449 | spacer.translatesAutoresizingMaskIntoConstraints = false 450 | containerView.addSubview(spacer) 451 | 452 | newConstraints.append(spacer.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor)) 453 | newConstraints.append(spacer.topAnchor.constraint(equalTo: anchor)) 454 | newConstraints.append(spacer.widthAnchor.constraint(equalToConstant: 0)) 455 | newConstraints.append(iconView.topAnchor.constraint(equalTo: spacer.bottomAnchor)) 456 | 457 | let spacerHeight = spacer.heightAnchor.constraint(equalToConstant: minimumSpacing) 458 | spacerHeight.priority = UILayoutPriority(rawValue: UILayoutPriority.required.rawValue - 10) 459 | newConstraints.append(spacerHeight) 460 | 461 | if let spacerYDimension = spacerYDimension { 462 | newConstraints.append(spacer.heightAnchor.constraint(equalTo: spacerYDimension)) 463 | } 464 | else { 465 | spacerYDimension = spacer.heightAnchor 466 | } 467 | 468 | currentYAnchors = (top: spacer.bottomAnchor, bottom: iconView.bottomAnchor) 469 | previousYAnchor = iconView.bottomAnchor 470 | } 471 | else { 472 | // Vertical constraints for first (leading) icon in first (top) row 473 | 474 | newConstraints.append(iconView.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor)) 475 | 476 | currentYAnchors = (top: iconView.topAnchor, bottom: iconView.bottomAnchor) 477 | previousYAnchor = iconView.bottomAnchor 478 | } 479 | 480 | if let anchor = currentXAnchors[i % iconsPerRow] { 481 | // Horizontal constraints for subsequent (_not_ first/top) icons within the current column 482 | 483 | newConstraints.append(iconView.leadingAnchor.constraint(equalTo: anchor)) 484 | } 485 | else if let anchor = previousXAnchor { 486 | // Horizontal constraints for for first (top) icon in subsequent (_not_ first/leading) columns 487 | 488 | let spacer = UIView() // Horizontal spacer 489 | spacer.alpha = 0 490 | spacer.translatesAutoresizingMaskIntoConstraints = false 491 | containerView.addSubview(spacer) 492 | 493 | newConstraints.append(spacer.leadingAnchor.constraint(equalTo: anchor)) 494 | newConstraints.append(spacer.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor)) 495 | newConstraints.append(spacer.heightAnchor.constraint(equalToConstant: 0)) 496 | newConstraints.append(iconView.leadingAnchor.constraint(equalTo: spacer.trailingAnchor)) 497 | 498 | if anchorHorizontalEdges { 499 | newConstraints.append(spacer.widthAnchor.constraint(greaterThanOrEqualToConstant: minimumSpacing)) 500 | } 501 | else { 502 | newConstraints.append(spacer.widthAnchor.constraint(equalToConstant: 0)) 503 | } 504 | 505 | if let spacerXDimension = spacerXDimension { 506 | newConstraints.append(spacer.widthAnchor.constraint(equalTo: spacerXDimension)) 507 | } 508 | else { 509 | spacerXDimension = spacer.widthAnchor 510 | } 511 | 512 | currentXAnchors[i] = spacer.trailingAnchor 513 | } 514 | else { 515 | // Vertical constraints for first (top) icon in first (leading) column 516 | 517 | newConstraints.append(iconView.leadingAnchor.constraint(equalTo: edgeXAnchors.leading)) 518 | 519 | iconXDimension = iconView.widthAnchor 520 | } 521 | 522 | previousXAnchor = iconView.trailingAnchor 523 | 524 | if let dimension = iconXDimension, dimension != iconView.widthAnchor { // Match widths to first icon 525 | newConstraints.append(iconView.widthAnchor.constraint(equalTo: dimension)) 526 | } 527 | else if let first = iconViews.first, iconView != first { 528 | newConstraints.append(iconView.widthAnchor.constraint(equalTo: first.widthAnchor)) 529 | } 530 | 531 | if i == iconViews.count - 1 { // Last in array 532 | newConstraints.append(iconView.bottomAnchor.constraint(equalTo: containerView.layoutMarginsGuide.bottomAnchor)) 533 | } 534 | 535 | if i % iconsPerRow == iconsPerRow - 1 { // Last in row 536 | previousXAnchor = nil 537 | currentYAnchors = nil 538 | 539 | newConstraints.append(iconView.trailingAnchor.constraint(equalTo: edgeXAnchors.trailing)) 540 | } 541 | } 542 | 543 | NSLayoutConstraint.activate(newConstraints) 544 | internalConstraints = newConstraints 545 | } 546 | 547 | override public func updateConstraints() { 548 | if let internalConstraints = internalConstraints { 549 | NSLayoutConstraint.deactivate(internalConstraints) 550 | } 551 | 552 | prepareConstraints() 553 | 554 | super.updateConstraints() 555 | } 556 | 557 | } 558 | -------------------------------------------------------------------------------- /src/IconSelector/IconSelectorViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Daniel Farrelly & Curtis Herbert 3 | // 4 | // Redistribution and use in source and binary forms, with or without modification, 5 | // are permitted provided that the following conditions are met: 6 | // 7 | // * Redistributions of source code must retain the above copyright notice, this list 8 | // of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above copyright notice, this 10 | // list of conditions and the following disclaimer in the documentation and/or 11 | // other materials provided with the distribution. 12 | // 13 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 16 | // IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 17 | // INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 18 | // BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 20 | // LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 21 | // OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 22 | // ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | // 24 | 25 | import UIKit 26 | 27 | /// A very simple view controller implementation of the `IconSelector`, which can be instantiated to display a custom 28 | /// collection of icons, or pull the complete list from a given bundle. 29 | @available(iOSApplicationExtension, unavailable) 30 | open class IconSelectorViewController: UITableViewController { 31 | 32 | /// The icons displayed by the receiver. 33 | public let icons: [Icon] 34 | 35 | /// Creates an `IconSelectorViewController` with the given `icons`. 36 | /// - Parameter icons: Icons to display 37 | public init(icons: [Icon]) { 38 | self.icons = icons 39 | super.init(nibName: nil, bundle: nil) 40 | } 41 | 42 | /// Creates an `IconSelectorViewController` with icons from the given `bundle`. 43 | /// Icons should be defined within the `CFBundleIcons` value of the given `bundle`'s Info.plist. 44 | /// - Parameter bundle: The `Bundle` to source icons from. Defaults to the `main` bundle. 45 | public convenience init(bundle: Bundle = .main) { 46 | self.init(icons: Icon.options(for: bundle)) 47 | } 48 | 49 | public required convenience init?(coder: NSCoder) { 50 | self.init() 51 | } 52 | 53 | // MARK: View life cycle 54 | 55 | /// Returns the icon selector managed by the controller object. 56 | public var iconSelector: IconSelector? { 57 | return view as? IconSelector 58 | } 59 | 60 | public override func loadView() { 61 | let iconSelector = IconSelector(icons: icons) 62 | iconSelector.backgroundColor = UIColor.white 63 | iconSelector.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0) 64 | iconSelector.adjustHeightToFitContent = false 65 | iconSelector.anchorHorizontalEdges = false 66 | iconSelector.shouldDisplayLabels = true 67 | iconSelector.scrollView.alwaysBounceVertical = true 68 | iconSelector.addTarget(self, action: #selector(didSelectIcon(_:)), for: .valueChanged) 69 | view = iconSelector 70 | } 71 | 72 | /// Method called when the icon selector is interacted with. 73 | @objc private func didSelectIcon(_ sender: IconSelector) { 74 | guard let selectedIcon = sender.selectedIcon else { 75 | return 76 | } 77 | 78 | self.iconSelector(sender, didChangeValue: selectedIcon) 79 | } 80 | 81 | // MARK: Responding to selection 82 | 83 | /// Method called when the icon is selected within the view controller. 84 | /// 85 | /// - Note: The default implementation validates that alternate icons are supported, and that the application is in 86 | /// an active state before attempting to change the selected icon. If the application is not in an active state, 87 | /// the application will loop until it is. 88 | /// - Parameters: 89 | /// - iconSelector: The `IconSelector` that the `selectedIcon` was selected in. 90 | /// - selectedIcon: The `Icon` that was selected. 91 | open func iconSelector(_ iconSelector: IconSelector, didChangeValue selectedIcon: Icon) { 92 | guard UIApplication.shared.supportsAlternateIcons else { 93 | return 94 | } 95 | 96 | guard UIApplication.shared.applicationState == .active else { 97 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 98 | self.iconSelector(iconSelector, didChangeValue: selectedIcon) 99 | } 100 | 101 | return 102 | } 103 | 104 | UIApplication.shared.setAlternateIcon(selectedIcon) { error in 105 | guard let error = error else { 106 | return 107 | } 108 | 109 | self.iconSelector(iconSelector, didFailWith: error) 110 | } 111 | } 112 | 113 | /// Method called when the application throws an error upon attempting to select an icon. 114 | /// 115 | /// - Note: The default implementation does nothing, which is a mostly valid option. The only errors really thrown 116 | /// are to indicate that an invalid icon was selected, so as long as the icons you provide during init are 117 | /// sourced from the Info.plist (the default option), you're golden. 118 | /// - Parameters: 119 | /// - iconSelector: The `IconSelector` that the `selectedIcon` was selected in. 120 | /// - error: The `Error` that was thrown by the system. 121 | open func iconSelector(_ iconSelector: IconSelector, didFailWith error: Swift.Error) { 122 | 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/IconSelector/IconView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Daniel Farrelly & Curtis Herbert 3 | // 4 | // Redistribution and use in source and binary forms, with or without modification, 5 | // are permitted provided that the following conditions are met: 6 | // 7 | // * Redistributions of source code must retain the above copyright notice, this list 8 | // of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above copyright notice, this 10 | // list of conditions and the following disclaimer in the documentation and/or 11 | // other materials provided with the distribution. 12 | // 13 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 16 | // IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 17 | // INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 18 | // BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 20 | // LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 21 | // OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 22 | // ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | // 24 | 25 | import UIKit 26 | 27 | class IconView: UIView { 28 | 29 | let icon: Icon 30 | 31 | let size: CGFloat 32 | 33 | let borderWidth: CGFloat 34 | 35 | var unselectedStrokeColor: UIColor? { 36 | didSet { 37 | updateUnselectedBorder() 38 | } 39 | } 40 | 41 | internal let borderView: UIView = BorderView() 42 | 43 | internal let imageView: UIImageView = ImageView() 44 | 45 | internal let label = UILabel() 46 | 47 | private var strokeWidth: CGFloat = 0.0 48 | 49 | init(icon: Icon, size: CGFloat, borderWidth: CGFloat) { 50 | self.icon = icon 51 | self.size = size 52 | self.borderWidth = borderWidth 53 | self.labelHeightConstraint = label.heightAnchor.constraint(equalToConstant: 0) 54 | 55 | super.init(frame: CGRect(x: 0, y: 0, width: size + (borderWidth * 2), height: size + (borderWidth * 2))) 56 | 57 | backgroundColor = UIColor.clear 58 | clipsToBounds = false 59 | layoutMargins = UIEdgeInsets(top: borderWidth, left: borderWidth, bottom: borderWidth, right: borderWidth) 60 | translatesAutoresizingMaskIntoConstraints = false 61 | accessibilityLabel = icon.localizedName ?? icon.name 62 | accessibilityTraits = .button 63 | isAccessibilityElement = true 64 | 65 | borderView.clipsToBounds = true 66 | borderView.layer.masksToBounds = true 67 | borderView.backgroundColor = UIColor.clear 68 | borderView.translatesAutoresizingMaskIntoConstraints = false 69 | addSubview(borderView) 70 | 71 | imageView.image = icon[size] 72 | imageView.clipsToBounds = true 73 | imageView.contentMode = .scaleAspectFit 74 | imageView.layer.masksToBounds = true 75 | imageView.translatesAutoresizingMaskIntoConstraints = false 76 | borderView.addSubview(imageView) 77 | 78 | label.text = icon.localizedName ?? icon.name 79 | label.font = UIFont.systemFont(ofSize: labelFontSize(for: traitCollection.preferredContentSizeCategory)) 80 | label.textAlignment = .center 81 | label.translatesAutoresizingMaskIntoConstraints = false 82 | label.allowsDefaultTighteningForTruncation = true 83 | label.adjustsFontForContentSizeCategory = false 84 | label.setContentHuggingPriority(.defaultHigh, for: .vertical) 85 | label.setContentHuggingPriority(.defaultLow, for: .horizontal) 86 | addSubview(label) 87 | 88 | if #available(iOS 13.4, *), NSClassFromString("UIPointerInteraction") != nil { 89 | addInteraction(UIPointerInteraction(delegate: self)) 90 | } 91 | 92 | prepareConstraints() 93 | updateUnselectedBorder() 94 | } 95 | 96 | required init?(coder aDecoder: NSCoder) { 97 | fatalError("init(coder:) has not been implemented") 98 | } 99 | 100 | private func prepareConstraints() { 101 | borderView.centerXAnchor.constraint(equalTo: label.centerXAnchor).isActive = true 102 | borderView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor).isActive = true 103 | borderView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor).isActive = true 104 | borderView.topAnchor.constraint(equalTo: topAnchor).isActive = true 105 | 106 | imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor).isActive = true 107 | imageView.leadingAnchor.constraint(equalTo: borderView.leadingAnchor, constant: borderWidth).isActive = true 108 | imageView.trailingAnchor.constraint(equalTo: borderView.trailingAnchor, constant: -borderWidth).isActive = true 109 | imageView.topAnchor.constraint(equalTo: borderView.topAnchor, constant: borderWidth).isActive = true 110 | imageView.bottomAnchor.constraint(equalTo: borderView.bottomAnchor, constant: -borderWidth).isActive = true 111 | imageView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -borderWidth).isActive = true 112 | 113 | label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor).isActive = true 114 | label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor).isActive = true 115 | label.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true 116 | 117 | // We allow the following constraints to be broken as needed to stop the auto layout system from chucking a 118 | // tanty when the selector is contained within a table view cell. 119 | 120 | let width = imageView.widthAnchor.constraint(equalToConstant: size) 121 | width.priority = UILayoutPriority(rawValue: UILayoutPriority.required.rawValue - 10) 122 | width.isActive = true 123 | 124 | let spacing = borderView.bottomAnchor.constraint(equalTo: label.topAnchor, constant: -5) 125 | spacing.priority = UILayoutPriority(rawValue: UILayoutPriority.required.rawValue - 10) 126 | spacing.isActive = true 127 | } 128 | 129 | override func layoutSubviews() { 130 | super.layoutSubviews() 131 | 132 | updateUnselectedBorder() 133 | } 134 | 135 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 136 | super.traitCollectionDidChange(previousTraitCollection) 137 | 138 | if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { 139 | label.font = label.font?.withSize(labelFontSize(for: traitCollection.preferredContentSizeCategory)) 140 | } 141 | } 142 | 143 | private func labelFontSize(for contentSizeCategory: UIContentSizeCategory) -> CGFloat { 144 | switch contentSizeCategory { 145 | case .extraSmall: return 11.0 146 | case .small: return 11.5 147 | case .medium: return 12.0 148 | case .large: return 12.5 149 | case .extraLarge: return 13.0 150 | case .extraExtraLarge: return 13.5 151 | case .extraExtraExtraLarge: return 14.0 152 | case .accessibilityMedium: return 14.5 153 | case .accessibilityLarge: return 15.0 154 | case .accessibilityExtraLarge: return 15.5 155 | case .accessibilityExtraExtraLarge: return 16.0 156 | case .accessibilityExtraExtraExtraLarge: return 16.5 157 | default: return 12.0 158 | } 159 | } 160 | 161 | private func updateUnselectedBorder() { 162 | if !isSelected, let color = unselectedStrokeColor { 163 | (imageView as? ImageView)?.borderLayer.strokeColor = color.cgColor 164 | } 165 | else { 166 | (imageView as? ImageView)?.borderLayer.strokeColor = UIColor.clear.cgColor 167 | } 168 | } 169 | 170 | override func tintColorDidChange() { 171 | guard isSelected else { 172 | return 173 | } 174 | 175 | borderView.backgroundColor = tintColor 176 | } 177 | 178 | private class BorderView: UIView { 179 | 180 | private var shapeMask = CAShapeLayer() 181 | 182 | override func layoutSubviews() { 183 | super.layoutSubviews() 184 | 185 | shapeMask.path = UIBezierPath(roundedRect: bounds, cornerRadius: bounds.size.width * 0.225).cgPath 186 | layer.mask = shapeMask 187 | } 188 | 189 | } 190 | 191 | private class ImageView: UIImageView { 192 | 193 | private var shapeMask = CAShapeLayer() 194 | 195 | var borderLayer: CAShapeLayer = { 196 | let layer = CAShapeLayer() 197 | layer.strokeColor = UIColor.clear.cgColor 198 | layer.fillColor = UIColor.clear.cgColor 199 | return layer 200 | }() 201 | 202 | override func layoutSubviews() { 203 | super.layoutSubviews() 204 | 205 | let path = UIBezierPath(roundedRect: bounds, cornerRadius: bounds.size.width * 0.225) 206 | 207 | borderLayer.lineWidth = 2.0 / (window?.screen ?? .main).scale 208 | borderLayer.path = path.cgPath 209 | layer.addSublayer(borderLayer) 210 | 211 | shapeMask.path = path.cgPath 212 | layer.mask = shapeMask 213 | } 214 | 215 | } 216 | 217 | // MARK: Displaying labels 218 | 219 | private var labelHeightConstraint: NSLayoutConstraint 220 | 221 | var shouldDisplayLabel: Bool { 222 | get { return !labelHeightConstraint.isActive } 223 | set { 224 | label.alpha = newValue ? 1 : 0 225 | labelHeightConstraint.isActive = !newValue 226 | } 227 | } 228 | 229 | // MARK: Selection 230 | 231 | var isSelected: Bool { 232 | get { 233 | return accessibilityTraits.contains(.selected) 234 | } 235 | set { 236 | if newValue { 237 | borderView.backgroundColor = tintColor 238 | accessibilityTraits.insert(.selected) 239 | } 240 | else { 241 | borderView.backgroundColor = UIColor.clear 242 | accessibilityTraits.remove(.selected) 243 | } 244 | 245 | updateUnselectedBorder() 246 | } 247 | } 248 | 249 | // MARK: Highlighting 250 | 251 | var isHighlighted: Bool { 252 | get { return highlightedView != nil } 253 | set { 254 | if newValue { 255 | let view = HighlightedView(frame: bounds.insetBy(dx: -borderWidth, dy: -borderWidth)) 256 | borderView.addSubview(view) 257 | highlightedView = view 258 | } 259 | else { 260 | highlightedView?.removeFromSuperview() 261 | highlightedView = nil 262 | } 263 | } 264 | } 265 | 266 | private var highlightedView: HighlightedView? 267 | 268 | private class HighlightedView: UIView { 269 | 270 | override init(frame: CGRect) { 271 | super.init(frame: frame) 272 | 273 | backgroundColor = UIColor(white: 0, alpha: 0.6) 274 | layer.masksToBounds = true 275 | } 276 | 277 | required init?(coder aDecoder: NSCoder) { 278 | fatalError("init(coder:) has not been implemented") 279 | } 280 | 281 | } 282 | 283 | } 284 | 285 | @available(iOS 13.4, *) 286 | extension IconView: UIPointerInteractionDelegate { 287 | 288 | func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { 289 | guard !isSelected else { 290 | return nil 291 | } 292 | 293 | return UIPointerStyle(effect: .lift(UITargetedPreview(view: borderView))) 294 | } 295 | 296 | } 297 | -------------------------------------------------------------------------------- /src/IconSelector/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/IconSelector/UIApplication.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Daniel Farrelly & Curtis Herbert 3 | // 4 | // Redistribution and use in source and binary forms, with or without modification, 5 | // are permitted provided that the following conditions are met: 6 | // 7 | // * Redistributions of source code must retain the above copyright notice, this list 8 | // of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above copyright notice, this 10 | // list of conditions and the following disclaimer in the documentation and/or 11 | // other materials provided with the distribution. 12 | // 13 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 16 | // IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 17 | // INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 18 | // BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 20 | // LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 21 | // OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 22 | // ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | // 24 | 25 | import Foundation 26 | import UIKit 27 | 28 | extension UIApplication { 29 | 30 | /// Gets or sets an alternate icon to use 31 | public var alternateIcon: Icon? { 32 | get { 33 | guard supportsAlternateIcons, let name = alternateIconName else { 34 | return Icon.default 35 | } 36 | 37 | return Icon(named: name) 38 | } 39 | set { 40 | setAlternateIcon(newValue, completionHandler: nil) 41 | } 42 | } 43 | 44 | /// Sets a new alternate icon 45 | /// - Parameters: 46 | /// - icon: Icon to set; use `nil` to restore the default 47 | /// - completionHandler: optional completion handler 48 | public func setAlternateIcon(_ icon: Icon?, completionHandler: ((_ error: Swift.Error?) -> Void)? = nil) { 49 | guard supportsAlternateIcons else { 50 | return 51 | } 52 | 53 | setAlternateIconName(icon?.name, completionHandler: completionHandler) 54 | } 55 | 56 | } 57 | 58 | -------------------------------------------------------------------------------- /src/IconSelectorTests/IconSelectorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Daniel Farrelly & Curtis Herbert 3 | // 4 | // Redistribution and use in source and binary forms, with or without modification, 5 | // are permitted provided that the following conditions are met: 6 | // 7 | // * Redistributions of source code must retain the above copyright notice, this list 8 | // of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above copyright notice, this 10 | // list of conditions and the following disclaimer in the documentation and/or 11 | // other materials provided with the distribution. 12 | // 13 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 16 | // IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 17 | // INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 18 | // BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 20 | // LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 21 | // OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 22 | // ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | // 24 | 25 | import XCTest 26 | 27 | class IconSelectorTests: XCTestCase { 28 | 29 | override func setUp() { 30 | // Put setup code here. This method is called before the invocation of each test method in the class. 31 | } 32 | 33 | override func tearDown() { 34 | // Put teardown code here. This method is called after the invocation of each test method in the class. 35 | } 36 | 37 | func testExample() { 38 | // This is an example of a functional test case. 39 | // Use XCTAssert and related functions to verify your tests produce the correct results. 40 | } 41 | 42 | func testPerformanceExample() { 43 | // This is an example of a performance test case. 44 | self.measure { 45 | // Put the code you want to measure the time of here. 46 | } 47 | } 48 | 49 | } 50 | 51 | -------------------------------------------------------------------------------- /src/IconSelectorTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | --------------------------------------------------------------------------------