├── .gitattributes ├── .gitignore ├── Example ├── CircularControlExample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── contents.xcworkspacedata └── CircularControlExample │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── CircularControlExample.entitlements │ ├── CircularControlExampleApp.swift │ ├── ContentView.swift │ ├── DynamicView.swift │ ├── EditableDemoView.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── SimpleDemoView.swift ├── LICENSE ├── Package.swift ├── README.md ├── Resources ├── CircularControl.png ├── interactive-control.gif ├── old-example-1.png ├── old-example-2.png └── static-progress.gif └── Sources └── PZCircularControl ├── CircularControl+Knob.swift ├── CircularControl+Track.swift ├── CircularControl.swift ├── CircularControlLabelFormat.swift ├── CircularControlStyle.swift ├── DefaultCircularControlLabel.swift ├── Extensions ├── Color+Knob.swift ├── Comparable+Clamped.swift └── EnvironmentValues+CircularControl.swift └── HapticFeedback.swift /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm -------------------------------------------------------------------------------- /Example/CircularControlExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 4AAD45712D1A77D900DEAA42 /* PZCircularControl in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAD45702D1A77D900DEAA42 /* PZCircularControl */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXFileReference section */ 14 | 4AAD45502D1A779900DEAA42 /* CircularControlExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CircularControlExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 15 | /* End PBXFileReference section */ 16 | 17 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 18 | 4AAD45522D1A779900DEAA42 /* CircularControlExample */ = { 19 | isa = PBXFileSystemSynchronizedRootGroup; 20 | path = CircularControlExample; 21 | sourceTree = ""; 22 | }; 23 | /* End PBXFileSystemSynchronizedRootGroup section */ 24 | 25 | /* Begin PBXFrameworksBuildPhase section */ 26 | 4AAD454D2D1A779900DEAA42 /* Frameworks */ = { 27 | isa = PBXFrameworksBuildPhase; 28 | buildActionMask = 2147483647; 29 | files = ( 30 | 4AAD45712D1A77D900DEAA42 /* PZCircularControl in Frameworks */, 31 | ); 32 | runOnlyForDeploymentPostprocessing = 0; 33 | }; 34 | /* End PBXFrameworksBuildPhase section */ 35 | 36 | /* Begin PBXGroup section */ 37 | 4AAD45472D1A779900DEAA42 = { 38 | isa = PBXGroup; 39 | children = ( 40 | 4AAD45522D1A779900DEAA42 /* CircularControlExample */, 41 | 4AAD45512D1A779900DEAA42 /* Products */, 42 | ); 43 | sourceTree = ""; 44 | }; 45 | 4AAD45512D1A779900DEAA42 /* Products */ = { 46 | isa = PBXGroup; 47 | children = ( 48 | 4AAD45502D1A779900DEAA42 /* CircularControlExample.app */, 49 | ); 50 | name = Products; 51 | sourceTree = ""; 52 | }; 53 | /* End PBXGroup section */ 54 | 55 | /* Begin PBXNativeTarget section */ 56 | 4AAD454F2D1A779900DEAA42 /* CircularControlExample */ = { 57 | isa = PBXNativeTarget; 58 | buildConfigurationList = 4AAD455F2D1A779A00DEAA42 /* Build configuration list for PBXNativeTarget "CircularControlExample" */; 59 | buildPhases = ( 60 | 4AAD454C2D1A779900DEAA42 /* Sources */, 61 | 4AAD454D2D1A779900DEAA42 /* Frameworks */, 62 | 4AAD454E2D1A779900DEAA42 /* Resources */, 63 | ); 64 | buildRules = ( 65 | ); 66 | dependencies = ( 67 | ); 68 | fileSystemSynchronizedGroups = ( 69 | 4AAD45522D1A779900DEAA42 /* CircularControlExample */, 70 | ); 71 | name = CircularControlExample; 72 | packageProductDependencies = ( 73 | 4AAD45702D1A77D900DEAA42 /* PZCircularControl */, 74 | ); 75 | productName = CircularControlExample; 76 | productReference = 4AAD45502D1A779900DEAA42 /* CircularControlExample.app */; 77 | productType = "com.apple.product-type.application"; 78 | }; 79 | /* End PBXNativeTarget section */ 80 | 81 | /* Begin PBXProject section */ 82 | 4AAD45482D1A779900DEAA42 /* Project object */ = { 83 | isa = PBXProject; 84 | attributes = { 85 | BuildIndependentTargetsInParallel = 1; 86 | LastSwiftUpdateCheck = 1620; 87 | LastUpgradeCheck = 1620; 88 | TargetAttributes = { 89 | 4AAD454F2D1A779900DEAA42 = { 90 | CreatedOnToolsVersion = 16.2; 91 | }; 92 | }; 93 | }; 94 | buildConfigurationList = 4AAD454B2D1A779900DEAA42 /* Build configuration list for PBXProject "CircularControlExample" */; 95 | developmentRegion = en; 96 | hasScannedForEncodings = 0; 97 | knownRegions = ( 98 | en, 99 | Base, 100 | ); 101 | mainGroup = 4AAD45472D1A779900DEAA42; 102 | minimizedProjectReferenceProxies = 1; 103 | packageReferences = ( 104 | 4AAD456F2D1A77D900DEAA42 /* XCLocalSwiftPackageReference "../../PZCircularControl" */, 105 | ); 106 | preferredProjectObjectVersion = 77; 107 | productRefGroup = 4AAD45512D1A779900DEAA42 /* Products */; 108 | projectDirPath = ""; 109 | projectRoot = ""; 110 | targets = ( 111 | 4AAD454F2D1A779900DEAA42 /* CircularControlExample */, 112 | ); 113 | }; 114 | /* End PBXProject section */ 115 | 116 | /* Begin PBXResourcesBuildPhase section */ 117 | 4AAD454E2D1A779900DEAA42 /* Resources */ = { 118 | isa = PBXResourcesBuildPhase; 119 | buildActionMask = 2147483647; 120 | files = ( 121 | ); 122 | runOnlyForDeploymentPostprocessing = 0; 123 | }; 124 | /* End PBXResourcesBuildPhase section */ 125 | 126 | /* Begin PBXSourcesBuildPhase section */ 127 | 4AAD454C2D1A779900DEAA42 /* Sources */ = { 128 | isa = PBXSourcesBuildPhase; 129 | buildActionMask = 2147483647; 130 | files = ( 131 | ); 132 | runOnlyForDeploymentPostprocessing = 0; 133 | }; 134 | /* End PBXSourcesBuildPhase section */ 135 | 136 | /* Begin XCBuildConfiguration section */ 137 | 4AAD455D2D1A779A00DEAA42 /* Debug */ = { 138 | isa = XCBuildConfiguration; 139 | buildSettings = { 140 | ALWAYS_SEARCH_USER_PATHS = NO; 141 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 142 | CLANG_ANALYZER_NONNULL = YES; 143 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 144 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 145 | CLANG_ENABLE_MODULES = YES; 146 | CLANG_ENABLE_OBJC_ARC = YES; 147 | CLANG_ENABLE_OBJC_WEAK = YES; 148 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 149 | CLANG_WARN_BOOL_CONVERSION = YES; 150 | CLANG_WARN_COMMA = YES; 151 | CLANG_WARN_CONSTANT_CONVERSION = YES; 152 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 153 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 154 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 155 | CLANG_WARN_EMPTY_BODY = YES; 156 | CLANG_WARN_ENUM_CONVERSION = YES; 157 | CLANG_WARN_INFINITE_RECURSION = YES; 158 | CLANG_WARN_INT_CONVERSION = YES; 159 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 160 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 161 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 162 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 163 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 164 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 165 | CLANG_WARN_STRICT_PROTOTYPES = YES; 166 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 167 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 168 | CLANG_WARN_UNREACHABLE_CODE = YES; 169 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 170 | COPY_PHASE_STRIP = NO; 171 | DEBUG_INFORMATION_FORMAT = dwarf; 172 | ENABLE_STRICT_OBJC_MSGSEND = YES; 173 | ENABLE_TESTABILITY = YES; 174 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 175 | GCC_C_LANGUAGE_STANDARD = gnu17; 176 | GCC_DYNAMIC_NO_PIC = NO; 177 | GCC_NO_COMMON_BLOCKS = YES; 178 | GCC_OPTIMIZATION_LEVEL = 0; 179 | GCC_PREPROCESSOR_DEFINITIONS = ( 180 | "DEBUG=1", 181 | "$(inherited)", 182 | ); 183 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 184 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 185 | GCC_WARN_UNDECLARED_SELECTOR = YES; 186 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 187 | GCC_WARN_UNUSED_FUNCTION = YES; 188 | GCC_WARN_UNUSED_VARIABLE = YES; 189 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 190 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 191 | MTL_FAST_MATH = YES; 192 | ONLY_ACTIVE_ARCH = YES; 193 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 194 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 195 | }; 196 | name = Debug; 197 | }; 198 | 4AAD455E2D1A779A00DEAA42 /* Release */ = { 199 | isa = XCBuildConfiguration; 200 | buildSettings = { 201 | ALWAYS_SEARCH_USER_PATHS = NO; 202 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 203 | CLANG_ANALYZER_NONNULL = YES; 204 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 205 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 206 | CLANG_ENABLE_MODULES = YES; 207 | CLANG_ENABLE_OBJC_ARC = YES; 208 | CLANG_ENABLE_OBJC_WEAK = YES; 209 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 210 | CLANG_WARN_BOOL_CONVERSION = YES; 211 | CLANG_WARN_COMMA = YES; 212 | CLANG_WARN_CONSTANT_CONVERSION = YES; 213 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 214 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 215 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 216 | CLANG_WARN_EMPTY_BODY = YES; 217 | CLANG_WARN_ENUM_CONVERSION = YES; 218 | CLANG_WARN_INFINITE_RECURSION = YES; 219 | CLANG_WARN_INT_CONVERSION = YES; 220 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 221 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 222 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 223 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 224 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 225 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 226 | CLANG_WARN_STRICT_PROTOTYPES = YES; 227 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 228 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 229 | CLANG_WARN_UNREACHABLE_CODE = YES; 230 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 231 | COPY_PHASE_STRIP = NO; 232 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 233 | ENABLE_NS_ASSERTIONS = NO; 234 | ENABLE_STRICT_OBJC_MSGSEND = YES; 235 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 236 | GCC_C_LANGUAGE_STANDARD = gnu17; 237 | GCC_NO_COMMON_BLOCKS = YES; 238 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 239 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 240 | GCC_WARN_UNDECLARED_SELECTOR = YES; 241 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 242 | GCC_WARN_UNUSED_FUNCTION = YES; 243 | GCC_WARN_UNUSED_VARIABLE = YES; 244 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 245 | MTL_ENABLE_DEBUG_INFO = NO; 246 | MTL_FAST_MATH = YES; 247 | SWIFT_COMPILATION_MODE = wholemodule; 248 | }; 249 | name = Release; 250 | }; 251 | 4AAD45602D1A779A00DEAA42 /* Debug */ = { 252 | isa = XCBuildConfiguration; 253 | buildSettings = { 254 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 255 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 256 | CODE_SIGN_ENTITLEMENTS = CircularControlExample/CircularControlExample.entitlements; 257 | CODE_SIGN_IDENTITY = "Apple Development"; 258 | CODE_SIGN_STYLE = Automatic; 259 | CURRENT_PROJECT_VERSION = 1; 260 | DEVELOPMENT_ASSET_PATHS = "\"CircularControlExample/Preview Content\""; 261 | DEVELOPMENT_TEAM = D7DS2U3H59; 262 | ENABLE_HARDENED_RUNTIME = YES; 263 | ENABLE_PREVIEWS = YES; 264 | GENERATE_INFOPLIST_FILE = YES; 265 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 266 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 267 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 268 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 269 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 270 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 271 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 272 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 273 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 274 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 275 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 276 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 277 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 278 | MACOSX_DEPLOYMENT_TARGET = 15.2; 279 | MARKETING_VERSION = 1.0; 280 | PRODUCT_BUNDLE_IDENTIFIER = work.philz.CircularControl; 281 | PRODUCT_NAME = "$(TARGET_NAME)"; 282 | PROVISIONING_PROFILE_SPECIFIER = ""; 283 | SDKROOT = auto; 284 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 285 | SWIFT_EMIT_LOC_STRINGS = YES; 286 | SWIFT_VERSION = 5.0; 287 | TARGETED_DEVICE_FAMILY = "1,2,7"; 288 | XROS_DEPLOYMENT_TARGET = 2.2; 289 | }; 290 | name = Debug; 291 | }; 292 | 4AAD45612D1A779A00DEAA42 /* Release */ = { 293 | isa = XCBuildConfiguration; 294 | buildSettings = { 295 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 296 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 297 | CODE_SIGN_ENTITLEMENTS = CircularControlExample/CircularControlExample.entitlements; 298 | CODE_SIGN_IDENTITY = "Apple Development"; 299 | CODE_SIGN_STYLE = Automatic; 300 | CURRENT_PROJECT_VERSION = 1; 301 | DEVELOPMENT_ASSET_PATHS = "\"CircularControlExample/Preview Content\""; 302 | DEVELOPMENT_TEAM = D7DS2U3H59; 303 | ENABLE_HARDENED_RUNTIME = YES; 304 | ENABLE_PREVIEWS = YES; 305 | GENERATE_INFOPLIST_FILE = YES; 306 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 307 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 308 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 309 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 310 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 311 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 312 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 313 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 314 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 315 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 316 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 317 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 318 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 319 | MACOSX_DEPLOYMENT_TARGET = 15.2; 320 | MARKETING_VERSION = 1.0; 321 | PRODUCT_BUNDLE_IDENTIFIER = work.philz.CircularControl; 322 | PRODUCT_NAME = "$(TARGET_NAME)"; 323 | PROVISIONING_PROFILE_SPECIFIER = ""; 324 | SDKROOT = auto; 325 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; 326 | SWIFT_EMIT_LOC_STRINGS = YES; 327 | SWIFT_VERSION = 5.0; 328 | TARGETED_DEVICE_FAMILY = "1,2,7"; 329 | XROS_DEPLOYMENT_TARGET = 2.2; 330 | }; 331 | name = Release; 332 | }; 333 | /* End XCBuildConfiguration section */ 334 | 335 | /* Begin XCConfigurationList section */ 336 | 4AAD454B2D1A779900DEAA42 /* Build configuration list for PBXProject "CircularControlExample" */ = { 337 | isa = XCConfigurationList; 338 | buildConfigurations = ( 339 | 4AAD455D2D1A779A00DEAA42 /* Debug */, 340 | 4AAD455E2D1A779A00DEAA42 /* Release */, 341 | ); 342 | defaultConfigurationIsVisible = 0; 343 | defaultConfigurationName = Release; 344 | }; 345 | 4AAD455F2D1A779A00DEAA42 /* Build configuration list for PBXNativeTarget "CircularControlExample" */ = { 346 | isa = XCConfigurationList; 347 | buildConfigurations = ( 348 | 4AAD45602D1A779A00DEAA42 /* Debug */, 349 | 4AAD45612D1A779A00DEAA42 /* Release */, 350 | ); 351 | defaultConfigurationIsVisible = 0; 352 | defaultConfigurationName = Release; 353 | }; 354 | /* End XCConfigurationList section */ 355 | 356 | /* Begin XCLocalSwiftPackageReference section */ 357 | 4AAD456F2D1A77D900DEAA42 /* XCLocalSwiftPackageReference "../../PZCircularControl" */ = { 358 | isa = XCLocalSwiftPackageReference; 359 | relativePath = ../../PZCircularControl; 360 | }; 361 | /* End XCLocalSwiftPackageReference section */ 362 | 363 | /* Begin XCSwiftPackageProductDependency section */ 364 | 4AAD45702D1A77D900DEAA42 /* PZCircularControl */ = { 365 | isa = XCSwiftPackageProductDependency; 366 | productName = PZCircularControl; 367 | }; 368 | /* End XCSwiftPackageProductDependency section */ 369 | }; 370 | rootObject = 4AAD45482D1A779900DEAA42 /* Project object */; 371 | } 372 | -------------------------------------------------------------------------------- /Example/CircularControlExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/CircularControlExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/CircularControlExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | }, 30 | { 31 | "idiom" : "mac", 32 | "scale" : "1x", 33 | "size" : "16x16" 34 | }, 35 | { 36 | "idiom" : "mac", 37 | "scale" : "2x", 38 | "size" : "16x16" 39 | }, 40 | { 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "32x32" 44 | }, 45 | { 46 | "idiom" : "mac", 47 | "scale" : "2x", 48 | "size" : "32x32" 49 | }, 50 | { 51 | "idiom" : "mac", 52 | "scale" : "1x", 53 | "size" : "128x128" 54 | }, 55 | { 56 | "idiom" : "mac", 57 | "scale" : "2x", 58 | "size" : "128x128" 59 | }, 60 | { 61 | "idiom" : "mac", 62 | "scale" : "1x", 63 | "size" : "256x256" 64 | }, 65 | { 66 | "idiom" : "mac", 67 | "scale" : "2x", 68 | "size" : "256x256" 69 | }, 70 | { 71 | "idiom" : "mac", 72 | "scale" : "1x", 73 | "size" : "512x512" 74 | }, 75 | { 76 | "idiom" : "mac", 77 | "scale" : "2x", 78 | "size" : "512x512" 79 | } 80 | ], 81 | "info" : { 82 | "author" : "xcode", 83 | "version" : 1 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Example/CircularControlExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/CircularControlExample/CircularControlExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/CircularControlExample/CircularControlExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularControlExampleApp.swift 3 | // CircularControlExample 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct CircularControlExampleApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Example/CircularControlExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CircularControlExample 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | var body: some View { 12 | NavigationSplitView { 13 | List { 14 | NavigationLink("Simple Progress") { 15 | SimpleDemoView() 16 | .navigationTitle("Simple Progress") 17 | .toolbarTitleDisplayMode(.inline) 18 | } 19 | 20 | NavigationLink("Editable Control") { 21 | EditableDemoView() 22 | .navigationTitle("Editable Control") 23 | .toolbarTitleDisplayMode(.inline) 24 | } 25 | } 26 | #if os(macOS) 27 | .navigationSplitViewColumnWidth(min: 180, ideal: 200) 28 | #endif 29 | } detail: { 30 | Text("Select an item") 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Example/CircularControlExample/DynamicView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DynamicView.swift 3 | // CircularControlExample 4 | // 5 | // Created by Phil Zakharchenko on 12/24/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A view that switches between horizontal and vertical layouts depending on the horizontal size class and fitting size. 11 | struct DynamicView: View { 12 | @ViewBuilder let contentView: Content 13 | 14 | #if !os(macOS) 15 | @Environment(\.horizontalSizeClass) var horizontalSizeClass 16 | #else 17 | enum SizeClass { 18 | case compact 19 | case regular 20 | } 21 | 22 | private let horizontalSizeClass: SizeClass = .regular 23 | #endif 24 | 25 | var body: some View { 26 | ViewThatFits { 27 | switch horizontalSizeClass { 28 | case .regular: 29 | HStack { 30 | contentView 31 | } 32 | .padding() 33 | 34 | VStack { 35 | contentView 36 | } 37 | .padding() 38 | default: 39 | VStack { 40 | contentView 41 | } 42 | .padding() 43 | 44 | HStack { 45 | contentView 46 | } 47 | .padding() 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Example/CircularControlExample/EditableDemoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditableDemoView.swift 3 | // CircularControlExample 4 | // 5 | // Created by Phil Zakharchenko on 12/24/24. 6 | // 7 | 8 | import SwiftUI 9 | import PZCircularControl 10 | 11 | struct EditableDemoView: View { 12 | @State private var isDisabled: Bool = false 13 | @State private var allowsWrapping: Bool = false 14 | 15 | @State private var firstProgress: Double = 0.4 16 | @State private var secondProgress: Double = 0.75 17 | 18 | var body: some View { 19 | VStack { 20 | GroupBox { 21 | Toggle("Disabled", isOn: $isDisabled) 22 | Toggle("Wraps Around", isOn: $allowsWrapping) 23 | .disabled(isDisabled) 24 | } 25 | 26 | DynamicView { 27 | contentView 28 | .circularControlAllowsWrapping(allowsWrapping) 29 | } 30 | .frame(maxHeight: .infinity, alignment: .center) 31 | } 32 | .padding() 33 | } 34 | 35 | @ViewBuilder 36 | private var contentView: some View { 37 | CircularControl(progress: $firstProgress) 38 | .padding() 39 | .disabled(isDisabled) 40 | 41 | CircularControl(progress: $secondProgress, strokeWidth: 30, style: .init( 42 | track: Color.indigo.opacity(0.2), 43 | progress: LinearGradient( 44 | colors: [.mint, .blue], 45 | startPoint: .topLeading, 46 | endPoint: .bottomTrailing 47 | ), 48 | shadow: .init(color: .mint.opacity(0.6), radius: 12) 49 | )) 50 | .fontDesign(.rounded) 51 | .circularControlKnobScale(1) 52 | .disabled(isDisabled) 53 | .padding() 54 | 55 | CircularControl( 56 | progress: $secondProgress, 57 | strokeWidth: 25, 58 | style: .init( 59 | track: Color.cyan.opacity(0.2), 60 | progress: Color.cyan 61 | ), 62 | format: .custom({ value in "\(Int(value * 10)) / 10" }) 63 | ) 64 | .disabled(isDisabled) 65 | .padding() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Example/CircularControlExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/CircularControlExample/SimpleDemoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimpleDemoView.swift 3 | // CircularControlExample 4 | // 5 | // Created by Phil Zakharchenko on 12/24/24. 6 | // 7 | 8 | import SwiftUI 9 | import PZCircularControl 10 | 11 | struct SimpleDemoView: View { 12 | var body: some View { 13 | DynamicView { 14 | contentView 15 | } 16 | } 17 | 18 | @ViewBuilder 19 | private var contentView: some View { 20 | CircularControl(progress: 0.4) 21 | .padding() 22 | 23 | CircularControl(progress: 0.63, strokeWidth: 30, style: .init( 24 | track: Color.indigo.opacity(0.2), 25 | progress: LinearGradient( 26 | colors: [.teal, .blue], 27 | startPoint: .topLeading, 28 | endPoint: .bottomTrailing 29 | ), 30 | shadow: .init(color: .teal.opacity(0.6), radius: 12) 31 | ), format: .fraction) 32 | .circularControlProgressAnimation(.bouncy) 33 | .fontDesign(.serif) 34 | .padding() 35 | 36 | CircularControl( 37 | progress: 0.75, 38 | strokeWidth: 25, 39 | style: .init( 40 | track: Color.orange.opacity(0.2), 41 | progress: Color.orange 42 | ) 43 | ) { 44 | Image(systemName: "star.fill") 45 | .font(.largeTitle) 46 | .foregroundStyle(.orange) 47 | } 48 | .circularControlProgressAnimation(.bouncy(duration: 2.4)) 49 | .padding() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2024 Phil Zakharchenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 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: "PZCircularControl", 8 | platforms: [ 9 | .iOS(.v17), .macOS(.v14), .watchOS(.v10), .tvOS(.v17), .visionOS(.v1) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library( 14 | name: "PZCircularControl", 15 | targets: ["PZCircularControl"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 24 | .target( 25 | name: "PZCircularControl", 26 | dependencies: []), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Circular Control](./Resources/CircularControl.png) 2 | 3 | # SwiftUI Circular Control 4 | 5 | A cross-platform, highly customizable circular progress control for SwiftUI. 6 | 7 | [![Swift Package Manager](https://img.shields.io/badge/Swift_Package_Manager-compatible-orange?style=flat-square)](https://swift.org/package-manager) 8 | [![Platforms](https://img.shields.io/badge/Platforms-iOS_17.0+_macOS_14.0+_visionOS_1+-blue?style=flat-square)](https://developer.apple.com/swift) 9 | [![Swift](https://img.shields.io/badge/Swift-6.0+-orange?style=flat-square)](https://swift.org) 10 | 11 | ## Features 12 | 13 | - Use as an editable control or a progress indicator 14 | - Rich customization options with built-in and custom styles 15 | - Built-in animation and transition support 16 | 17 | ## Installation 18 | 19 | ### Swift Package Manager 20 | 21 | Add PZCircularControl to your project through Xcode's package manager: 22 | 23 | 1. File > Add Package Dependencies 24 | 2. Add https://github.com/philptr/PZCircularControl.git 25 | 26 | Or add it to your `Package.swift`: 27 | 28 | ```swift 29 | dependencies: [ 30 | .package(url: "https://github.com/philptr/PZCircularControl.git", from: "1.0.0") 31 | ] 32 | ``` 33 | 34 | ## Quick Start 35 | 36 | ### Static Progress 37 | 38 | ![Basic Usage](./Resources/static-progress.gif) 39 | 40 | You can add a simple yet customizable progress indicator to your view in a single line of code. 41 | 42 | ```swift 43 | // Basic usage: 44 | CircularControl(progress: 0.4) 45 | 46 | // Customized style: 47 | CircularControl( 48 | progress: 0.75, 49 | strokeWidth: 15, 50 | style: .init( 51 | track: Color.secondary.opacity(0.25), 52 | progress: LinearGradient( 53 | colors: [.teal, .mint], 54 | startPoint: .topLeading, 55 | endPoint: .bottomTrailing 56 | ) 57 | ) 58 | ) 59 | ``` 60 | 61 | ### Interactive Control 62 | 63 | ![Interactive Control](./Resources/interactive-control.gif) 64 | 65 | ```swift 66 | import CircularControl 67 | 68 | struct ContentView: View { 69 | @State private var progress = 0.7 70 | 71 | var body: some View { 72 | // A basic interactive control usage with the default style: 73 | CircularControl(progress: $progress) 74 | 75 | // A customized interactive control with a custom style: 76 | CircularControl( 77 | progress: $progress, 78 | strokeWidth: 15, 79 | style: .init( 80 | track: Color.secondary.opacity(0.2), 81 | progress: LinearGradient( 82 | colors: [.blue, .purple], 83 | startPoint: .topLeading, 84 | endPoint: .bottomTrailing 85 | ) 86 | ) 87 | ) 88 | } 89 | } 90 | ``` 91 | 92 | Check out the example project for more usage examples. 93 | 94 | ## Customization 95 | 96 | ### Styles 97 | 98 | PZCircularControl supports custom styles for the track, the progress bar, and the interactive knob: 99 | 100 | ```swift 101 | let style = CircularControlStyle( 102 | track: Color.gray.opacity(0.2), 103 | progress: LinearGradient(colors: [.blue, .purple]), 104 | knob: Color.white 105 | ) 106 | 107 | CircularControl( 108 | progress: progress, 109 | style: style 110 | ) { 111 | Text("\(Int(progress * 100))%") 112 | } 113 | ``` 114 | 115 | ### Custom Labels 116 | 117 | You can provide any SwiftUI view as a label: 118 | 119 | ```swift 120 | CircularControl(progress: progress) { 121 | VStack { 122 | Image(systemName: "star.fill") 123 | Text("\(Int(progress * 100))%") 124 | } 125 | } 126 | ``` 127 | 128 | To simplify your custom label implementation, you can read `circularControlProgress` from environment. 129 | 130 | ### Environment Configuration 131 | 132 | Configure behavior through environment values: 133 | 134 | ```swift 135 | CircularControl(progress: $progress) 136 | // Enable continuous wrapping (off by default). 137 | .circularControlAllowsWrapping(true) 138 | // Adjust the scale of the interactive knob. 139 | .circularControlKnobScale(2.0) 140 | ``` 141 | 142 | ### Progress Updates 143 | 144 | You don't have to use the Binding-based initializer to support user interaction. Instead, you may provide an initial value, specify the `isEditable` parameter, and track changes via a closure. 145 | 146 | ```swift 147 | CircularControl( 148 | progress: progress, 149 | isEditable: true 150 | ) { newValue in 151 | print("Progress updated to \(newValue).") 152 | } 153 | ``` 154 | 155 | ## Migration 156 | 157 | The API surface of PZCircularControl has been completely reworked in 1.0.0 to better match the latest Swift and SwiftUI conventions, support editing, and provide more opportunities for client customization. For details about the legacy API and support for older OS versions, view the section below. 158 | 159 | Migration guide: 160 | 1. Replace `PZCircularControl` with `CircularControl`. 161 | 2. Convert `PZCircularControlParams` to the new style system. 162 | 3. Move any overlay views to the new label view builder. 163 | 4. Update progress handling to use the new binding-based API. 164 | 165 |
166 | 167 | Versions 0.x 168 | 169 | ## Usage 170 | 171 | 1. Install via Swift Package Manager 172 | 2. Create a `PZCircularControlParams` object to configure the style of your control (optional) 173 | 3. Instanciate a `PZCircularControl` and pass in the params 174 | 175 | ### Basic example 176 | 177 | ```swift 178 | PZCircularControl( 179 | PZCircularControlParams( 180 | innerBackgroundColor: Color.clear, 181 | outerBackgroundColor: Color.gray.opacity(0.5), 182 | tintColor: LinearGradient(gradient: Gradient(colors: [.red, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing) 183 | ) 184 | ) 185 | ``` 186 | 187 | This produces the following output: 188 | 189 | ![Output Image](./Resources/old-example-1.png) 190 | 191 | The params object's instance data can be modified and animated. For example, the following code animates the control to the 35% state when the button is tapped: 192 | 193 | ```swift 194 | PZExampleButton(label: "35%", font: .headline) { 195 | withAnimation(.easeInOut(duration: 1.0)) { 196 | control.params.progress = CGFloat(0.35) 197 | } 198 | } 199 | ``` 200 | 201 | ## More examples 202 | 203 | ### Dark background (see cover image) 204 | 205 | ```swift 206 | PZCircularControl( 207 | PZCircularControlParams( 208 | innerBackgroundColor: Color.black, 209 | outerBackgroundColor: Color.black, 210 | tintColor: LinearGradient(gradient: Gradient(colors: [.yellow, .pink]), startPoint: .bottomLeading, endPoint: .topLeading), 211 | textColor: .orange, 212 | barWidth: 24.0, 213 | glowDistance: 15.0, 214 | initialValue: CGFloat(Float.random(in: 0...1)) 215 | ) 216 | ) 217 | ``` 218 | 219 | ### Purple text and gradient fill 220 | 221 | ```swift 222 | PZCircularControl( 223 | PZCircularControlParams( 224 | innerBackgroundColor: Color.clear, 225 | outerBackgroundColor: Color.gray.opacity(0.5), 226 | tintColor: LinearGradient(gradient: Gradient(colors: [.blue, .purple]), startPoint: .bottomLeading, endPoint: .topLeading), 227 | textColor: .purple, 228 | barWidth: 30.0, 229 | glowDistance: 30.0, 230 | initialValue: CGFloat(Float.random(in: 0...1)) 231 | ) 232 | ) 233 | ``` 234 | 235 | ![Output Image](./Resources/old-example-2.png) 236 | 237 | ## Customization 238 | 239 | `PZCircularControlParams` object is accessible through the `params` instance field of a `PZCircularControl`. Any change to the instance will be reflected on the control automatically, without the need to refresh it or reload any data. 240 | 241 | Some customization options include: 242 | * `progress` (accessible via `<>.params.progress`) – the current progress the control displays (from 0.0 to 1.0, as `CGFloat`). 243 | * `glowDistance` – the radius of the glow effect around the control (`CGFloat`). Set to 0.0 to remove the glow. 244 | * `barWidth` – the width of the stroke of the control. 245 | * `textColor` – the progress label color (as SwiftUI `Color`). 246 | * `font` – the progress label font (as SwiftUI `Font`). 247 | * `innerBackgroundColor` – the color of the inner part of your control (inner radius). Has to conform to `ShapeStyle` (ie. anything from `Color` to `Gradient`). 248 | * `outerBackgroundColor` – the color of the active part's background. Has to conform to `ShapeStyle` (ie. anything from `Color` to `Gradient`). 249 | * `tintColor` – the tint color of the active area of your control. Has to conform to `ShapeStyle` (ie. anything from `Color` to `Gradient`). 250 | * `textFormatter` – an optional closure that takes in a CGFloat value of the current progress between 0.0 and 1.0 and returns formatted text that will be displayed in the center of the progress bar. 251 | 252 |
253 | 254 | ## License 255 | 256 | MIT License 257 | 258 | Copyright (c) 2019-2024 Phil Zakharchenko 259 | 260 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 261 | 262 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 263 | 264 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 265 | -------------------------------------------------------------------------------- /Resources/CircularControl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philptr/PZCircularControl/8a3423f7d91542d81c255157dd57656ca223792c/Resources/CircularControl.png -------------------------------------------------------------------------------- /Resources/interactive-control.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philptr/PZCircularControl/8a3423f7d91542d81c255157dd57656ca223792c/Resources/interactive-control.gif -------------------------------------------------------------------------------- /Resources/old-example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philptr/PZCircularControl/8a3423f7d91542d81c255157dd57656ca223792c/Resources/old-example-1.png -------------------------------------------------------------------------------- /Resources/old-example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philptr/PZCircularControl/8a3423f7d91542d81c255157dd57656ca223792c/Resources/old-example-2.png -------------------------------------------------------------------------------- /Resources/static-progress.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philptr/PZCircularControl/8a3423f7d91542d81c255157dd57656ca223792c/Resources/static-progress.gif -------------------------------------------------------------------------------- /Sources/PZCircularControl/CircularControl+Knob.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularControl+Knob.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension CircularControl { 11 | struct Knob: View { 12 | let style: KnobStyle 13 | let position: CGPoint 14 | @Binding var isDragging: Bool 15 | 16 | @Environment(\.isEnabled) private var isEnabled 17 | 18 | var body: some View { 19 | Rectangle() 20 | .fill(style.opacity(isEnabled ? isDragging ? 0.8 : 1 : 0.3)) 21 | .background(.thinMaterial) 22 | .clipShape(.circle) 23 | .contentShape(.circle) 24 | .shadow(radius: 0.5) 25 | .shadow(radius: isDragging ? 3 : 2) 26 | #if !os(macOS) && !os(watchOS) 27 | .hoverEffect() 28 | #endif 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/CircularControl+Track.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularControl+Track.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension CircularControl { 11 | struct Track: View { 12 | @Environment(\.circularControlAllowsWrapping) private var allowsWrapping 13 | @Environment(\.circularControlKnobScale) private var knobScale 14 | 15 | let progress: Double 16 | let isEditable: Bool 17 | let strokeWidth: CGFloat 18 | let style: CircularControlStyle 19 | let onProgressChange: ((Double) -> Void)? 20 | 21 | @State private var currentProgress: Double = 0 22 | @State private var currentAngle: Double? 23 | @State private var isDragging = false 24 | 25 | @Environment(\.isEnabled) private var isEnabled 26 | @Environment(\.circularControlProgressAnimation) private var progressAnimation 27 | 28 | var body: some View { 29 | GeometryReader { geometry in 30 | let size = min(geometry.size.width, geometry.size.height) 31 | let center = CGPoint(x: size / 2, y: size / 2) 32 | let radius = (size - strokeWidth) / 2 33 | 34 | ZStack { 35 | backgroundTrack 36 | progressView 37 | 38 | if isEditable { 39 | let position = knobPosition(for: currentProgress, radius: radius) 40 | Knob(style: style.knob, position: position, isDragging: $isDragging) 41 | .frame(width: strokeWidth * knobScale, height: strokeWidth * knobScale) 42 | .position( 43 | x: center.x + position.x, 44 | y: center.y + position.y 45 | ) 46 | .animation(nil, value: currentProgress) 47 | } 48 | } 49 | .gesture( 50 | DragGesture(minimumDistance: 0) 51 | .onChanged { value in 52 | handleDrag(value, in: geometry.size) 53 | } 54 | .onEnded { value in 55 | handleDrag(value, in: geometry.size) 56 | 57 | isDragging = false 58 | currentAngle = nil 59 | }, 60 | isEnabled: isEditable && isEnabled 61 | ) 62 | } 63 | .aspectRatio(1, contentMode: .fit) 64 | .padding(strokeWidth / 2) 65 | .onChange(of: progress) { oldValue, newValue in 66 | if !isDragging { 67 | withAnimation(progressAnimation) { 68 | currentProgress = newValue 69 | } 70 | } 71 | } 72 | .onAppear { 73 | withAnimation(progressAnimation) { 74 | currentProgress = progress 75 | } 76 | } 77 | } 78 | 79 | private var backgroundTrack: some View { 80 | Circle() 81 | .stroke(style.track, lineWidth: strokeWidth) 82 | } 83 | 84 | private var progressView: some View { 85 | Circle() 86 | .trim(from: 0, to: currentProgress) 87 | .stroke( 88 | style.progress, 89 | style: StrokeStyle( 90 | lineWidth: strokeWidth, 91 | lineCap: .round 92 | ) 93 | ) 94 | .rotationEffect(.degrees(-90)) 95 | .shadow( 96 | color: style.shadow?.color ?? .clear, 97 | radius: style.shadow?.radius ?? 0 98 | ) 99 | } 100 | 101 | private func knobPosition(for progress: Double, radius: CGFloat) -> CGPoint { 102 | let angle = Double.pi * 2 * progress - Double.pi / 2 103 | return CGPoint( 104 | x: CoreGraphics.cos(angle) * (radius + strokeWidth/2), 105 | y: CoreGraphics.sin(angle) * (radius + strokeWidth/2) 106 | ) 107 | } 108 | 109 | private func handleDrag(_ value: DragGesture.Value, in size: CGSize) { 110 | let center = CGPoint(x: size.width / 2, y: size.height / 2) 111 | let location = value.location 112 | let angle = atan2(location.y - center.y, location.x - center.x) 113 | 114 | var progress = (angle + .pi / 2) / (.pi * 2) 115 | if progress < 0 { 116 | progress += 1 117 | } 118 | 119 | if !isDragging { 120 | isDragging = true 121 | currentAngle = progress 122 | #if os(iOS) 123 | HapticFeedback.selection.play() 124 | #endif 125 | } 126 | 127 | if let currentAngle { 128 | let delta = progress - currentAngle 129 | let isIncreasing = delta > 0 130 | 131 | // Handle wrapping 132 | if !allowsWrapping { 133 | if abs(delta) > 0.5 { 134 | // User dragged across the boundary 135 | if isIncreasing { 136 | // Trying to wrap from end to start 137 | progress = 0 138 | } else { 139 | // Trying to wrap from start to end 140 | progress = 1 141 | } 142 | } else { 143 | let candidateProgress = currentProgress + delta 144 | if isIncreasing { 145 | progress = min(candidateProgress, 1) 146 | } else { 147 | progress = max(candidateProgress, 0) 148 | } 149 | } 150 | } else if abs(delta) > 0.5 { 151 | // Adjust for wraparound 152 | if delta > 0 { 153 | progress -= 1 154 | } else { 155 | progress += 1 156 | } 157 | } 158 | } 159 | 160 | self.currentAngle = progress 161 | 162 | #if os(iOS) 163 | if abs(progress - currentProgress) > 0.05 { 164 | HapticFeedback.light.play() 165 | } 166 | #endif 167 | 168 | currentProgress = progress 169 | onProgressChange?(currentProgress) 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/CircularControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularControl.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/6/19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A circular progress control that supports rich customization of appearance and behavior. 11 | /// 12 | /// `CircularControl` can be either static or interactive. 13 | /// The control supports customizable appearance through generic style parameters and can display a built-in or custom label view 14 | /// in its center. 15 | public struct CircularControl: View { 16 | private let progress: Double 17 | private let isEditable: Bool 18 | private let strokeWidth: CGFloat 19 | private let strokeStyle: CircularControlStyle 20 | private let label: Label 21 | private let onProgressChange: ((Double) -> Void)? 22 | 23 | @State private var currentProgress: Double 24 | 25 | @Environment(\.circularControlAllowsWrapping) private var allowsWrapping 26 | @Environment(\.circularControlKnobScale) private var knobScale 27 | 28 | /// Creates a circular control with the specified configuration. 29 | /// 30 | /// - Parameters: 31 | /// - progress: The current progress value, between 0 and 1. 32 | /// - isEditable: Whether the control responds to user interaction. Defaults to `false`. 33 | /// - strokeWidth: The width of the progress track. Defaults to 20. 34 | /// - style: The visual style configuration for the control. 35 | /// - onProgressChange: A closure called when the progress value changes through user interaction. 36 | /// - label: A view builder that creates the label view displayed in the center of the control. 37 | public init( 38 | progress: Double, 39 | isEditable: Bool = false, 40 | strokeWidth: CGFloat = .defaultStrokeWidth, 41 | style: CircularControlStyle = .init(), 42 | onProgressChange: ((Double) -> Void)? = nil, 43 | @ViewBuilder label: () -> Label 44 | ) { 45 | let initialProgress = progress.clamped(to: 0...1) 46 | self.progress = initialProgress 47 | self._currentProgress = State(initialValue: initialProgress) 48 | self.isEditable = isEditable 49 | self.strokeWidth = strokeWidth 50 | self.strokeStyle = style 51 | self.onProgressChange = onProgressChange 52 | self.label = label() 53 | } 54 | 55 | /// Creates an editable circular control bound to a progress value. 56 | /// 57 | /// This initializer creates an interactive control that automatically updates the bound progress value 58 | /// when the user interacts with it. The control is always editable when created with this initializer. 59 | /// 60 | /// - Parameters: 61 | /// - progress: A binding to the progress value, which will be clamped between 0 and 1. 62 | /// - strokeWidth: The width of the progress track. Defaults to 20. 63 | /// - style: The visual style configuration for the control. 64 | /// - label: A view builder that creates the label view displayed in the center of the control. 65 | public init( 66 | progress: Binding, 67 | strokeWidth: CGFloat = .defaultStrokeWidth, 68 | style: CircularControlStyle = .init(), 69 | @ViewBuilder label: () -> Label 70 | ) { 71 | let initialProgress = progress.wrappedValue.clamped(to: 0...1) 72 | self.progress = initialProgress 73 | self._currentProgress = State(initialValue: initialProgress) 74 | self.isEditable = true 75 | self.strokeWidth = strokeWidth 76 | self.strokeStyle = style 77 | self.onProgressChange = { newValue in 78 | progress.wrappedValue = newValue.clamped(to: 0...1) 79 | } 80 | self.label = label() 81 | } 82 | 83 | public var body: some View { 84 | Track( 85 | progress: currentProgress, 86 | isEditable: isEditable, 87 | strokeWidth: strokeWidth, 88 | style: strokeStyle, 89 | onProgressChange: { newProgress in 90 | currentProgress = newProgress 91 | onProgressChange?(newProgress) 92 | } 93 | ) 94 | .overlay( 95 | label 96 | .environment(\.circularControlProgress, currentProgress) 97 | ) 98 | .onChange(of: progress) { _, newValue in 99 | currentProgress = newValue.clamped(to: 0...1) 100 | } 101 | } 102 | } 103 | 104 | // MARK: - Default Label Convenience Initializer 105 | 106 | extension CircularControl where Label == DefaultCircularControlLabel { 107 | /// Creates a circular control with a default progress label. 108 | /// 109 | /// This convenience initializer configures the control with a built-in label that formats 110 | /// the progress value according to the specified format. 111 | /// 112 | /// - Parameters: 113 | /// - progress: The current progress value, between 0 and 1. 114 | /// - isEditable: Whether the control responds to user interaction. Defaults to false. 115 | /// - strokeWidth: The width of the progress track. Defaults to 20. 116 | /// - style: The visual style configuration for the control. 117 | /// - format: The format to use for displaying the progress value. Defaults to percentage. 118 | /// - onProgressChange: A closure called when the progress value changes through user interaction. 119 | public init( 120 | progress: Double, 121 | isEditable: Bool = false, 122 | strokeWidth: CGFloat = .defaultStrokeWidth, 123 | style: CircularControlStyle = .init(), 124 | format: CircularControlLabelFormat = .percentage, 125 | onProgressChange: ((Double) -> Void)? = nil 126 | ) { 127 | self.init( 128 | progress: progress, 129 | isEditable: isEditable, 130 | strokeWidth: strokeWidth, 131 | style: style, 132 | onProgressChange: onProgressChange 133 | ) { 134 | DefaultCircularControlLabel(format: format) 135 | } 136 | } 137 | 138 | /// Creates an editable circular control with a default percentage label bound to a progress value. 139 | /// 140 | /// This convenience initializer creates an interactive control with a built-in label that formats 141 | /// the progress value according to the specified format. The control is always editable when created 142 | /// with this initializer. 143 | /// 144 | /// - Parameters: 145 | /// - progress: A binding to the progress value, which will be clamped between 0 and 1. 146 | /// - strokeWidth: The width of the progress track. Defaults to 20. 147 | /// - style: The visual style configuration for the control. 148 | /// - format: The format to use for displaying the progress value. Defaults to percentage. 149 | public init( 150 | progress: Binding, 151 | strokeWidth: CGFloat = .defaultStrokeWidth, 152 | style: CircularControlStyle = .init(), 153 | format: CircularControlLabelFormat = .percentage 154 | ) { 155 | self.init( 156 | progress: progress, 157 | strokeWidth: strokeWidth, 158 | style: style 159 | ) { 160 | DefaultCircularControlLabel(format: format) 161 | } 162 | } 163 | } 164 | 165 | extension CGFloat { 166 | @usableFromInline 167 | static let defaultStrokeWidth: CGFloat = 20 168 | } 169 | 170 | // MARK: - Xcode Previews 171 | 172 | #Preview { 173 | VStack { 174 | CircularControl( 175 | progress: 0.7, 176 | isEditable: true, 177 | style: .init( 178 | track: Color.indigo.opacity(0.2), 179 | progress: LinearGradient( 180 | colors: [.indigo, .purple], 181 | startPoint: .topLeading, 182 | endPoint: .bottomTrailing 183 | ), 184 | shadow: .init(color: .purple.opacity(0.3), radius: 10) 185 | ) 186 | ) { progress in 187 | print("Progress changed to: \(progress)") 188 | } 189 | .circularControlAllowsWrapping(false) 190 | 191 | CircularControl( 192 | progress: 0.4, 193 | strokeWidth: 25, 194 | style: .init( 195 | track: Color.orange.opacity(0.2), 196 | progress: Color.orange 197 | ) 198 | ) { 199 | VStack { 200 | Image(systemName: "star.fill") 201 | .font(.title) 202 | Text("40%") 203 | .font(.headline) 204 | } 205 | .foregroundStyle(.orange) 206 | } 207 | } 208 | .padding() 209 | } 210 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/CircularControlLabelFormat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularControlLabelFormat.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum CircularControlLabelFormat { 11 | case percentage 12 | case fraction 13 | case custom((Double) -> String) 14 | 15 | func string(from value: Double) -> String { 16 | switch self { 17 | case .percentage: 18 | return "\(Int(value * 100))%" 19 | case .fraction: 20 | return String(format: "%.1f", value) 21 | case .custom(let formatter): 22 | return formatter(value) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/CircularControlStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CircularControlStyle.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct CircularControlStyle { 11 | public struct Shadow { 12 | let color: Color 13 | let radius: CGFloat 14 | 15 | public init(color: Color, radius: CGFloat) { 16 | self.color = color 17 | self.radius = radius 18 | } 19 | } 20 | 21 | let track: TrackStyle 22 | let progress: ProgressStyle 23 | let knob: KnobStyle 24 | let shadow: Shadow? 25 | 26 | public init( 27 | track: TrackStyle = Color.secondary.opacity(0.2), 28 | progress: ProgressStyle = LinearGradient( 29 | colors: [.blue, .purple], 30 | startPoint: .topLeading, 31 | endPoint: .bottomTrailing 32 | ), 33 | knob: KnobStyle = Color.circularControlKnob, 34 | shadow: Shadow? = .init(color: .blue.opacity(0.3), radius: 8) 35 | ) { 36 | self.track = track 37 | self.progress = progress 38 | self.knob = knob 39 | self.shadow = shadow 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/DefaultCircularControlLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultCircularControlLabel.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/6/19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct DefaultCircularControlLabel: View { 11 | let format: CircularControlLabelFormat 12 | 13 | @Environment(\.circularControlProgress) private var progress 14 | 15 | public var body: some View { 16 | ViewThatFits { 17 | Text(format.string(from: progress)) 18 | .font(.system(.title, weight: .semibold).monospacedDigit()) 19 | .fixedSize() 20 | .foregroundStyle(.primary) 21 | .animation(.snappy, value: progress) 22 | .contentTransition(.numericText()) 23 | 24 | Text("") 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/Extensions/Color+Knob.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Knob.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Color { 11 | public static var circularControlKnob: Self { 12 | #if os(macOS) 13 | Color(nsColor: NSColor.controlColor) 14 | #else 15 | .white 16 | #endif 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/Extensions/Comparable+Clamped.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Comparable+Clamped.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Comparable { 11 | func clamped(to limits: ClosedRange) -> Self { 12 | min(max(self, limits.lowerBound), limits.upperBound) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/Extensions/EnvironmentValues+CircularControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues+CircularControl.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension EnvironmentValues { 11 | /// Controls whether the circular control allows continuous rotation past its start/end points. 12 | /// 13 | /// When `true`, the control allows the user to continuously rotate past the maximum value, 14 | /// wrapping back to zero, or past the minimum value, wrapping to maximum. When `false`, 15 | /// the control clamps at its minimum and maximum values. 16 | /// 17 | /// The default value is `false`. 18 | /// 19 | /// - Note: This setting only affects interactive controls where `isEditable` is `true`. 20 | @Entry public var circularControlAllowsWrapping: Bool = false 21 | 22 | /// Specifies the size of the control's drag knob relative to the stroke width. 23 | /// 24 | /// The knob size is calculated by multiplying this value with the control's stroke width. 25 | /// For example, a value of 1.4 makes the knob 40% larger than the stroke width. 26 | /// 27 | /// - Note: This setting only affects interactive controls where `isEditable` is `true`. 28 | @Entry public var circularControlKnobScale: CGFloat = 1.4 29 | 30 | /// Defines the animation used when the progress value changes programmatically. 31 | /// 32 | /// When set, this animation is applied to progress changes that occur through binding updates 33 | /// or direct property changes. It does not affect interactive changes made through dragging. 34 | /// 35 | /// The default value is `nil`, which means no animation is applied. 36 | @Entry public var circularControlProgressAnimation: Animation? 37 | 38 | /// The current progress value of the control, made available to child views. 39 | /// 40 | /// This value is automatically updated as the control's progress changes and can be 41 | /// read by child views (particularly custom labels) to respond to progress changes. 42 | /// 43 | /// The value is always normalized between 0 and 1. 44 | @Entry public var circularControlProgress: Double = 0 45 | } 46 | 47 | extension View { 48 | /// Configures whether circular controls in this view allow continuous rotation. 49 | /// 50 | /// Example: 51 | /// ```swift 52 | /// CircularControl(progress: $progress) 53 | /// .circularControlAllowsWrapping(true) 54 | /// ``` 55 | /// 56 | /// - Parameter allowsWrapping: Whether to allow continuous rotation past start/end points. 57 | /// - Returns: A view with the modified environment value. 58 | public func circularControlAllowsWrapping(_ allowsWrapping: Bool) -> some View { 59 | environment(\.circularControlAllowsWrapping, allowsWrapping) 60 | } 61 | 62 | /// Sets the size of the drag knob relative to the stroke width for circular controls in this view. 63 | /// 64 | /// Example: 65 | /// ```swift 66 | /// CircularControl(progress: $progress) 67 | /// .circularControlKnobScale(2.0) 68 | /// ``` 69 | /// 70 | /// - Parameter scale: The scale factor for the knob size relative to the stroke width. 71 | /// - Returns: A view with the modified environment value. 72 | public func circularControlKnobScale(_ scale: CGFloat) -> some View { 73 | environment(\.circularControlKnobScale, scale) 74 | } 75 | 76 | /// Sets the animation to use for progress changes in circular controls within this view. 77 | /// 78 | /// Example: 79 | /// ```swift 80 | /// CircularControl(progress: $progress) 81 | /// .circularControlProgressAnimation(.snappy()) 82 | /// ``` 83 | /// 84 | /// - Parameter animation: The animation to apply to progress changes, or `nil` for no animation. 85 | /// - Returns: A view with the modified environment value. 86 | public func circularControlProgressAnimation(_ animation: Animation?) -> some View { 87 | environment(\.circularControlProgressAnimation, animation) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/PZCircularControl/HapticFeedback.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HapticFeedback.swift 3 | // PZCircularControl 4 | // 5 | // Created by Phil Zakharchenko on 12/23/24. 6 | // 7 | 8 | #if os(iOS) 9 | 10 | import UIKit 11 | 12 | @MainActor 13 | enum HapticFeedback { 14 | case light 15 | case selection 16 | case impact(UIImpactFeedbackGenerator.FeedbackStyle) 17 | 18 | func play() { 19 | switch self { 20 | case .light: 21 | let generator = UIImpactFeedbackGenerator(style: .light) 22 | generator.impactOccurred() 23 | case .selection: 24 | let generator = UISelectionFeedbackGenerator() 25 | generator.selectionChanged() 26 | case .impact(let style): 27 | let generator = UIImpactFeedbackGenerator(style: style) 28 | generator.impactOccurred() 29 | } 30 | } 31 | } 32 | 33 | #endif 34 | --------------------------------------------------------------------------------