├── .gitignore ├── Assets ├── Demo.mp4 ├── Header.png ├── Logo.png ├── Showroom_1.png └── Showroom_2.png ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── PillPickerViewExample │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon.png │ │ └── Contents.json │ └── Contents.json │ └── PillPickerViewExample.swift ├── Package.swift ├── README.md └── Sources └── PillPickerView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Assets/Demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defaultdino/PillPickerView/186ce51f1a3abda0ab4ade4025e78d1811353523/Assets/Demo.mp4 -------------------------------------------------------------------------------- /Assets/Header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defaultdino/PillPickerView/186ce51f1a3abda0ab4ade4025e78d1811353523/Assets/Header.png -------------------------------------------------------------------------------- /Assets/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defaultdino/PillPickerView/186ce51f1a3abda0ab4ade4025e78d1811353523/Assets/Logo.png -------------------------------------------------------------------------------- /Assets/Showroom_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defaultdino/PillPickerView/186ce51f1a3abda0ab4ade4025e78d1811353523/Assets/Showroom_1.png -------------------------------------------------------------------------------- /Assets/Showroom_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defaultdino/PillPickerView/186ce51f1a3abda0ab4ade4025e78d1811353523/Assets/Showroom_2.png -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | D4A238012A18DD700081FCAB /* PillPickerViewExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A238002A18DD700081FCAB /* PillPickerViewExample.swift */; }; 11 | D4A238052A18DD710081FCAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D4A238042A18DD710081FCAB /* Assets.xcassets */; }; 12 | D4A238122A18DE080081FCAB /* PillPickerView in Frameworks */ = {isa = PBXBuildFile; productRef = D4A238112A18DE080081FCAB /* PillPickerView */; }; 13 | /* End PBXBuildFile section */ 14 | 15 | /* Begin PBXFileReference section */ 16 | D4A237FD2A18DD700081FCAB /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 17 | D4A238002A18DD700081FCAB /* PillPickerViewExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillPickerViewExample.swift; sourceTree = ""; }; 18 | D4A238042A18DD710081FCAB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 19 | D4A2380F2A18DDFE0081FCAB /* PillPickerView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = PillPickerView; path = ..; sourceTree = ""; }; 20 | /* End PBXFileReference section */ 21 | 22 | /* Begin PBXFrameworksBuildPhase section */ 23 | D4A237FA2A18DD700081FCAB /* Frameworks */ = { 24 | isa = PBXFrameworksBuildPhase; 25 | buildActionMask = 2147483647; 26 | files = ( 27 | D4A238122A18DE080081FCAB /* PillPickerView in Frameworks */, 28 | ); 29 | runOnlyForDeploymentPostprocessing = 0; 30 | }; 31 | /* End PBXFrameworksBuildPhase section */ 32 | 33 | /* Begin PBXGroup section */ 34 | D4A237F42A18DD700081FCAB = { 35 | isa = PBXGroup; 36 | children = ( 37 | D4A2380E2A18DDFE0081FCAB /* Packages */, 38 | D4A237FF2A18DD700081FCAB /* PillPickerViewExample */, 39 | D4A237FE2A18DD700081FCAB /* Products */, 40 | D4A238102A18DE080081FCAB /* Frameworks */, 41 | ); 42 | sourceTree = ""; 43 | }; 44 | D4A237FE2A18DD700081FCAB /* Products */ = { 45 | isa = PBXGroup; 46 | children = ( 47 | D4A237FD2A18DD700081FCAB /* Example.app */, 48 | ); 49 | name = Products; 50 | sourceTree = ""; 51 | }; 52 | D4A237FF2A18DD700081FCAB /* PillPickerViewExample */ = { 53 | isa = PBXGroup; 54 | children = ( 55 | D4A238002A18DD700081FCAB /* PillPickerViewExample.swift */, 56 | D4A238042A18DD710081FCAB /* Assets.xcassets */, 57 | ); 58 | path = PillPickerViewExample; 59 | sourceTree = ""; 60 | }; 61 | D4A2380E2A18DDFE0081FCAB /* Packages */ = { 62 | isa = PBXGroup; 63 | children = ( 64 | D4A2380F2A18DDFE0081FCAB /* PillPickerView */, 65 | ); 66 | name = Packages; 67 | sourceTree = ""; 68 | }; 69 | D4A238102A18DE080081FCAB /* Frameworks */ = { 70 | isa = PBXGroup; 71 | children = ( 72 | ); 73 | name = Frameworks; 74 | sourceTree = ""; 75 | }; 76 | /* End PBXGroup section */ 77 | 78 | /* Begin PBXNativeTarget section */ 79 | D4A237FC2A18DD700081FCAB /* Example */ = { 80 | isa = PBXNativeTarget; 81 | buildConfigurationList = D4A2380B2A18DD710081FCAB /* Build configuration list for PBXNativeTarget "Example" */; 82 | buildPhases = ( 83 | D4A237F92A18DD700081FCAB /* Sources */, 84 | D4A237FA2A18DD700081FCAB /* Frameworks */, 85 | D4A237FB2A18DD700081FCAB /* Resources */, 86 | ); 87 | buildRules = ( 88 | ); 89 | dependencies = ( 90 | ); 91 | name = Example; 92 | packageProductDependencies = ( 93 | D4A238112A18DE080081FCAB /* PillPickerView */, 94 | ); 95 | productName = Example; 96 | productReference = D4A237FD2A18DD700081FCAB /* Example.app */; 97 | productType = "com.apple.product-type.application"; 98 | }; 99 | /* End PBXNativeTarget section */ 100 | 101 | /* Begin PBXProject section */ 102 | D4A237F52A18DD700081FCAB /* Project object */ = { 103 | isa = PBXProject; 104 | attributes = { 105 | BuildIndependentTargetsInParallel = 1; 106 | LastSwiftUpdateCheck = 1420; 107 | LastUpgradeCheck = 1420; 108 | TargetAttributes = { 109 | D4A237FC2A18DD700081FCAB = { 110 | CreatedOnToolsVersion = 14.2; 111 | }; 112 | }; 113 | }; 114 | buildConfigurationList = D4A237F82A18DD700081FCAB /* Build configuration list for PBXProject "Example" */; 115 | compatibilityVersion = "Xcode 14.0"; 116 | developmentRegion = en; 117 | hasScannedForEncodings = 0; 118 | knownRegions = ( 119 | en, 120 | Base, 121 | ); 122 | mainGroup = D4A237F42A18DD700081FCAB; 123 | productRefGroup = D4A237FE2A18DD700081FCAB /* Products */; 124 | projectDirPath = ""; 125 | projectRoot = ""; 126 | targets = ( 127 | D4A237FC2A18DD700081FCAB /* Example */, 128 | ); 129 | }; 130 | /* End PBXProject section */ 131 | 132 | /* Begin PBXResourcesBuildPhase section */ 133 | D4A237FB2A18DD700081FCAB /* Resources */ = { 134 | isa = PBXResourcesBuildPhase; 135 | buildActionMask = 2147483647; 136 | files = ( 137 | D4A238052A18DD710081FCAB /* Assets.xcassets in Resources */, 138 | ); 139 | runOnlyForDeploymentPostprocessing = 0; 140 | }; 141 | /* End PBXResourcesBuildPhase section */ 142 | 143 | /* Begin PBXSourcesBuildPhase section */ 144 | D4A237F92A18DD700081FCAB /* Sources */ = { 145 | isa = PBXSourcesBuildPhase; 146 | buildActionMask = 2147483647; 147 | files = ( 148 | D4A238012A18DD700081FCAB /* PillPickerViewExample.swift in Sources */, 149 | ); 150 | runOnlyForDeploymentPostprocessing = 0; 151 | }; 152 | /* End PBXSourcesBuildPhase section */ 153 | 154 | /* Begin XCBuildConfiguration section */ 155 | D4A238092A18DD710081FCAB /* Debug */ = { 156 | isa = XCBuildConfiguration; 157 | buildSettings = { 158 | ALWAYS_SEARCH_USER_PATHS = NO; 159 | CLANG_ANALYZER_NONNULL = YES; 160 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 161 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 162 | CLANG_ENABLE_MODULES = YES; 163 | CLANG_ENABLE_OBJC_ARC = YES; 164 | CLANG_ENABLE_OBJC_WEAK = YES; 165 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 166 | CLANG_WARN_BOOL_CONVERSION = YES; 167 | CLANG_WARN_COMMA = YES; 168 | CLANG_WARN_CONSTANT_CONVERSION = YES; 169 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 170 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 171 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 172 | CLANG_WARN_EMPTY_BODY = YES; 173 | CLANG_WARN_ENUM_CONVERSION = YES; 174 | CLANG_WARN_INFINITE_RECURSION = YES; 175 | CLANG_WARN_INT_CONVERSION = YES; 176 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 177 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 178 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 179 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 180 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 181 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 182 | CLANG_WARN_STRICT_PROTOTYPES = YES; 183 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 184 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 185 | CLANG_WARN_UNREACHABLE_CODE = YES; 186 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 187 | COPY_PHASE_STRIP = NO; 188 | DEBUG_INFORMATION_FORMAT = dwarf; 189 | ENABLE_STRICT_OBJC_MSGSEND = YES; 190 | ENABLE_TESTABILITY = YES; 191 | GCC_C_LANGUAGE_STANDARD = gnu11; 192 | GCC_DYNAMIC_NO_PIC = NO; 193 | GCC_NO_COMMON_BLOCKS = YES; 194 | GCC_OPTIMIZATION_LEVEL = 0; 195 | GCC_PREPROCESSOR_DEFINITIONS = ( 196 | "DEBUG=1", 197 | "$(inherited)", 198 | ); 199 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 200 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 201 | GCC_WARN_UNDECLARED_SELECTOR = YES; 202 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 203 | GCC_WARN_UNUSED_FUNCTION = YES; 204 | GCC_WARN_UNUSED_VARIABLE = YES; 205 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 206 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 207 | MTL_FAST_MATH = YES; 208 | ONLY_ACTIVE_ARCH = YES; 209 | SDKROOT = iphoneos; 210 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 211 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 212 | }; 213 | name = Debug; 214 | }; 215 | D4A2380A2A18DD710081FCAB /* Release */ = { 216 | isa = XCBuildConfiguration; 217 | buildSettings = { 218 | ALWAYS_SEARCH_USER_PATHS = NO; 219 | CLANG_ANALYZER_NONNULL = YES; 220 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 221 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 222 | CLANG_ENABLE_MODULES = YES; 223 | CLANG_ENABLE_OBJC_ARC = YES; 224 | CLANG_ENABLE_OBJC_WEAK = YES; 225 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 226 | CLANG_WARN_BOOL_CONVERSION = YES; 227 | CLANG_WARN_COMMA = YES; 228 | CLANG_WARN_CONSTANT_CONVERSION = YES; 229 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 230 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 231 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 232 | CLANG_WARN_EMPTY_BODY = YES; 233 | CLANG_WARN_ENUM_CONVERSION = YES; 234 | CLANG_WARN_INFINITE_RECURSION = YES; 235 | CLANG_WARN_INT_CONVERSION = YES; 236 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 237 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 238 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 239 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 240 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 241 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 242 | CLANG_WARN_STRICT_PROTOTYPES = YES; 243 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 244 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 245 | CLANG_WARN_UNREACHABLE_CODE = YES; 246 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 247 | COPY_PHASE_STRIP = NO; 248 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 249 | ENABLE_NS_ASSERTIONS = NO; 250 | ENABLE_STRICT_OBJC_MSGSEND = YES; 251 | GCC_C_LANGUAGE_STANDARD = gnu11; 252 | GCC_NO_COMMON_BLOCKS = YES; 253 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 254 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 255 | GCC_WARN_UNDECLARED_SELECTOR = YES; 256 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 257 | GCC_WARN_UNUSED_FUNCTION = YES; 258 | GCC_WARN_UNUSED_VARIABLE = YES; 259 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 260 | MTL_ENABLE_DEBUG_INFO = NO; 261 | MTL_FAST_MATH = YES; 262 | SDKROOT = iphoneos; 263 | SWIFT_COMPILATION_MODE = wholemodule; 264 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 265 | VALIDATE_PRODUCT = YES; 266 | }; 267 | name = Release; 268 | }; 269 | D4A2380C2A18DD710081FCAB /* Debug */ = { 270 | isa = XCBuildConfiguration; 271 | buildSettings = { 272 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 273 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 274 | CODE_SIGN_STYLE = Automatic; 275 | CURRENT_PROJECT_VERSION = 1; 276 | DEVELOPMENT_ASSET_PATHS = ""; 277 | DEVELOPMENT_TEAM = 5QHM3X75YP; 278 | ENABLE_PREVIEWS = YES; 279 | GENERATE_INFOPLIST_FILE = YES; 280 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 281 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 282 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 283 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 284 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 285 | LD_RUNPATH_SEARCH_PATHS = ( 286 | "$(inherited)", 287 | "@executable_path/Frameworks", 288 | ); 289 | MARKETING_VERSION = 1.0; 290 | PRODUCT_BUNDLE_IDENTIFIER = com.spark.Example; 291 | PRODUCT_NAME = "$(TARGET_NAME)"; 292 | SWIFT_EMIT_LOC_STRINGS = YES; 293 | SWIFT_VERSION = 5.0; 294 | TARGETED_DEVICE_FAMILY = "1,2"; 295 | }; 296 | name = Debug; 297 | }; 298 | D4A2380D2A18DD710081FCAB /* Release */ = { 299 | isa = XCBuildConfiguration; 300 | buildSettings = { 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 303 | CODE_SIGN_STYLE = Automatic; 304 | CURRENT_PROJECT_VERSION = 1; 305 | DEVELOPMENT_ASSET_PATHS = ""; 306 | DEVELOPMENT_TEAM = 5QHM3X75YP; 307 | ENABLE_PREVIEWS = YES; 308 | GENERATE_INFOPLIST_FILE = YES; 309 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 310 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 311 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 312 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 313 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 314 | LD_RUNPATH_SEARCH_PATHS = ( 315 | "$(inherited)", 316 | "@executable_path/Frameworks", 317 | ); 318 | MARKETING_VERSION = 1.0; 319 | PRODUCT_BUNDLE_IDENTIFIER = com.spark.Example; 320 | PRODUCT_NAME = "$(TARGET_NAME)"; 321 | SWIFT_EMIT_LOC_STRINGS = YES; 322 | SWIFT_VERSION = 5.0; 323 | TARGETED_DEVICE_FAMILY = "1,2"; 324 | }; 325 | name = Release; 326 | }; 327 | /* End XCBuildConfiguration section */ 328 | 329 | /* Begin XCConfigurationList section */ 330 | D4A237F82A18DD700081FCAB /* Build configuration list for PBXProject "Example" */ = { 331 | isa = XCConfigurationList; 332 | buildConfigurations = ( 333 | D4A238092A18DD710081FCAB /* Debug */, 334 | D4A2380A2A18DD710081FCAB /* Release */, 335 | ); 336 | defaultConfigurationIsVisible = 0; 337 | defaultConfigurationName = Release; 338 | }; 339 | D4A2380B2A18DD710081FCAB /* Build configuration list for PBXNativeTarget "Example" */ = { 340 | isa = XCConfigurationList; 341 | buildConfigurations = ( 342 | D4A2380C2A18DD710081FCAB /* Debug */, 343 | D4A2380D2A18DD710081FCAB /* Release */, 344 | ); 345 | defaultConfigurationIsVisible = 0; 346 | defaultConfigurationName = Release; 347 | }; 348 | /* End XCConfigurationList section */ 349 | 350 | /* Begin XCSwiftPackageProductDependency section */ 351 | D4A238112A18DE080081FCAB /* PillPickerView */ = { 352 | isa = XCSwiftPackageProductDependency; 353 | productName = PillPickerView; 354 | }; 355 | /* End XCSwiftPackageProductDependency section */ 356 | }; 357 | rootObject = D4A237F52A18DD700081FCAB /* Project object */; 358 | } 359 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/PillPickerViewExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x6F", 9 | "green" : "0x52", 10 | "red" : "0xEA" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Example/PillPickerViewExample/Assets.xcassets/AppIcon.appiconset/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defaultdino/PillPickerView/186ce51f1a3abda0ab4ade4025e78d1811353523/Example/PillPickerViewExample/Assets.xcassets/AppIcon.appiconset/AppIcon.png -------------------------------------------------------------------------------- /Example/PillPickerViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Example/PillPickerViewExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/PillPickerViewExample/PillPickerViewExample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PillPickerViewExample.swift 3 | // PillPickerViewExample 4 | // 5 | // Created by Adis Veletanlic on 2023-05-20. 6 | // 7 | 8 | import SwiftUI 9 | import PillPickerView 10 | 11 | @main 12 | struct PillPickerViewExample: App { 13 | var body: some Scene { 14 | WindowGroup { 15 | ContentView() 16 | } 17 | } 18 | } 19 | 20 | // MARK: - Example 21 | 22 | struct ContentView: View { 23 | 24 | /// Required collection of items confirming to `Pill` 25 | /// which will be used for tracking which objects 26 | /// are selected 27 | @State private var selectedGenres: [Genre] = [] 28 | 29 | var body: some View { 30 | NavigationView { 31 | VStack { 32 | TabView { 33 | 34 | /// Example view where pills wrap to new line and only occupy 35 | /// necessary space 36 | ExampleBuilder(selectedItems: $selectedGenres, content: { 37 | PillPickerView(items: genres, selectedPills: $selectedGenres) 38 | .pillStackStyle(.wrap) 39 | .pillLeadingIcon(Image(systemName: "popcorn")) 40 | .pillTrailingIcon(Image(systemName: "checkmark")) 41 | .pillTrailingOnlySelected(true) 42 | }) 43 | .tag(0) 44 | .navigationTitle("Wrapping pills") 45 | 46 | /// Example view where pills do not wrap to new line and occupy 47 | /// set amount of space horizontally and vertically 48 | ExampleBuilder(selectedItems: $selectedGenres, content: { 49 | PillPickerView(items: genres, selectedPills: $selectedGenres) 50 | .pillStackStyle(.noWrap) 51 | .pillLeadingIcon(Image(systemName: "popcorn")) 52 | .pillTrailingIcon(Image(systemName: "checkmark")) 53 | .pillTrailingOnlySelected(true) 54 | }) 55 | .tag(1) 56 | .navigationTitle("Static pills") 57 | 58 | /// Set the limit for the number of pills that can be selected, and configure the appropriate options for the disabled state. 59 | ExampleBuilder(selectedItems: $selectedGenres, content: { 60 | PillPickerView(items: genres, selectedPills: $selectedGenres, maxSelectablePills: 5) 61 | .pillStackStyle(.wrap) 62 | .pillLeadingIcon(Image(systemName: "popcorn")) 63 | .pillTrailingIcon(Image(systemName: "checkmark")) 64 | .pillTrailingOnlySelected(true) 65 | .pillDisabledBackgroundColor(.gray.opacity(0.2)) 66 | .pillDisabledForegroundColor(.gray.opacity(0.5)) 67 | 68 | }) 69 | .tag(2) 70 | .navigationTitle("Maximum Selectable Pills") 71 | 72 | } 73 | .tabViewStyle(.page) 74 | .tint(.accentColor) 75 | } 76 | .toolbar(content: { 77 | ToolbarItem(placement: .navigationBarTrailing, content: { 78 | Button(action: { 79 | withAnimation { 80 | selectedGenres.removeAll() 81 | } 82 | }, label: { 83 | Text("Clear All") 84 | }) 85 | }) 86 | }) 87 | } 88 | } 89 | } 90 | 91 | struct ExampleBuilder: View where T: Pill, V: View { 92 | 93 | typealias ContentGenerator = () -> V 94 | 95 | @Binding var selectedItems: [T] 96 | 97 | var content: ContentGenerator 98 | 99 | var body: some View { 100 | ScrollView (showsIndicators: false) { 101 | HStack { 102 | Text("Select Your Favorite Genres") 103 | .font(.system(size: 26, weight: .semibold, design: .rounded)) 104 | Spacer() 105 | } 106 | .padding(.vertical, 30) 107 | 108 | /// PillPickerView usage example 109 | content() 110 | 111 | Text("Selected Genres:") 112 | .font(.system(size: 20, weight: .semibold, design: .rounded)) 113 | .padding(.top, 30) 114 | 115 | VStack(spacing: 10) { 116 | ForEach(selectedItems, id: \.self) { item in 117 | HStack { 118 | Text(item.title) 119 | .font(.system(size: 16, weight: .semibold, design: .rounded)) 120 | .foregroundColor(.white) 121 | } 122 | .padding() 123 | .frame(maxWidth: .infinity) 124 | .background( 125 | RoundedRectangle(cornerRadius: 20) 126 | .foregroundColor(.accentColor) 127 | ) 128 | } 129 | } 130 | .padding(.bottom, 60) 131 | 132 | Spacer() 133 | } 134 | .padding(.horizontal, 15) 135 | } 136 | } 137 | 138 | /// Sample model conforming to the `Pill` protocol. 139 | /// An element must have a `title` attribute. 140 | struct Genre: Pill { 141 | let title: String 142 | } 143 | 144 | /// Collection of items conforming to `Pill` 145 | let genres: [Genre] = [ 146 | Genre(title: "Action"), 147 | Genre(title: "Adventure"), 148 | Genre(title: "Comedy"), 149 | Genre(title: "Drama"), 150 | Genre(title: "Fantasy"), 151 | Genre(title: "Horror"), 152 | Genre(title: "Mystery"), 153 | Genre(title: "Romance"), 154 | Genre(title: "Sci-Fi"), 155 | Genre(title: "Thriller"), 156 | Genre(title: "Western"), 157 | Genre(title: "Animation"), 158 | Genre(title: "Documentary"), 159 | Genre(title: "Historical"), 160 | Genre(title: "Musical"), 161 | Genre(title: "War"), 162 | Genre(title: "Crime"), 163 | Genre(title: "Family"), 164 | Genre(title: "Sports"), 165 | Genre(title: "Biography") 166 | ] 167 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 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: "PillPickerView", 8 | platforms: [ 9 | .iOS(.v14), .macOS(.v11) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "PillPickerView", 15 | targets: ["PillPickerView"]), 16 | ], 17 | dependencies: [], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 21 | .target( 22 | name: "PillPickerView", 23 | dependencies: [], 24 | path: "Sources" 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | PillPickerView Logo 3 | 4 |
5 | 6 | A SwiftUI library to present a Pill Picker view 7 | 8 | - Highly customizable: PillPickerView offers a wide range of customization options to tailor the appearance of the pills to your needs. You can customize the font, border color, animation, width, height, corner radius, and color scheme of the pills. 9 | 10 | - Easy integration: PillPickerView seamlessly integrates with SwiftUI, making it simple to add the pill picker to your SwiftUI-based app. 11 | 12 | - Select multiple pills: You can select multiple pills simultaneously, and the library provides smooth transitions when adding or removing pills from the selection. 13 | 14 | - Simple API: PillPickerView follows a straightforward API design, making it easy to use. It automatically adjusts the layout of pills to fit within the available space horizontally. 15 | 16 | - Compatibility: Supports iOS 14+, macOS 11+ 17 | 18 | - Lightweight and dependency-free: The library has a lightweight structure and does not have any external dependencies, minimizing its impact on your app's size and performance. 19 | 20 |
21 | 22 | ## How it looks 23 | 24 |
25 |
26 |

Static placement

27 | PillPickerView example 1 28 |
29 |
30 |

Flowing placement

31 | PillPickerView example 2 32 |
33 |
34 | 35 |
36 | 37 | Demo: 38 | https://github.com/adisve/PillPickerView/assets/96535657/4f052e75-36f1-4f59-9664-0a187b07de28 39 | 40 |
41 | 42 | ## 📀 Installation 43 | Requires iOS 14+. PillPickerView can be installed through the [Swift Package Manager](https://developer.apple.com/documentation/swift_packages/adding_package_dependencies_to_your_app) (recommended) or [Cocoapods](https://cocoapods.org/). 44 | 45 | 46 | 47 | 54 | 61 | 62 | 63 | 64 | 71 | 78 | 79 |
48 | 49 | Swift Package Manager 50 | 51 |
52 | Add the Package URL: 53 |
55 | 56 | Cocoapods 57 | 58 |
59 | Add this to your Podfile: 60 |
65 |
66 | 67 | ``` 68 | https://github.com/adisve/PillPickerView 69 | ``` 70 |
72 |
73 | 74 | ``` 75 | pod 'PillPickerView' 76 | ``` 77 |
80 | 81 |
82 | 83 | ## 🧑‍💻 Usage 84 | 85 | Creating a PillPickerView 86 | 87 | To create a pill picker, you need to follow these steps: 88 | 89 | - Define a struct or enum that conforms to the Pill protocol. This protocol requires implementing the title property, which represents the title of each pill, as well as requiring the object to be Equatable and Hashable. 90 | 91 | - In your SwiftUI view, create a @State or @Binding variable to hold the selected pills. For example: 92 | 93 | ```swift 94 | @State private var selectedPills: [YourPillType] = [] 95 | ``` 96 | 97 |
98 | 99 | Instantiate a PillPickerView by providing the necessary parameters, such as the list of items and the selected pills binding: 100 | 101 | ```swift 102 | PillPickerView( 103 | items: yourItemList, 104 | selectedPills: $selectedPills, 105 | maxSelectablePills : 5 // Optional Property 106 | ) 107 | ``` 108 | 109 |
110 | 111 | Here's an example usage of PillPickerView in a SwiftUI view: 112 | 113 | ```swift 114 | import PillPickerView 115 | 116 | struct ContentView: View { 117 | @State private var selectedPills: [YourPillType] = [] 118 | 119 | var body: some View { 120 | VStack { 121 | // Your other content here 122 | 123 | PillPickerView( 124 | items: yourItemList, 125 | selectedPills: $selectedPills, 126 | maxSelectablePill : 5 127 | ) 128 | 129 | // Your other content here 130 | } 131 | } 132 | } 133 | ``` 134 | 135 | In the example above, replace YourPillType with your custom pill type and yourItemList with an array of items conforming to the Pill protocol. 136 | The maxSelectablePills property is optional. If it is not set, all pills in the array can be selected. If it is set, only the specified number of pills can be selected, and the rest will be disabled. 137 | 138 |
139 | 140 | ## ✨ Customization 141 | 142 | PillPickerView offers a range of customization options to tailor the appearance of the pills to your app's design. You can customize the font, colors, animation, size, and other visual aspects of the pills by using the available modifier functions. 143 | 144 | The PillPickerView includes a wrapping mechanism that automatically adjusts the layout of pills to fit within the available space. If the pills exceed the horizontal width of the container, the view wraps the excess pills to a new line. This makes it easy to present a large number of pills without worrying about truncation. 145 | 146 | You can customize the appearance of the pills by chaining the available modifier functions on the PillPickerView. For example: 147 | 148 |
149 | 150 | ```swift 151 | PillPickerView( 152 | items: yourItemList, 153 | selectedPills: $selectedPills, 154 | maxSelectablePills : 5 155 | ) 156 | .pillFont(.system(size: 16, weight: .semibold)) 157 | .pillSelectedForegroundColor(.white) 158 | .pillSelectedBackgroundColor(.blue) 159 | .pillDisabledBackgroundColor(.gray.opacity(0.2)) 160 | .pillDisabledForegroundColor(.gray.opacity(0.5)) 161 | ``` 162 | 163 |
164 | 165 | To switch between the underlying stack style for the content. Choosing `.noWrap` will invariably cause any text inside the pills to be truncated depending on length, as it will automatically be fitted inside the view and not wrap to a new line. Choosing `.wrap` will let the pills be dynamically placed an move in the PillPickerView. 166 | 167 | ```swift 168 | .pillStackStyle(.noWrap) // Prevents pills from wrapping to a new line and being dynamic 169 | .pillStackStyle(.wrap) // Default value. Allows pills to move in container 170 | ``` 171 | 172 |
173 | 174 | To modify the vertical or horizontal spacing in the PillPickerView 175 | 176 | ```swift 177 | .pillViewVerticalSpacing(10) 178 | .pillViewHorizontalSpacing(5) 179 | ``` 180 | 181 | To change the font of the pills 182 | 183 | ```swift 184 | .pillFont(.caption) 185 | ``` 186 | 187 |
188 | 189 | You can of course chain things together to get a good layout based on your circumstances and requirements. 190 | 191 | ```swift 192 | .pillFont(.title3) 193 | .pillViewHorizontalSpacing(30) 194 | ``` 195 | 196 |
197 | 198 | To change the icon used by each pill when it is 'selected'. 199 | I advise you to choose something that indicates that the pill will no longer be selected if this icon is pressed, as this is the intended behavior. 200 | 201 | ```swift 202 | .pillSelectedIcon(Image(systemName: "xmark")) 203 | ``` 204 | 205 |
206 | 207 | To change the background color of a not selected and selected pill, respectively 208 | 209 | ```swift 210 | .pillNormalBackgroundColor(.green) 211 | .pillSelectedBackgroundColor(.blue) 212 | ``` 213 | 214 |
215 | 216 | To change the foreground color of a not selected and selected pill, respectively 217 | 218 | ```swift 219 | .pillNormalForegroundColor(.orange) 220 | .pillSelectedForegroundColor(.white) 221 | ``` 222 | 223 |
224 | 225 | The height and width of the pills can also be set, but they will be treated as the minimum width of each pill 226 | 227 | ```swift 228 | .pillMinWidth(20) 229 | .pillMinHeight(10) 230 | ``` 231 | 232 |
233 | 234 | This adds trailing and leading icons to the view, but only displays the trailing icons when the element has been selected. 235 | 236 | ```swift 237 | .pillLeadingIcon(Image(systemName: "popcorn")) 238 | .pillTrailingIcon(Image(systemName: "checkmark")) 239 | .pillTrailingOnlySelected(true) /// Only when item is selected 240 | ``` 241 | 242 |
243 | 244 | Corner radius and border color can also be changed easily 245 | 246 | ```swift 247 | .pillBorderColor(.green) 248 | .pillCornerRadius(40) 249 | ``` 250 | 251 |
252 | 253 | You can also change the animation used when a pill is pressed or wrapped to a newline 254 | 255 | ```swift 256 | .pillAnimation(.easeInOut) 257 | ``` 258 | 259 |
260 | 261 | Padding can also be applied horizontally or vertically 262 | 263 | ```swift 264 | .pillPaddingHorizontal(10) 265 | .pillPaddingVertical(10) 266 | ``` 267 | 268 |
269 | 270 | If maxSelectablePills is set you can adjust the disabled state colors 271 | 272 | ```swift 273 | .pillDisabledBackgroundColor(.gray.opacity(0.2)) 274 | .pillDisabledForegroundColor(.gray.opacity(0.5)) 275 | ``` 276 | 277 | 278 | ## License 279 | 280 | ``` 281 | Created by A. Veletanlic (github.com/adisve) on 5/20/23. 282 | Copyright © 2023 A. Veletanlic. All rights reserved. 283 | 284 | MIT License 285 | 286 | Copyright (c) 2023 A. Veletanlic 287 | 288 | Permission is hereby granted, free of charge, to any person obtaining a copy 289 | of this software and associated documentation files (the "Software"), to deal 290 | in the Software without restriction, including without limitation the rights 291 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 292 | copies of the Software, and to permit persons to whom the Software is 293 | furnished to do so, subject to the following conditions: 294 | 295 | The above copyright notice and this permission notice shall be included in all 296 | copies or substantial portions of the Software. 297 | 298 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 299 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 300 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 301 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 302 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 303 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 304 | SOFTWARE. 305 | ``` 306 | 307 | ![Stats](https://repobeats.axiom.co/api/embed/c1744bde4d6a01c80440755160925ce130cd3042.svg "Repobeats stats") 308 | -------------------------------------------------------------------------------- /Sources/PillPickerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - Protocols 4 | 5 | /// Each Pill item must have a title, 6 | /// which will be displayed on the actual pill. 7 | public protocol Pill: Equatable, Hashable { 8 | var title: String { get } 9 | } 10 | 11 | // MARK: - Enums 12 | 13 | /// The style the pill content has, whether 14 | /// it is dynamic and wrapping or statically placed 15 | public enum StackStyle { 16 | case wrap 17 | case noWrap 18 | } 19 | 20 | // MARK: - Pill customizations 21 | 22 | public struct PillOptions { 23 | 24 | /// Font type for a pill element 25 | public var font: Font = .system(size: 14, weight: .semibold, design: .rounded) 26 | 27 | /// Border color of a pill 28 | public var borderColor: Color = .clear 29 | 30 | /// The animation type which the pill will use 31 | /// when animating in/out in its parent view 32 | public var animation: Animation = .spring() 33 | 34 | /// Minimum width of the pill 35 | public var minWidth: CGFloat = 50 36 | 37 | /// Height of the pill 38 | public var minHeight: CGFloat = 15 39 | 40 | /// Radius of the enclosing view of each pill 41 | public var cornerRadius: CGFloat = 40 42 | 43 | /// The background color of a pill when it is marked 44 | /// as being selected 45 | public var selectedBackgroundColor: Color = .accentColor 46 | 47 | /// The foregound color of a pill when it is marked 48 | /// as being selected 49 | public var selectedForegroundColor: Color = .white 50 | 51 | /// Background color of a pill when it is not selected 52 | public var normalBackgroundColor: Color = .accentColor.opacity(0.5) 53 | 54 | /// Foreground color of a pill when it is not selected 55 | public var normalForegroundColor: Color = .white 56 | 57 | /// Vertical padding of content inside a pill 58 | public var verticalPadding: CGFloat = 7.5 59 | 60 | /// Vertical padding of content inside a pill 61 | public var horizontalPadding: CGFloat = 7.5 62 | 63 | /// Whether pills should wrap to new line or not 64 | public var stackStyle: StackStyle = StackStyle.noWrap 65 | 66 | /// Spacing applied vertically between pill rows 67 | public var verticalSpacing: CGFloat = 2.5 // -> Custom changed 68 | 69 | /// Spacing applied horizontally between pills 70 | public var horizontalSpacing: CGFloat = 2 71 | 72 | /// Trailing icon displayed inside the pills 73 | public var trailingIcon: Image? = nil 74 | 75 | /// Leading icon displayed inside the pills 76 | public var leadingIcon: Image? = nil 77 | 78 | /// Whether leading icon should only be displayed if 79 | /// the element is selected or not 80 | public var leadingOnlyWhenSelected: Bool = false 81 | 82 | /// Whether trailing icon should only be displayed if 83 | /// the element is selected or not 84 | public var trailingOnlyWhenSelected: Bool = false 85 | 86 | 87 | /// Background color of a pill when it is disabled 88 | public var disabledBackgroundColor: Color = .gray 89 | 90 | /// Foreground color of a pill when it is disabled 91 | public var disabledForegroundColor: Color = .white 92 | } 93 | 94 | // MARK: - Main view 95 | 96 | public struct PillPickerView: View { 97 | 98 | let maxSelectablePills: Int 99 | 100 | // MARK: - Properties 101 | 102 | /// Options for configuring each individual PillView 103 | public var options = PillOptions() 104 | 105 | /// Provider for the selectable items, passed as a 106 | /// @Binding list of elements conforming to PillEnum 107 | @Binding var selectedPills: [T] 108 | 109 | /// List of items that will be available to choose from 110 | let items: [T] 111 | 112 | // MARK: - Initialization 113 | 114 | public init( 115 | items: [T], 116 | selectedPills: Binding<[T]>, 117 | maxSelectablePills: Int = Int.max 118 | ) { 119 | self.items = items 120 | self._selectedPills = selectedPills 121 | self.maxSelectablePills = maxSelectablePills 122 | } 123 | 124 | // MARK: - Body 125 | 126 | public var body: some View { 127 | switch options.stackStyle { 128 | case StackStyle.noWrap: 129 | StaticStack(options: options, items: items, viewGenerator: { item in 130 | PillView(maxSelectablePills: maxSelectablePills, options: options, item: item, selectedPills: $selectedPills) 131 | }) 132 | case StackStyle.wrap: 133 | FlowStack(options: options, items: items, viewGenerator: { item in 134 | PillView(maxSelectablePills: maxSelectablePills, options: options, item: item, selectedPills: $selectedPills) 135 | }) 136 | } 137 | } 138 | } 139 | 140 | // MARK: - Extensions 141 | 142 | public extension PillPickerView { 143 | 144 | /// Set the background color for each pill when disabled 145 | func pillDisabledBackgroundColor(_ value: Color) -> PillPickerView { 146 | var view = self 147 | view.options.disabledBackgroundColor = value 148 | return view 149 | } 150 | 151 | /// Set the foreground color for the title and icon in each pill when disabled 152 | func pillDisabledForegroundColor(_ value: Color) -> PillPickerView { 153 | var view = self 154 | view.options.disabledForegroundColor = value 155 | return view 156 | } 157 | 158 | /// The foreground color used for the title 159 | /// and icon in each pill when not selected 160 | func pillNormalForegroundColor(_ value: Color) -> PillPickerView { 161 | var view = self 162 | view.options.normalForegroundColor = value 163 | return view 164 | } 165 | 166 | /// The background color used for each pill 167 | /// when not selected 168 | func pillNormalBackgroundColor(_ value: Color) -> PillPickerView { 169 | var view = self 170 | view.options.normalBackgroundColor = value 171 | return view 172 | } 173 | 174 | /// The background color used for each pill when 175 | /// marked as being selected 176 | func pillSelectedBackgroundColor(_ value: Color) -> PillPickerView { 177 | var view = self 178 | view.options.selectedBackgroundColor = value 179 | return view 180 | } 181 | 182 | /// The foreground color used for the title 183 | /// and icon in each pill when marked as being selected 184 | func pillSelectedForegroundColor(_ value: Color) -> PillPickerView { 185 | var view = self 186 | view.options.selectedForegroundColor = value 187 | return view 188 | } 189 | 190 | /// The font used in each pill regardless of whether 191 | /// it is selected or not 192 | func pillFont(_ value: Font) -> PillPickerView { 193 | var view = self 194 | view.options.font = value 195 | return view 196 | } 197 | 198 | /// The minimum width of each pill 199 | func pillMinWidth(_ value: CGFloat) -> PillPickerView { 200 | var view = self 201 | view.options.minWidth = value 202 | return view 203 | } 204 | 205 | /// The minimum height of each pill 206 | func pillMinHeight(_ value: CGFloat) -> PillPickerView { 207 | var view = self 208 | view.options.minHeight = value 209 | return view 210 | } 211 | 212 | /// The corner radius of each pill 213 | func pillCornerRadius(_ value: CGFloat) -> PillPickerView { 214 | var view = self 215 | view.options.cornerRadius = value 216 | return view 217 | } 218 | 219 | /// The border color of the edges of each pill 220 | func pillBorderColor(_ value: Color) -> PillPickerView { 221 | var view = self 222 | view.options.borderColor = value 223 | return view 224 | } 225 | 226 | /// The animation used when a pill is animated in 227 | /// its parent element, toggled when selected/removed 228 | func pillAnimation(_ value: Animation) -> PillPickerView { 229 | var view = self 230 | view.options.animation = value 231 | return view 232 | } 233 | 234 | /// Horizontal padding of content inside each pill 235 | func pillPaddingVertical(_ value: CGFloat) -> PillPickerView { 236 | var view = self 237 | view.options.verticalPadding = value 238 | return view 239 | } 240 | 241 | /// Horizontal padding of content inside each pill 242 | func pillPaddingHorizontal(_ value: CGFloat) -> PillPickerView { 243 | var view = self 244 | view.options.horizontalPadding = value 245 | return view 246 | } 247 | 248 | /// The stack style the PillPickerView uses, either wrapping 249 | /// the pills to new lines or having them statically placed 250 | func pillStackStyle(_ value: StackStyle) -> PillPickerView { 251 | var view = self 252 | view.options.stackStyle = value 253 | return view 254 | } 255 | 256 | /// Set the vertical spacing of pills inside PillPickerView 257 | func pillViewVerticalSpacing(_ value: CGFloat) -> PillPickerView { 258 | var view = self 259 | view.options.verticalSpacing = value 260 | return view 261 | } 262 | 263 | /// Set the horizontal spacing of pills inside PillPickerView 264 | func pillViewHorizontalSpacing(_ value: CGFloat) -> PillPickerView { 265 | var view = self 266 | view.options.horizontalSpacing = value 267 | return view 268 | } 269 | 270 | func pillLeadingIcon(_ value: Image) -> PillPickerView { 271 | var view = self 272 | view.options.leadingIcon = value 273 | return view 274 | } 275 | 276 | func pillTrailingIcon(_ value: Image) -> PillPickerView { 277 | var view = self 278 | view.options.trailingIcon = value 279 | return view 280 | } 281 | 282 | func pillTrailingOnlySelected(_ value: Bool) -> PillPickerView { 283 | var view = self 284 | view.options.trailingOnlyWhenSelected = value 285 | return view 286 | } 287 | 288 | func pillLeadingOnlySelected(_ value: Bool) -> PillPickerView { 289 | var view = self 290 | view.options.leadingOnlyWhenSelected = value 291 | return view 292 | } 293 | 294 | } 295 | 296 | // MARK: - Pill View 297 | 298 | /// View containing the selectable element 299 | struct PillView: View { 300 | 301 | // MARK: - Properties 302 | 303 | let maxSelectablePills: Int 304 | 305 | let options: PillOptions 306 | 307 | /// Passed element conforming to PillItem 308 | let item: T 309 | 310 | /// List of Binding items that are currently toggled 311 | @Binding var selectedPills: [T] 312 | 313 | 314 | // MARK: - Body 315 | 316 | public var body: some View { 317 | Button(action: { 318 | withAnimation(options.animation) { 319 | if !isItemSelected() { 320 | 321 | if selectedPills.count < maxSelectablePills { 322 | selectedPills.append(item) 323 | } 324 | 325 | } else { 326 | selectedPills.removeAll(where: { $0 == item }) 327 | } 328 | } 329 | }, label: { 330 | HStack { 331 | if !options.leadingOnlyWhenSelected || isItemSelected() { 332 | leadingIcon 333 | } 334 | Text(item.title) 335 | .font(options.font) 336 | .foregroundColor(pillForegroundColor) 337 | if !options.trailingOnlyWhenSelected || isItemSelected() { 338 | trailingIcon 339 | } 340 | } 341 | .frame(minWidth: options.minWidth, minHeight: options.minHeight) 342 | }) 343 | .buttonStyle( 344 | PillItemStyle( 345 | selected: isItemSelected(), 346 | disabled: isDisabled(), 347 | borderColor: options.borderColor, 348 | cornerRadius: options.cornerRadius, 349 | options: options 350 | ) 351 | ) 352 | .padding(5) 353 | .padding(.vertical, 2.5) 354 | } 355 | 356 | var leadingIcon: some View { 357 | options.leadingIcon 358 | .font(options.font) 359 | .foregroundColor(pillForegroundColor) 360 | .padding(.leading, 5) 361 | } 362 | 363 | var trailingIcon: some View { 364 | options.trailingIcon 365 | .font(options.font) 366 | .foregroundColor(pillForegroundColor) 367 | .padding(.leading, 5) 368 | } 369 | 370 | // MARK: - Helper Functions 371 | 372 | /// Checks if @Binding collection contains 373 | /// the given element 374 | func isItemSelected() -> Bool { 375 | return selectedPills.contains(item) 376 | } 377 | 378 | 379 | /// Retrieves the foreground color based 380 | /// on the state of the element 381 | private var pillForegroundColor: Color { 382 | if isItemSelected() { 383 | return options.selectedForegroundColor 384 | } else if isDisabled() { 385 | return options.disabledForegroundColor 386 | } 387 | return options.normalForegroundColor 388 | } 389 | 390 | 391 | /// Determines if the pill should be in a disabled state 392 | func isDisabled() -> Bool { 393 | // If the item is already selected, it's not disabled. 394 | if isItemSelected() { 395 | return false 396 | } 397 | // Disable if maxSelectablePills is reached but the item is not selected. 398 | return selectedPills.count >= maxSelectablePills 399 | } 400 | 401 | 402 | 403 | } 404 | 405 | // MARK: - Button Styles 406 | 407 | /// Basic Pill item style, giving some 408 | /// bounce and label color 409 | struct PillItemStyle: ButtonStyle { 410 | 411 | /// Whether pill is selected or not 412 | let selected: Bool 413 | 414 | let disabled: Bool 415 | 416 | /// Border color of the pill 417 | let borderColor: Color 418 | 419 | /// Corner radius of the pills border 420 | let cornerRadius: CGFloat 421 | 422 | let options: PillOptions 423 | 424 | 425 | func makeBody(configuration: Configuration) -> some View { 426 | configuration.label 427 | .padding(.horizontal, options.horizontalPadding) 428 | .padding(.vertical, options.verticalPadding) 429 | .background(background) 430 | .foregroundColor(foreground) 431 | .cornerRadius(cornerRadius) 432 | .overlay( 433 | RoundedRectangle(cornerRadius: cornerRadius) 434 | .stroke(borderColor, lineWidth: 1) 435 | ) 436 | .scaleEffect(configuration.isPressed ? 0.9 : 1) 437 | .animation(options.animation, value: configuration.isPressed) 438 | } 439 | 440 | private var background: Color { 441 | if selected { 442 | return options.selectedBackgroundColor 443 | } else if disabled { 444 | return options.disabledBackgroundColor 445 | } 446 | return options.normalBackgroundColor 447 | } 448 | 449 | 450 | private var foreground: Color { 451 | if selected { 452 | return options.selectedForegroundColor 453 | } else if disabled { 454 | return options.disabledForegroundColor 455 | } 456 | return options.normalForegroundColor 457 | } 458 | } 459 | 460 | // MARK: - StaticStack 461 | 462 | 463 | /// Stack of pills not wrapping to a new line 464 | struct StaticStack: View where T: Pill, V: View { 465 | 466 | /// Alias for function type generating content 467 | typealias ContentGenerator = (T) -> V 468 | 469 | let options: PillOptions 470 | 471 | /// Collection of items passed to view 472 | var items: [T] 473 | 474 | /// Content generator function 475 | var viewGenerator: ContentGenerator 476 | 477 | /// Chunk size which `items` is divided into 478 | @State private var chunkSize: Int = 1 479 | 480 | /// Current total height calculated 481 | @State private var totalHeight = CGFloat.zero 482 | 483 | /// The calculateChunkSize function determines the optimal number 484 | /// of items per chunk based on the available width of the view. 485 | private func calculateChunkSize(geometry: GeometryProxy) { 486 | let availableWidth = geometry.size.width 487 | let itemWidth: CGFloat = 100 488 | 489 | chunkSize = max(Int(availableWidth / (itemWidth + options.minWidth + options.horizontalSpacing)), 1) 490 | } 491 | 492 | // MARK: - Height Calculation 493 | 494 | /// Used to calculate the total height of the view. It wraps the ZStack 495 | /// in a GeometryReader to obtain the height of the content and updates 496 | /// the `totalHeight` state variable accordingly. 497 | private func viewHeightReader(_ binding: Binding) -> some View { 498 | return GeometryReader { geometry -> Color in 499 | let rect = geometry.frame(in: .local) 500 | DispatchQueue.main.async { 501 | binding.wrappedValue = rect.size.height 502 | } 503 | return .clear 504 | } 505 | } 506 | 507 | var body: some View { 508 | VStack { 509 | GeometryReader { geometry in 510 | VStack(spacing: options.verticalSpacing) { 511 | ForEach(items.chunked(into: chunkSize), id: \.self) { chunk in 512 | HStack(spacing: options.horizontalSpacing) { 513 | ForEach(chunk, id: \.self) { item in 514 | viewGenerator(item) 515 | } 516 | } 517 | .frame(width: geometry.size.width, alignment: .leading) 518 | } 519 | } 520 | 521 | /// Necessary to get generated height 522 | /// of child elements combined, then 523 | /// set the parent `VStack` height accordingly 524 | .background(viewHeightReader($totalHeight)) 525 | 526 | .onAppear { 527 | calculateChunkSize(geometry: geometry) 528 | } 529 | 530 | /// Dynamically generate chunk size based on 531 | /// screen direction and dimension 532 | .onChange(of: geometry.size.width) { _ in 533 | calculateChunkSize(geometry: geometry) 534 | } 535 | } 536 | } 537 | .frame(height: totalHeight) 538 | .frame(maxWidth: .infinity) 539 | } 540 | } 541 | 542 | // MARK: - Extension 543 | 544 | extension Array { 545 | 546 | /// This method takes an integer `size` 547 | /// and returns a two-dimensional array ([[Element]]) 548 | /// where the original array is divided into chunks of the specified size. 549 | func chunked(into size: Int) -> [[Element]] { 550 | stride(from: 0, to: count, by: size).map { 551 | Array(self[$0..: View where T: Pill, V: View { 562 | 563 | // MARK: - Types and Properties 564 | 565 | /// Alias for function type generating content 566 | typealias ContentGenerator = (T) -> V 567 | 568 | let options: PillOptions 569 | 570 | /// Collection of items passed to view 571 | var items: [T] 572 | 573 | /// Content generator function 574 | var viewGenerator: ContentGenerator 575 | 576 | /// Current total height calculated 577 | @State private var totalHeight = CGFloat.zero 578 | 579 | // MARK: - Body 580 | 581 | public var body: some View { 582 | VStack { 583 | GeometryReader { geometry in 584 | generateContent(in: geometry) 585 | } 586 | } 587 | .frame(height: totalHeight) 588 | } 589 | 590 | // MARK: - Content Generation 591 | 592 | private func generateContent(in geometry: GeometryProxy) -> some View { 593 | var width = CGFloat.zero 594 | var height = CGFloat.zero 595 | 596 | return ZStack(alignment: .topLeading) { 597 | ForEach(items, id: \.self) { item in 598 | viewGenerator(item) 599 | .padding(.horizontal, options.horizontalSpacing) 600 | .padding(.vertical, options.verticalSpacing) 601 | .alignmentGuide(.leading, computeValue: { dimension in 602 | return calculateLeadingAlignment(dimension: dimension, item: item) 603 | }) 604 | .alignmentGuide(.top, computeValue: { dimension in 605 | return calculateTopAlignment(item: item) 606 | }) 607 | } 608 | } 609 | .background(viewHeightReader($totalHeight)) 610 | 611 | // MARK: - Alignment calculations 612 | 613 | /// Checks if adding the item's width to the current width value exceeds the 614 | /// available width (given by `geometry.size.width`). If it does, it resets width 615 | /// to 0 and subtracts the item's height from height to move to the next row. 616 | /// Otherwise, it returns the current `width` value and updates `width` by subtracting the item's width. 617 | func calculateLeadingAlignment(dimension: ViewDimensions, item: T) -> CGFloat { 618 | if abs(width - dimension.width) > geometry.size.width { 619 | width = 0 620 | height -= dimension.height 621 | } 622 | let result = width 623 | if item == items.last { 624 | width = 0 625 | } else { 626 | width -= dimension.width 627 | } 628 | return result 629 | } 630 | 631 | /// Used to calculate the top (vertical) alignment for each item. 632 | /// It receives the item itself and returns the current height value. 633 | /// If the item is the last one, it resets `height` to 0. 634 | func calculateTopAlignment(item: T) -> CGFloat { 635 | let result = height 636 | if item == items.last { 637 | height = 0 638 | } 639 | return result 640 | } 641 | 642 | } 643 | } 644 | 645 | /// MARK: - Utility functions 646 | 647 | /// Get height of context and set passed binding 648 | /// parameter based on received value 649 | func viewHeightReader(_ binding: Binding) -> some View { 650 | return GeometryReader { geometry -> Color in 651 | let rect = geometry.frame(in: .local) 652 | DispatchQueue.main.async { 653 | binding.wrappedValue = rect.size.height 654 | } 655 | return .clear 656 | } 657 | } 658 | --------------------------------------------------------------------------------