├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── SlidersLogo.svg ├── Sources └── Sliders │ ├── Joystick.swift │ ├── LSlider.swift │ ├── OverFlowSlider.swift │ ├── PSlider.swift │ ├── RadialPad.swift │ ├── RadialSlider.swift │ └── TrackPad.swift ├── Tests ├── LinuxMain.swift └── SlidersTests │ ├── SlidersTests.swift │ └── XCTestManifests.swift └── sliders-logo.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kieran Brown 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:5.2 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: "Sliders", 8 | platforms: [.iOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 11 | .library( 12 | name: "Sliders", 13 | targets: ["Sliders"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/kieranb662/CGExtender.git", from: "1.0.1"), 17 | .package(url: "https://github.com/kieranb662/Shapes.git", from: "1.0.2"), 18 | .package(url: "https://github.com/kieranb662/bez.git", from: "1.0.0") 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 25 | .target( 26 | name: "Sliders", 27 | dependencies: ["CGExtender", "Shapes", "bez"]), 28 | .testTarget( 29 | name: "SlidersTests", 30 | dependencies: ["Sliders"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | SwiftUI 7 | Swift 5.1 8 | Swift 5.1 9 | kieranb662 followers 10 |

11 | 12 | **Sliders** is a compilation of all my stylable drag based SwiftUI components. It provides a variety of unique controls as well as an enhanced version of the normal `Slider` called an `LSlider`. You can try them all out quickly by clone the example [project](https://github.com/kieranb662/SlidersExamples) 13 | 14 |

15 | Activity Rings Gif 16 |

17 | 18 | 19 | 20 | 21 | 22 | The various components are: 23 | * `LSlider` - a spatially adaptive slider that fits to its container at any angle you decide. 24 | * `RSlider` - A circularly shaped slider which restricts movement of the thumb to the radius of the circle 25 | * `PSlider` - Turn any `Shape` into its very own slider! 26 | * `OverflowSlider` - A meter like slider which has two moving components, the track and the thumb. Also has velocity based gestures implemented 27 | * `TrackPad` - A 2D version of a normal slider, which restricts displacement of the thumb to the bounds of the track 28 | * `RadialPad` - A joystick like component that does not reset the position of the thumb upon gesture end. 29 | * `Joystick` - an onscreen joystick that can appear anywhere the user drags within the defined hitbox, if the drag ends inside the lockbox, the joystick remains onscreen locked in the forward position. if the drag ends **not** inside the lockbox the joystick fades away until the hitbox is dragged again. 30 | 31 | 32 | 33 | ## Requirements 34 | 35 | **Sliders** as a default requires the SwiftUI Framework to be operational and since the `DragGesture` is required only these platforms can make use of the library: 36 | 37 | * macOS 10.15 or Greater 38 | * iOS 13 or Greater 39 | * watchOS 6 or Greater 40 | 41 | ## How To Add To Your Project 42 | 43 | 1. Snag that URL from the github repo 44 | 2. In Xcode -> File -> Swift Packages -> Add Package Dependencies 45 | 3. Paste the URL Into the box 46 | 4. Specify the minimum version number (This is new so 1.0.3 and greater will work). 47 | 48 | ## Dependencies 49 | 50 | * [CGExtender](https://github.com/kieranb662/CGExtender) 51 | * [Shapes](https://github.com/kieranb662/Shapes) - Currently looking for contributors 52 | * [bez](https://github.com/kieranb662/bez) 53 | 54 | ## LSlider 55 | 56 | ### Spatially Adaptive Linear Slider 57 | 58 | While at on the surface this slider my seem like a copy of the already available `Slider`. I implore you to try and make a neat layout with a `Slider` in the vertical position. 59 | After trying everything with SwiftUI's built in slider I realized making layouts with it just was not going to work. So I created the `LSlider`. It works just like a normal `Slider` except 60 | You can provide a value for the angle parameter which rotates the slider and adaptively fits it to its containing view. Also Its fully customizable with cascading styles thanks to Environment variables. 61 | 62 | - **parameters**: 63 | - `value`: `Binding` The value the slider should control 64 | - `range`: `ClosedRange` The minimum and maximum numbers that `value` can be 65 | - `angle`: `Angle` The angle you would like the slider to be at 66 | - `isDisabled`: `Bool` Whether or not the slider should be disabled 67 | 68 | ### Styling The Slider 69 | 70 | To create a custom style for the slider you need to create a `LSliderStyle` conforming struct. Conformance requires implementation of 2 methods 71 | 72 | 1. `makeThumb`: which creates the draggable portion of the slider 73 | 2. `makeTrack`: which creates the track which fills or empties as the thumb is dragging within it 74 | 75 | Both methods provide access to the sliders current state thru the `LSliderConfiguration` of the `LSlider `to be styled 76 | 77 | ````Swift 78 | struct LSliderConfiguration { 79 | let isDisabled: Bool // whether or not the slider is current disables 80 | let isActive: Bool // whether or not the thumb is dragging or not 81 | let pctFill: Double // The percentage of the sliders track that is filled 82 | let value: Double // The current value of the slider 83 | let angle: Angle // The angle of the slider 84 | let min: Double // The minimum value of the sliders range 85 | let max: Double // The maximum value of the sliders range 86 | } 87 | ```` 88 | 89 | To make this easier just copy and paste the following style based on the `DefaultLSliderStyle`. After creating your custom style 90 | apply it by calling the `linearSliderStyle` method on the `LSlider` or a view containing it. 91 | 92 | ````Swift 93 | struct <#My Slider Style#>: LSliderStyle { 94 | func makeThumb(configuration: LSliderConfiguration) -> some View { 95 | Circle() 96 | .fill(configuration.isActive ? Color.yellow : Color.white) 97 | .frame(width: 40, height: 40) 98 | } 99 | func makeTrack(configuration: LSliderConfiguration) -> some View { 100 | let style: StrokeStyle = .init(lineWidth: 10, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0) 101 | return AdaptiveLine(angle: configuration.angle) 102 | .stroke(Color.gray, style: style) 103 | .overlay(AdaptiveLine(angle: configuration.angle).trim(from: 0, to: CGFloat(configuration.pctFill)).stroke(Color.blue, style: style)) 104 | } 105 | } 106 | 107 | ```` 108 | 109 | ## Radial Slider 110 | A Circular slider whose thumb is dragged causing it to follow the path of the circle 111 | 112 | - **parameters**: 113 | - `value`: a `Binding` value to be controlled. 114 | - `range`: a `ClosedRange` denoting the minimum and maximum values of the slider (default is `0...1`) 115 | - `isDisabled`: a `Bool` value describing if the sliders state is disabled (default is `false`) 116 | 117 | ### Styling The Slider 118 | 119 | To create a custom style for the slider you need to create a `RSliderStyle` conforming struct. Conformance requires implementation of 2 methods 120 | 121 | 1. `makeThumb`: which creates the draggable portion of the slider 122 | 2. `makeTrack`: which creates the track which fills or emptys as the thumb is dragging within it 123 | 124 | Both methods provide access to state values of the radial slider thru the `RSliderConfiguration` struct 125 | 126 | ````Swift 127 | 128 | struct RSliderConfiguration { 129 | let isDisabled: Bool // whether or not the slider is current disables 130 | let isActive: Bool // whether or not the thumb is dragging or not 131 | let pctFill: Double // The percentage of the sliders track that is filled 132 | let value: Double // The current value of the slider 133 | let angle: Angle // The direction from the thumb to the slider center 134 | let min: Double // The minimum value of the sliders range 135 | let max: Double // The maximum value of the sliders range 136 | } 137 | 138 | ```` 139 | To make this easier just copy and paste the following style based on the `DefaultRSliderStyle`. After creating your custom style 140 | apply it by calling the `radialSliderStyle` method on the `RSlider` or a view containing it. 141 | 142 | ````Swift 143 | struct <#My Slider Style #>: RSliderStyle { 144 | func makeThumb(configuration: RSliderConfiguration) -> some View { 145 | Circle() 146 | .frame(width: 30, height:30) 147 | .foregroundColor(configuration.isActive ? Color.yellow : Color.white) 148 | } 149 | 150 | func makeTrack(configuration: RSliderConfiguration) -> some View { 151 | Circle() 152 | .stroke(Color.gray, style: StrokeStyle(lineWidth: 10, lineCap: .round)) 153 | .overlay(Circle() 154 | .trim(from: 0, to: CGFloat(configuration.pctFill)) 155 | .stroke(Color.purple, style: StrokeStyle(lineWidth: 12, lineCap: .round))) 156 | 157 | } 158 | } 159 | ```` 160 | ## Path Slider 161 | A View that turns any `Shape` into a slider. Its great for creating unique user experiences 162 | 163 | - **parameters**: 164 | - `value`: a `Binding` value which represents the percent fill of the slider between (0,1). 165 | - `shape`: The `Shape` to be used as the sliders track 166 | - `range`: `ClosedRange` The minimum and maximum numbers that `value` can be 167 | - `isDisabled`: `Bool` Whether or not the slider should be disabled 168 | 169 | ### Styling The Slider 170 | 171 | To create a custom style for the slider you need to create a `PSliderStyle` conforming struct. Conformance requires implementation of 2 methods 172 | 173 | 1. `makeThumb`: which creates the draggable portion of the slider 174 | 2. `makeTrack`: which creates the track which fills or empties as the thumb is dragging within it 175 | 176 | Both methods provide access to state values through the `PSliderConfiguration` struct 177 | ````Swift 178 | struct PSliderConfiguration { 179 | let isDisabled: Bool // whether or not the slider is disabled 180 | let isActive: Bool // whether or not the thumb is currently dragging 181 | let pctFill: Double // The percentage of the sliders track that is filled 182 | let value: Double // The current value of the slider 183 | let angle: Angle // Angle of the thumb 184 | let min: Double // The minimum value of the sliders range 185 | let max: Double // The maximum value of the sliders range 186 | } 187 | ```` 188 | To make this easier just copy and paste the following style based on the `DefaultPSliderStyle`. After creating your custom style 189 | apply it by calling the `pathSliderStyle` method on the `PSlider` or a view containing it. 190 | 191 | ````Swift 192 | struct <#My PSlider Style#>: PSliderStyle { 193 | func makeThumb(configuration: PSliderConfiguration) -> some View { 194 | Circle() 195 | .frame(width: 30, height:30) 196 | .foregroundColor(configuration.isActive ? Color.yellow : Color.white) 197 | } 198 | 199 | func makeTrack(configuration: PSliderConfiguration) -> some View { 200 | configuration.shape 201 | .stroke(Color.gray, lineWidth: 8) 202 | .overlay( 203 | configuration.shape 204 | .trim(from: 0, to: CGFloat(configuration.pctFill)) 205 | .stroke(Color.purple, lineWidth: 10)) 206 | } 207 | } 208 | ```` 209 | ## Overflow Slider 210 | 211 | A Slider which has a fixed frame but a movable track in the background. Used for values that have a discrete nature to them but would not necessarily fit on screen. 212 | Both the thumb and track can be dragged, if the track is dragged and thrown the velocity of the throw is added to the tracks velocity and it slows gradually to a stop. 213 | If the thumb is currently being dragged and reaches the minimum or maximum value of its bounds, velocity is added to the track in the opposite direction of the drag. 214 | 215 | - **parameters**: 216 | - `value`: `Binding` The value the slider should control 217 | - `range`: `ClosedRange` The minimum and maximum numbers that `value` can be 218 | - `isDisabled`: `Bool` Whether or not the slider should be disabled 219 | 220 | 221 | ## Styling The Slider 222 | 223 | To create a custom style for the slider you need to create a `OverflowSliderStyle` conforming struct. Conformance requires implementation of 2 methods 224 | 225 | 1. `makeThumb`: which creates the draggable portion of the slider 226 | 2. `makeTrack`: which creates the draggable background track 227 | 228 | Both methods provide access to the sliders current state thru the `OverflowSliderConfiguration` of the `OverflowSlider `to be styled. 229 | ````Swift 230 | struct OverflowSliderConfiguration { 231 | let isDisabled: Bool // Whether the control is disabled or not 232 | let thumbIsActive: Bool // Whether the thumb is currently dragging or not 233 | let thumbIsAtLimit: Bool // Whether the thumb has reached its min/max displacement 234 | let trackIsActive: Bool // Whether of not the track is dragging 235 | let trackIsAtLimit: Bool // Whether the track has reached its min/max position 236 | let value: Double // The current value of the slider 237 | let min: Double // The minimum value of the sliders range 238 | let max: Double // The maximum value of the sliders range 239 | let tickSpacing: Double // The spacing of the sliders tick marks 240 | } 241 | ```` 242 | To make this easier just copy and paste the following style based on the `DefaultOverflowSliderStyle`. After creating your custom style apply it by calling the `overflowSliderStyle` method on the `OverflowSlider` or a view containing it. 243 | ````Swift 244 | struct <#My OverflowSlider Style#>: OverflowSliderStyle { 245 | func makeThumb(configuration: OverflowSliderConfiguration) -> some View { 246 | RoundedRectangle(cornerRadius: 5) 247 | .fill(configuration.thumbIsActive ? Color.orange : Color.blue) 248 | .opacity(0.5) 249 | .frame(width: 20, height: 50) 250 | } 251 | func makeTrack(configuration: OverflowSliderConfiguration) -> some View { 252 | let totalLength = configuration.max-configuration.min 253 | let spacing = configuration.tickSpacing 254 | 255 | return TickMarks(spacing: CGFloat(spacing), ticks: Int(totalLength/Double(spacing))) 256 | .stroke(Color.gray) 257 | .frame(width: CGFloat(totalLength)) 258 | } 259 | } 260 | ```` 261 | ## Track Pad 262 | 263 | Essentially the 2D equaivalent of a normal `Slider`, This creates a draggable thumb and a rectangular area that the thumbs translation is restricted within 264 | 265 | - **parameters**: 266 | - `value`: A `CGPoint ` representing the two values being controlled by the trackpad in the x, and y directions 267 | - `rangeX`: A `ClosedRange` defining the minimum and maximum of the `value` parameters x component 268 | - `rangeY`: A `ClosedRange` defining the minimum and maximum of the `value` parameters y component 269 | - `isDisabled`: A `Bool` value describing whether the track pad responds to user input or not 270 | 271 | ### Styling 272 | To create a custom style for the `TrackPad` you need to create a `TrackPadStyle` conforming struct. Conformance requires implementation of 2 methods 273 | 274 | 1. `makeThumb`: which creates the draggable portion of the trackpad 275 | 2. `makeTrack`: which creates view containing the thumb 276 | 277 | Both methods provide access to state values of the track pad thru the `TrackPadConfiguration` struct 278 | ````Swift 279 | struct TrackPadConfiguration { 280 | let isDisabled: Bool // Whether or not the trackpad is disabled 281 | let isActive: Bool // whether or not the thumb is dragging 282 | let pctX: Double // (valueX-minX)/(maxX-minX) 283 | let pctY: Double // (valueY-minY)/(maxY-minY) 284 | let valueX: Double // The current value in the x direction 285 | let valueY: Double // The current value in the y direction 286 | let minX: Double // The minimum value from rangeX 287 | let maxX: Double // The maximum value from rangeX 288 | let minY: Double // The minimum value from rangeY 289 | let maxY: Double // The maximum value from rangeY 290 | } 291 | 292 | ```` 293 | 294 | To make this easier just copy and paste the following style based on the `DefaultTrackPadStyle`. After creating your custom style 295 | apply it by calling the `trackPadStyle` method on the `TrackPad` or a view containing it. 296 | 297 | ````Swift 298 | struct <#My TrackPad Style #>: TrackPadStyle { 299 | func makeThumb(configuration: TrackPadConfiguration) -> some View { 300 | Circle() 301 | .fill(configuration.isActive ? Color.yellow : Color.black) 302 | .frame(width: 40, height: 40) 303 | } 304 | 305 | func makeTrack(configuration: TrackPadConfiguration) -> some View { 306 | RoundedRectangle(cornerRadius: 5) 307 | .fill(Color.gray) 308 | .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.blue)) 309 | } 310 | } 311 | ```` 312 | 313 | ## Radial Track Pad 314 | 315 | A control that constrains the drag gesture of the thumb to be contained within the radius of the track. 316 | Similar to a joystick, with the difference being that the thumb stays fixed at the gestures end location when the drag is finished. 317 | - **parameters**: 318 | - `offset`: `Binding` The distance measured from the tracks center to the thumbs location 319 | - `angle`: `Binding` The angle of the line between the pads center and the thumbs location, measured from the vector pointing in the trailing direction 320 | - `isDisabled`: `Bool` value describing if the sliders state is disabled (default is `false`) 321 | 322 | **note**: There is no need to define the radius of the track because the `RadialPad` automatically adjusts to the geometry of its container. 323 | 324 | ### Styling 325 | 326 | To create a custom style for the `RadialPad` you need to create a `RadialPadStyle` conforming struct. 327 | Conformance requires implementation of 2 methods 328 | 329 | 1. `makeThumb`: which creates the draggable portion of the `RadialPad` 330 | 2. `makeTrack`: which creates the background that the thumb will be contained in. 331 | 332 | Both methods provide read access to the state values of the `RadialPad` thru the `RadialPadConfiguration` struct 333 | ````Swift 334 | struct RadialPadConfiguration { 335 | let isDisabled: Bool // whether or not the slider is current disables 336 | let isActive: Bool // whether or not the thumb is dragging or not 337 | let isAtLimit: Bool // Is true if the radial offset is equal to the pads radius 338 | let angle: Angle // The angle of the line between the pads center and the thumbs location, measured from the vector pointing in the trailing direction 339 | let radialOffset: Double // The Thumb's distance from the Track's center 340 | } 341 | ```` 342 | To make this easier just copy and paste the following style based on the `DefaultRadialPadStyle`. After creating your custom style 343 | apply it by calling the `radialPadStyle` method on the `RadialPad` or a view containing it. 344 | ````Swift 345 | struct <#My RadialPad Style#>: RadialPadStyle { 346 | func makeTrack(configuration: RadialPadConfiguration) -> some View { 347 | Circle() 348 | .fill(Color.gray.opacity(0.4)) 349 | } 350 | func makeThumb(configuration: RadialPadConfiguration) -> some View { 351 | Circle() 352 | .fill(Color.blue) 353 | .frame(width: 45, height: 45) 354 | } 355 | } 356 | ```` 357 | 358 | ## Joystick 359 | 360 | Joystick view used to control various activities such as moving a character on the screen. The View creates a Rectangular region to act as a hitbox for drag gestures. Once a drag is initiated the joystick appears on screen centered at the start location of the gesture. While dragging, the thumb of the joystick is limited be within the `radius` of the sticks background circle. 361 | 362 | - **parameters**: 363 | - `state`: `Binding` you provide a binding to a Joystate value which allows you to maintain access to all of the Joysticks state values 364 | - `radius`: `Double` The radius of the track 365 | - `canLock`: A boolean value describing whether the joystick has locking behavior (**default: true**) 366 | - `isDisabled`: `Bool` whether the joystick allows hit testing or not (**default: false**) 367 | 368 | 369 | ### Style 370 | 371 | The Joystick can be themed and styled by making a custom struct conforming to the `JoystickStyle` 372 | protocol. Conformance requires that you implement 4 methods 373 | 374 | 1. `makeHitBox` - Creates the rectangular region that responds the the users touch 375 | 2. `makeLockBox` - Creates a view such that if the drag gestures location is contained within the lockbox, the joystick goes into the locked state 376 | 3. `makeTrack` - Creates the circular track that contains the joystick thumb 377 | 4. `makeThumb` - Creates the part of the joystick the moves when dragging 378 | 379 | These 4 methods all provide access to the `JoystickConfiguration` . 380 | Make use of the various state values to customize the Joystick to your liking. 381 | ````Swift 382 | struct JoystickConfiguration { 383 | let isDisabled: Bool // whether or not the slider is current disables 384 | let isActive: Bool // True if the joystick thumb is dragging or if the joystick is locked 385 | let isAtLimit: Bool // whether the offset of the thumb reached the radius of the circle 386 | let isLocked: Bool // Whether the joystick is locked or not 387 | let angle: Angle // The angle of the line between the pads center and the thumbs location, measured from the vector pointing in the trailing direction 388 | let radialOffset: Double // The current displacement of the thumb from the track's center 389 | } 390 | ```` 391 | Once your custom style has been created, implement it by calling the `joystickStyle(_ :)` method on the `Joystick` or 392 | a view containing the `Joystick` to be styled. To make it easier try using the follow example based upon the `DefaultJoystickStyle` 393 | ````Swift 394 | struct <#My Joystick Style#>: JoystickStyle { 395 | func makeHitBox(configuration: JoystickConfiguration) -> some View { 396 | Rectangle() 397 | .fill(Color.white.opacity(0.05)) 398 | } 399 | func makeLockBox(configuration: JoystickConfiguration) -> some View { 400 | Circle() 401 | .fill(Color.black) 402 | .overlay(Circle().fill(Color.yellow).scaleEffect(0.7)) 403 | .frame(width: 25, height: 25) 404 | } 405 | func makeTrack(configuration: JoystickConfiguration) -> some View { 406 | Circle() 407 | .fill(Color.gray.opacity(0.4)) 408 | } 409 | func makeThumb(configuration: JoystickConfiguration) -> some View { 410 | Circle() 411 | .fill(Color.blue) 412 | .frame(width: 45, height: 45) 413 | } 414 | } 415 | ```` 416 | 417 | 418 | 419 | -------------------------------------------------------------------------------- /SlidersLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xmlThumb 429 | Dial 454 | Zero 479 | Semi 749 | Major 763 | View Port 777 | Thumb 791 | Track 805 | View Port 819 | Track 833 | Sliders 845 | -------------------------------------------------------------------------------- /Sources/Sliders/Joystick.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Joystick.swift 3 | // MyExamples 4 | // 5 | // Created by Kieran Brown on 3/27/20. 6 | // Copyright © 2020 BrownandSons. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | 12 | // MARK: - Style 13 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 14 | public struct JoystickConfiguration { 15 | /// whether or not the slider is current disables 16 | public let isDisabled: Bool 17 | /// True if the joystick thumb is dragging or if the joystick is locked 18 | public let isActive: Bool 19 | /// whether the offset of the thumb reached the radius of the circle 20 | public let isAtLimit: Bool 21 | /// Whether the joystick is locked or not 22 | public let isLocked: Bool 23 | /// The angle of the line between the pads center and the thumbs location, measured from the vector pointing in the trailing direction 24 | public let angle: Angle 25 | /// The current displacement of the thumb from the track's center 26 | public let radialOffset: Double 27 | 28 | public init(_ isDisabled: Bool, _ isActive: Bool , _ isAtLimit: Bool, _ isLocked: Bool, _ angle: Angle, _ radialOffset: Double) { 29 | self.isDisabled = isDisabled 30 | self.isActive = isActive 31 | self.isAtLimit = isAtLimit 32 | self.isLocked = isLocked 33 | self.angle = angle 34 | self.radialOffset = radialOffset 35 | } 36 | } 37 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 38 | public protocol JoystickStyle { 39 | associatedtype HitBox: View 40 | associatedtype LockBox: View 41 | associatedtype Track: View 42 | associatedtype Thumb: View 43 | 44 | func makeHitBox(configuration: JoystickConfiguration) -> Self.HitBox 45 | func makeLockBox(configuration: JoystickConfiguration) -> Self.LockBox 46 | func makeTrack(configuration: JoystickConfiguration) -> Self.Track 47 | func makeThumb(configuration: JoystickConfiguration) -> Self.Thumb 48 | } 49 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 50 | public extension JoystickStyle { 51 | 52 | func makeHitBoxTypeErased(configuration: JoystickConfiguration) -> AnyView { 53 | AnyView(self.makeHitBox(configuration: configuration)) 54 | } 55 | func makeLockBoxTypeErased(configuration: JoystickConfiguration) -> AnyView { 56 | AnyView(self.makeLockBox(configuration: configuration)) 57 | } 58 | func makeTrackTypeErased(configuration: JoystickConfiguration) -> AnyView { 59 | AnyView(self.makeTrack(configuration: configuration)) 60 | } 61 | func makeThumbTypeErased(configuration: JoystickConfiguration) -> AnyView { 62 | AnyView(self.makeThumb(configuration: configuration)) 63 | } 64 | } 65 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 66 | public struct AnyJoystickStyle: JoystickStyle { 67 | private let _makeHitBox: (JoystickConfiguration) -> AnyView 68 | public func makeHitBox(configuration: JoystickConfiguration) -> some View { 69 | return self._makeHitBox(configuration) 70 | } 71 | 72 | private let _makeLockBox: (JoystickConfiguration) -> AnyView 73 | public func makeLockBox(configuration: JoystickConfiguration) -> some View { 74 | return self._makeLockBox(configuration) 75 | } 76 | 77 | private let _makeTrack: (JoystickConfiguration) -> AnyView 78 | public func makeTrack(configuration: JoystickConfiguration) -> some View { 79 | return self._makeTrack(configuration) 80 | } 81 | 82 | private let _makeThumb: (JoystickConfiguration) -> AnyView 83 | public func makeThumb(configuration: JoystickConfiguration) -> some View { 84 | return self._makeThumb(configuration) 85 | } 86 | 87 | public init(_ style: ST) { 88 | self._makeHitBox = style.makeHitBoxTypeErased 89 | self._makeLockBox = style.makeLockBoxTypeErased 90 | self._makeTrack = style.makeTrackTypeErased 91 | self._makeThumb = style.makeThumbTypeErased 92 | 93 | } 94 | } 95 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 96 | public struct DefaultJoystickStyle: JoystickStyle { 97 | public init() { } 98 | 99 | public func makeHitBox(configuration: JoystickConfiguration) -> some View { 100 | Rectangle() 101 | .fill(Color.white.opacity(0.05)) 102 | } 103 | public func makeLockBox(configuration: JoystickConfiguration) -> some View { 104 | ZStack { 105 | Circle() 106 | .fill(Color.black) 107 | Circle() 108 | .fill(Color.yellow) 109 | .scaleEffect(0.7) 110 | }.frame(width: 25, height: 25) 111 | } 112 | public func makeTrack(configuration: JoystickConfiguration) -> some View { 113 | Circle() 114 | .fill(Color.gray.opacity(0.4)) 115 | } 116 | public func makeThumb(configuration: JoystickConfiguration) -> some View { 117 | Circle() 118 | .fill(Color.blue) 119 | .frame(width: 45, height: 45) 120 | 121 | } 122 | } 123 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 124 | public struct JoystickStyleKey: EnvironmentKey { 125 | public static let defaultValue: AnyJoystickStyle = AnyJoystickStyle(DefaultJoystickStyle()) 126 | } 127 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 128 | extension EnvironmentValues { 129 | public var joystickStyle: AnyJoystickStyle { 130 | get { 131 | return self[JoystickStyleKey.self] 132 | } 133 | set { 134 | self[JoystickStyleKey.self] = newValue 135 | } 136 | } 137 | } 138 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 139 | extension View { 140 | public func joystickStyle(_ style: S) -> some View where S: JoystickStyle { 141 | self.environment(\.joystickStyle, AnyJoystickStyle(style)) 142 | } 143 | } 144 | 145 | 146 | // MARK: - State 147 | /// An Enumeration used to represent the state of a `Joystick` 148 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 149 | public enum JoyState { 150 | case inactive 151 | case locked 152 | case dragging(time: Date, translation: CGSize, startLocation: CGPoint, velocity: CGSize, acceleration: CGSize) 153 | 154 | public var isLocked: Bool { 155 | switch self { 156 | case .locked: return true 157 | default: return false 158 | } 159 | } 160 | public var isDragging: Bool { 161 | switch self { 162 | case .dragging(_, _, _, _, _): return true 163 | default: return false 164 | } 165 | } 166 | public var isActive: Bool { 167 | switch self { 168 | case .inactive: return false 169 | default: return true 170 | } 171 | } 172 | public var time: Date? { 173 | switch self { 174 | case .dragging( let time, _ , _, _, _): 175 | return time 176 | default: return nil 177 | } 178 | } 179 | public var translation: CGSize { 180 | switch self { 181 | case .dragging(_, let translation , _, _, _): 182 | return translation 183 | default: return .zero 184 | } 185 | } 186 | public var startLocation: CGPoint? { 187 | switch self { 188 | case .dragging(_, _ , let start, _, _): 189 | return start 190 | default: return nil 191 | } 192 | } 193 | public var velocity: CGSize { 194 | switch self { 195 | case .dragging(_ , _ , _, let velocity, _): 196 | return velocity 197 | default: return .zero 198 | } 199 | } 200 | public var acceleration: CGSize { 201 | switch self { 202 | case .dragging(_ , _ , _, _, let acceleration): 203 | return acceleration 204 | default: return .zero 205 | } 206 | } 207 | public var angle: Angle { 208 | switch self { 209 | case .dragging(_, let trans , _, _, _): 210 | return Angle(radians: atan2(Double(trans.height), Double(trans.width)) ) 211 | default: return .zero 212 | } 213 | } 214 | public var radialOffset: Double { 215 | switch self { 216 | case .dragging(_, let trans , _, _, _): 217 | return sqrt(trans.magnitudeSquared) 218 | default: return 0 219 | } 220 | } 221 | 222 | } 223 | 224 | // MARK: - Joystick 225 | 226 | /// # Joystick 227 | /// 228 | /// Joystick view used to control various activities such as moving a character on the screen. 229 | /// The View creates a Rectangular region to act as a hitbox for drag gestures. Once a drag 230 | /// is initiated the joystick appears on screen centered at the start location of the gesture. While 231 | /// dragging, the thumb of the joystick is limited be within the `radius` of the sticks background circle. 232 | /// 233 | /// - parameters: 234 | /// - state: `Binding` you provide a binding to a Joystate value which allows you to maintain access to all of the Joysticks state values 235 | /// - radius: `Double` The radius of the track 236 | /// - canLock: A boolean value describing whether the joystick has locking behavior (**default: true**) 237 | /// - isDisabled: `Bool` whether the joystick allows hit testing or not (**default: false**) 238 | /// 239 | /// 240 | /// ## Style 241 | /// 242 | /// The Joystick can be themed and styled by making a custom struct conforming to the `JoystickStyle` 243 | /// protocol. Conformance requires that you implement 4 methods 244 | /// 1. `makeHitBox` - Creates the rectangular region that responds the the users touch 245 | /// 2. `makeLockBox` - Creates a view such that if the drag gestures location is contained within the lockbox, the joystick goes into the locked state 246 | /// 3. `makeTrack` - Creates the circular track that contains the joystick thumb 247 | /// 4 `makeThumb` - Creates the part of the joystick the moves when dragging 248 | /// 249 | /// These 3 methods all provide access to the `JoystickConfiguration` . 250 | /// Make use of the various state values to customize the Joystick to your liking. 251 | /// 252 | /// struct JoystickConfiguration { 253 | /// let isDisabled: Bool // whether or not the slider is current disables 254 | /// let isActive: Bool // True if the joystick thumb is dragging or if the joystick is locked 255 | /// let isAtLimit: Bool // whether the offset of the thumb reached the radius of the circle 256 | /// let isLocked: Bool // Whether the joystick is locked or not 257 | /// let angle: Angle // The angle of the line between the pads center and the thumbs location, measured from the vector pointing in the trailing direction 258 | /// let radialOffset: Double // The current displacement of the thumb from the track's center 259 | /// } 260 | /// 261 | /// Once your custom style has been created, implement it by calling the `joystickStyle(_ :)` method on the `Joystick` or 262 | /// a view containing the `Joystick` to be styled. To make it easier try using the follow example based upon the `DefaultJoystickStyle` 263 | /// 264 | /// struct <#My Joystick Style#>: JoystickStyle { 265 | /// func makeHitBox(configuration: JoystickConfiguration) -> some View { 266 | /// Rectangle() 267 | /// .fill(Color.white.opacity(0.05)) 268 | /// } 269 | /// func makeLockBox(configuration: JoystickConfiguration) -> some View { 270 | /// Circle() 271 | /// .fill(Color.black) 272 | /// .overlay(Circle().fill(Color.yellow).scaleEffect(0.7)) 273 | /// .frame(width: 25, height: 25) 274 | /// } 275 | /// func makeTrack(configuration: JoystickConfiguration) -> some View { 276 | /// Circle() 277 | /// .fill(Color.gray.opacity(0.4)) 278 | /// } 279 | /// func makeThumb(configuration: JoystickConfiguration) -> some View { 280 | /// Circle() 281 | /// .fill(Color.blue) 282 | /// .frame(width: 45, height: 45) 283 | /// 284 | /// } 285 | /// } 286 | /// 287 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 288 | public struct Joystick: View { 289 | typealias Key = JoyStickKey 290 | struct JoyStickKey: PreferenceKey { 291 | static var defaultValue: [Int:Anchor] { [:] } 292 | static func reduce(value: inout [Int:Anchor], nextValue: () -> [Int:Anchor]) { 293 | value.merge(nextValue(), uniquingKeysWith: {$1}) 294 | } 295 | } 296 | @Environment(\.joystickStyle) private var style: AnyJoystickStyle 297 | let radius: Double 298 | let canLock: Bool 299 | let isDisabled: Bool 300 | @Binding public var state: JoyState 301 | 302 | public init(state: Binding, radius: Double, canLock: Bool, isDisabled: Bool) { 303 | self._state = state 304 | self.radius = radius 305 | self.canLock = canLock 306 | self.isDisabled = isDisabled 307 | } 308 | public init(state: Binding, radius: Double) { 309 | self._state = state 310 | self.radius = radius 311 | self.canLock = true 312 | self.isDisabled = false 313 | } 314 | public init(state: Binding, radius: Double, isDisabled: Bool) { 315 | self._state = state 316 | self.radius = radius 317 | self.canLock = true 318 | self.isDisabled = isDisabled 319 | } 320 | 321 | @State private var lastPlacement: CGPoint = .zero 322 | @State private var isInsideLockBox = false 323 | private var config: JoystickConfiguration { 324 | .init(isDisabled, 325 | state.isActive, 326 | state.radialOffset == radius, 327 | state.isLocked, 328 | state.angle, 329 | state.radialOffset) 330 | } 331 | // MARK: Calculations 332 | private func calculateVelocity(translation: CGSize, time: Date) -> CGSize { 333 | guard let last = state.time else {return .zero} 334 | let dx = translation.width-state.translation.width 335 | let dy = translation.height-state.translation.height 336 | let dt = CGFloat(1/last.timeIntervalSince(time)) 337 | return CGSize(width: dx*dt, height: dy*dt) 338 | } 339 | private func calculateAcceleration(velocity: CGSize, time: Date) -> CGSize { 340 | guard let last = state.time else {return .zero} 341 | let dx = velocity.width-state.velocity.width 342 | let dy = velocity.height-state.velocity.height 343 | let dt = CGFloat(1/last.timeIntervalSince(time)) 344 | return CGSize(width: dx*dt, height: dy*dt) 345 | } 346 | private func limitTranslation(_ translation: CGSize) -> CGSize { 347 | if translation.magnitudeSquared < radius*radius {return translation} 348 | let magnitude = sqrt(translation.magnitudeSquared) 349 | let w = Double(translation.width)*radius/magnitude 350 | let h = Double(translation.height)*radius/magnitude 351 | return CGSize(width: w, height: h) 352 | } 353 | 354 | // MARK: Haptics 355 | private func impactOccured() { 356 | #if os(macOS) 357 | #else 358 | let generator = UIImpactFeedbackGenerator(style: .medium) 359 | generator.impactOccurred() 360 | #endif 361 | } 362 | private func locked() { 363 | #if os(macOS) 364 | #else 365 | let generator = UINotificationFeedbackGenerator() 366 | generator.notificationOccurred(.success) 367 | #endif 368 | } 369 | private func enteredLockBoxHandler(_ isInside: Bool) { 370 | if self.canLock { 371 | if isInside { 372 | if !self.isInsideLockBox { 373 | self.impactOccured() 374 | } 375 | self.isInsideLockBox = true 376 | } else { 377 | self.isInsideLockBox = false 378 | } 379 | } 380 | } 381 | private func lock(_ shouldLock: Bool) { 382 | if canLock { 383 | if shouldLock { 384 | state = .locked 385 | locked() 386 | } else { 387 | state = .inactive 388 | } 389 | } else { 390 | self.state = .inactive 391 | } 392 | } 393 | 394 | // MARK: Views 395 | private var draggingViews: some View { 396 | Group { 397 | style.makeLockBox(configuration: config) 398 | .position(state.startLocation!) 399 | .offset(x: 0, y: -CGFloat(radius+50)) 400 | .transition(AnyTransition.opacity.animation(.easeIn)) 401 | .opacity(canLock ? 1 : 0) 402 | 403 | 404 | style.makeThumb(configuration: config) 405 | .offset(x: state.translation.width, y: state.translation.height) 406 | .background(style.makeTrack(configuration: config) 407 | .frame(width: CGFloat(2*radius), height: CGFloat(2*radius))) 408 | .position(state.startLocation!) 409 | .transition(AnyTransition.opacity.animation(.easeIn)) 410 | } 411 | } 412 | private var lockedViews: some View { 413 | Group { 414 | style.makeLockBox(configuration: config) 415 | .position(lastPlacement) 416 | .offset(x: 0, y: -CGFloat(radius+50)) 417 | 418 | style.makeThumb(configuration: config) 419 | .offset(x: state.translation.width, y: state.translation.height) 420 | .background(style.makeTrack(configuration: config) 421 | .frame(width: CGFloat(2*radius), height: CGFloat(2*radius))) 422 | .position(lastPlacement) 423 | } 424 | } 425 | private var joystick: some View { 426 | Group { 427 | if state.isDragging { 428 | self.draggingViews 429 | } else if state.isLocked { 430 | self.lockedViews 431 | } 432 | } 433 | } 434 | 435 | public var body: some View { 436 | ZStack { 437 | style.makeLockBox(configuration: config) 438 | .anchorPreference(key: Key.self, value: .bounds, transform: { [1: $0]}) 439 | .opacity(0) 440 | .position(state.startLocation ?? .zero) 441 | .offset(x: 0, y: -CGFloat(radius+50)) 442 | style.makeHitBox(configuration: config) 443 | .opacity(0) 444 | .allowsHitTesting(!self.isDisabled) 445 | }.overlayPreferenceValue(Key.self) { (bounds: [Int: Anchor]) in 446 | GeometryReader { proxy in 447 | ZStack(alignment: .center) { 448 | self.style.makeHitBox(configuration: self.config) 449 | .gesture(DragGesture() 450 | .onChanged({ (value) in 451 | self.lastPlacement = .zero 452 | let translation = self.limitTranslation(value.translation) 453 | let velocity = self.calculateVelocity(translation: translation, time: value.time) 454 | self.state = .dragging(time: value.time, 455 | translation: translation, 456 | startLocation: value.startLocation, 457 | velocity: velocity, 458 | acceleration: self.calculateAcceleration(velocity: velocity, time: value.time)) 459 | guard let bound = bounds[1] else { return } 460 | self.enteredLockBoxHandler(proxy[bound].contains(value.location)) 461 | }) 462 | .onEnded({ (value) in 463 | self.lastPlacement = value.startLocation 464 | guard let bound = bounds[1] else { return } 465 | self.lock(proxy[bound].contains(value.location)) 466 | })) 467 | self.joystick.allowsHitTesting(false) 468 | }.frame(width: proxy.size.width, height: proxy.size.height) 469 | } 470 | } 471 | } 472 | } 473 | 474 | 475 | 476 | -------------------------------------------------------------------------------- /Sources/Sliders/LSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LSlider.swift 3 | // MyExamples 4 | // 5 | // Created by Kieran Brown on 4/6/20. 6 | // Copyright © 2020 BrownandSons. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Shapes 11 | import CGExtender 12 | 13 | 14 | 15 | // MARK: - LSlider Configuration 16 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 17 | public struct LSliderConfiguration { 18 | /// whether or not the slider is current disables 19 | public let isDisabled: Bool 20 | /// whether or not the thumb is dragging or not 21 | public let isActive: Bool 22 | /// The percentage of the sliders track that is filled 23 | public let pctFill: Double 24 | /// The current value of the slider 25 | public let value: Double 26 | /// Angle of the slider 27 | public let angle: Angle 28 | /// The minimum value of the sliders range 29 | public let min: Double 30 | /// The maximum value of the sliders range 31 | public let max: Double 32 | } 33 | // MARK: - LSlider Style 34 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 35 | public protocol LSliderStyle { 36 | associatedtype Thumb: View 37 | associatedtype Track: View 38 | 39 | func makeThumb(configuration: LSliderConfiguration) -> Self.Thumb 40 | func makeTrack(configuration: LSliderConfiguration) -> Self.Track 41 | } 42 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 43 | public extension LSliderStyle { 44 | func makeThumbTypeErased(configuration: LSliderConfiguration) -> AnyView { 45 | AnyView(self.makeThumb(configuration: configuration)) 46 | } 47 | func makeTrackTypeErased(configuration: LSliderConfiguration) -> AnyView { 48 | AnyView(self.makeTrack(configuration: configuration)) 49 | } 50 | } 51 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 52 | public struct AnyLSliderStyle: LSliderStyle { 53 | private let _makeThumb: (LSliderConfiguration) -> AnyView 54 | public func makeThumb(configuration: LSliderConfiguration) -> some View { 55 | self._makeThumb(configuration) 56 | } 57 | private let _makeTrack: (LSliderConfiguration) -> AnyView 58 | public func makeTrack(configuration: LSliderConfiguration) -> some View { 59 | self._makeTrack(configuration) 60 | } 61 | 62 | public init(_ style: S) { 63 | self._makeThumb = style.makeThumbTypeErased 64 | self._makeTrack = style.makeTrackTypeErased 65 | } 66 | } 67 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 68 | public struct LSliderStyleKey: EnvironmentKey { 69 | public static let defaultValue: AnyLSliderStyle = AnyLSliderStyle(DefaultLSliderStyle()) 70 | } 71 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 72 | extension EnvironmentValues { 73 | public var linearSliderStyle: AnyLSliderStyle { 74 | get { 75 | return self[LSliderStyleKey.self] 76 | } 77 | set { 78 | self[LSliderStyleKey] = newValue 79 | } 80 | } 81 | } 82 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 83 | extension View { 84 | public func linearSliderStyle(_ style: S) -> some View where S: LSliderStyle { 85 | self.environment(\.linearSliderStyle, AnyLSliderStyle(style)) 86 | } 87 | } 88 | // MARK: - Default LSlider Style 89 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 90 | public struct DefaultLSliderStyle: LSliderStyle { 91 | public init() { 92 | 93 | } 94 | public func makeThumb(configuration: LSliderConfiguration) -> some View { 95 | Circle() 96 | .fill(configuration.isActive ? Color.yellow : Color.white) 97 | .frame(width: 40, height: 40) 98 | } 99 | 100 | public func makeTrack(configuration: LSliderConfiguration) -> some View { 101 | let style: StrokeStyle = .init(lineWidth: 40, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0) 102 | return AdaptiveLine(angle: configuration.angle) 103 | .stroke(Color.gray, style: style) 104 | .overlay(AdaptiveLine(angle: configuration.angle).trim(from: 0, to: CGFloat(configuration.pctFill)).stroke(Color.blue, style: style)) 105 | } 106 | } 107 | 108 | 109 | 110 | // MARK: - LSlider 111 | /// # Spatially Adaptive Linear Slider 112 | /// 113 | /// While at on the surface this slider my seem like a copy of the already availlable `Slider`. I implore you to try and make a neat layout with a `Slider` in the vertical position. 114 | /// After trying everything with SwiftUI's built in slider I realized making layouts with it just was not going to work. So I created the `LSlider`. It works just like a normal `Slider` except 115 | /// You can provide a value for the angle parameter which rotates the slider and adaptively fits it to its containing view. Also Its fully customizable with cascading styles thanks to Environment variables. 116 | /// 117 | /// - parameters: 118 | /// - value: `Binding` The value the slider should control 119 | /// - range: `ClosedRange` The minimum and maximum numbers that `value` can be 120 | /// - angle: `Angle` The angle you would like the slider to be at 121 | /// - isDisabled: `Bool` Whether or not the slider should be disabled 122 | /// 123 | /// ## Styling The Slider 124 | /// 125 | /// To create a custom style for the slider you need to create a `LSliderStyle` conforming struct. Conformnance requires implementation of 2 methods 126 | /// 1. `makeThumb`: which creates the draggable portion of the slider 127 | /// 2. `makeTrack`: which creates the track which fills or emptys as the thumb is dragging within it 128 | /// 129 | /// Both methods provide access to the sliders current state thru the `LSliderConfiguration` of the `LSlider `to be styled 130 | /// 131 | ///``` 132 | /// struct LSliderConfiguration { 133 | /// let isDisabled: Bool // whether or not the slider is current disables 134 | /// let isActive: Bool // whether or not the thumb is dragging or not 135 | /// let pctFill: Double // The percentage of the sliders track that is filled 136 | /// let value: Double // The current value of the slider 137 | /// let angle: Angle // The angle of the slider 138 | /// let min: Double // The minimum value of the sliders range 139 | /// let max: Double // The maximum value of the sliders range 140 | /// } 141 | /// ``` 142 | /// 143 | /// To make this easier just copy and paste the following style based on the `DefaultLSliderStyle`. After creating your custom style 144 | /// apply it by calling the `linearSliderStyle` method on the `LSlider` or a view containing it. 145 | /// 146 | /// ``` 147 | /// struct <#My Slider Style#>: LSliderStyle { 148 | /// func makeThumb(configuration: LSliderConfiguration) -> some View { 149 | /// Circle() 150 | /// .fill(configuration.isActive ? Color.yellow : Color.white) 151 | /// .frame(width: 40, height: 40) 152 | /// } 153 | /// func makeTrack(configuration: LSliderConfiguration) -> some View { 154 | /// let style: StrokeStyle = .init(lineWidth: 10, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0) 155 | /// return AdaptiveLine(angle: configuration.angle) 156 | /// .stroke(Color.gray, style: style) 157 | /// .overlay(AdaptiveLine(angle: configuration.angle).trim(from: 0, to: CGFloat(configuration.pctFill)).stroke(Color.blue, style: style)) 158 | /// } 159 | /// } 160 | /// 161 | /// ``` 162 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 163 | public struct LSlider: View { 164 | // MARK: State and Setup 165 | @Environment(\.linearSliderStyle) private var style: AnyLSliderStyle 166 | @State private var isActive: Bool = false 167 | @State private var atLimit: Bool = false 168 | private let space: String = "Slider" 169 | // MARK: Input 170 | @Binding public var value: Double 171 | public var range: ClosedRange = 0...1 172 | public var angle: Angle = .zero 173 | public var isDisabled: Bool = false 174 | 175 | public init(_ value: Binding, range: ClosedRange, angle: Angle, isDisabled: Bool = false) { 176 | self._value = value 177 | self.range = range 178 | self.angle = angle 179 | self.isDisabled = isDisabled 180 | } 181 | 182 | public init(_ value: Binding, range: ClosedRange, isDisabled: Bool = false) { 183 | self._value = value 184 | self.range = range 185 | self.isDisabled = isDisabled 186 | } 187 | 188 | public init(_ value: Binding, angle: Angle, isDisabled: Bool = false) { 189 | self._value = value 190 | self.angle = angle 191 | self.isDisabled = isDisabled 192 | } 193 | 194 | public init(_ value: Binding) { 195 | self._value = value 196 | } 197 | 198 | // MARK: Calculations 199 | // uses an arbitrarily large number to gesture a line segment that is guarenteed to intersect with the 200 | // bounding box, then finds those points of intersection to be used as the start and end points of the slider 201 | private func calculateEndPoints(_ proxy: GeometryProxy) -> (start: CGPoint, end: CGPoint) { 202 | let w = proxy.size.width 203 | let h = proxy.size.height 204 | let big: CGFloat = 50000000 205 | 206 | let x1 = w/2 + big*CGFloat(cos(self.angle.radians)) 207 | let y1 = h/2 + big*CGFloat(sin(self.angle.radians)) 208 | let x2 = w/2 - big*CGFloat(cos(self.angle.radians)) 209 | let y2 = h/2 - big*CGFloat(sin(self.angle.radians)) 210 | let points = lineRectIntersection(x1, y1, x2, y2, 0, 0, w, h) 211 | if points.count < 2 { 212 | return (.zero, .zero) 213 | } 214 | 215 | return (points[0], points[1]) 216 | } 217 | private func thumbOffset(_ proxy: GeometryProxy) -> CGSize { 218 | let ends = self.calculateEndPoints(proxy) 219 | let value = (self.value-range.lowerBound)/(range.upperBound - range.lowerBound) 220 | let x = (1-value)*Double(ends.start.x) + value*Double(ends.end.x) - Double(proxy.size.width/2) 221 | let y = (1-value)*Double(ends.start.y) + value*Double(ends.end.y) - Double(proxy.size.height/2) 222 | return CGSize(width: x, height: y) 223 | } 224 | 225 | private var configuration: LSliderConfiguration { 226 | .init(isDisabled: isDisabled, 227 | isActive: isActive, 228 | pctFill: (value-range.lowerBound)/(range.upperBound-range.lowerBound), 229 | value: value, 230 | angle: angle, 231 | min: range.lowerBound, 232 | max: range.upperBound) 233 | } 234 | 235 | // MARK: Haptics 236 | private func impactOccured() { 237 | #if os(macOS) 238 | #else 239 | let generator = UIImpactFeedbackGenerator(style: .medium) 240 | generator.impactOccurred() 241 | #endif 242 | } 243 | 244 | private func impactHandler(_ parameterAtLimit: Bool) { 245 | if parameterAtLimit { 246 | if !atLimit { 247 | impactOccured() 248 | } 249 | atLimit = true 250 | } else { 251 | atLimit = false 252 | } 253 | } 254 | 255 | // MARK: - Gesture 256 | private func makeGesture(_ proxy: GeometryProxy) -> some Gesture { 257 | DragGesture(minimumDistance: 10, coordinateSpace: .named(self.space)) 258 | .onChanged({ drag in 259 | let ends = self.calculateEndPoints(proxy) 260 | let parameter = Double(calculateParameter(ends.start, ends.end, drag.location)) 261 | self.impactHandler(parameter == 1 || parameter == 0) 262 | self.value = (self.range.upperBound-self.range.lowerBound)*parameter + self.range.lowerBound 263 | self.isActive = true 264 | }) 265 | .onEnded({ (drag) in 266 | let ends = self.calculateEndPoints(proxy) 267 | let parameter = Double(calculateParameter(ends.start, ends.end, drag.location)) 268 | self.impactHandler(parameter == 1 || parameter == 0) 269 | self.value = (self.range.upperBound-self.range.lowerBound)*parameter + self.range.lowerBound 270 | self.isActive = false 271 | }) 272 | } 273 | 274 | // MARK: View 275 | public var body: some View { 276 | ZStack { 277 | self.style.makeTrack(configuration: self.configuration) 278 | .overlay(GeometryReader { proxy in 279 | ZStack(alignment: .center) { 280 | self.style.makeThumb(configuration: self.configuration) 281 | .offset(self.thumbOffset(proxy)) 282 | .gesture(self.makeGesture(proxy)) 283 | .allowsHitTesting(!self.isDisabled) 284 | }.frame(width: proxy.size.width, height: proxy.size.height) 285 | }) 286 | } 287 | .coordinateSpace(name: space) 288 | } 289 | } 290 | 291 | 292 | -------------------------------------------------------------------------------- /Sources/Sliders/OverFlowSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OverflowSlider.swift 3 | // MyExamples 4 | // 5 | // Created by Kieran Brown on 3/25/20. 6 | // Copyright © 2020 BrownandSons. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Shapes 11 | 12 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 13 | public struct OverflowSliderConfiguration { 14 | /// Whether the control is disabled or not 15 | public let isDisabled: Bool 16 | /// Whether the thumb is currently dragging or not 17 | public let thumbIsActive: Bool 18 | /// Whether the thumb has reached its min/max displacement 19 | public let thumbIsAtLimit: Bool 20 | /// Whether of not the track is dragging 21 | public let trackIsActive: Bool 22 | /// Whether the track has reached its min/max position 23 | public let trackIsAtLimit: Bool 24 | /// The current value of the slider 25 | public let value: Double 26 | /// The minimum value of the sliders range 27 | public let min: Double 28 | /// The maximum value of the sliders range 29 | public let max: Double 30 | /// The spacing of the sliders tick marks 31 | public let tickSpacing: Double 32 | } 33 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 34 | public protocol OverflowSliderStyle { 35 | associatedtype Track: View 36 | associatedtype Thumb: View 37 | 38 | func makeTrack(configuration: OverflowSliderConfiguration) -> Self.Track 39 | func makeThumb(configuration: OverflowSliderConfiguration) -> Self.Thumb 40 | } 41 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 42 | extension OverflowSliderStyle { 43 | func makeTrackTypeErased(configuration: OverflowSliderConfiguration) -> AnyView { 44 | AnyView(self.makeTrack(configuration: configuration)) 45 | } 46 | func makeThumbTypeErased(configuration: OverflowSliderConfiguration) -> AnyView { 47 | AnyView(self.makeThumb(configuration: configuration)) 48 | } 49 | } 50 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 51 | public struct AnyOverflowSliderStyle: OverflowSliderStyle { 52 | private let _makeTrack: (OverflowSliderConfiguration) -> AnyView 53 | public func makeTrack(configuration: OverflowSliderConfiguration) -> some View { 54 | return self._makeTrack(configuration) 55 | } 56 | private let _makeThumb: (OverflowSliderConfiguration) -> AnyView 57 | public func makeThumb(configuration: OverflowSliderConfiguration) -> some View { 58 | return self._makeThumb(configuration) 59 | } 60 | 61 | init(_ style: S) { 62 | self._makeTrack = style.makeTrackTypeErased 63 | self._makeThumb = style.makeThumbTypeErased 64 | } 65 | } 66 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 67 | public struct DefaultOverflowSliderStyle: OverflowSliderStyle { 68 | public init() { } 69 | public func makeTrack(configuration: OverflowSliderConfiguration) -> some View { 70 | let totalLength = configuration.max-configuration.min 71 | let spacing = configuration.tickSpacing 72 | 73 | return TickMarks(spacing: CGFloat(spacing), ticks: Int(totalLength/Double(spacing))) 74 | .stroke(Color.gray) 75 | .frame(width: CGFloat(totalLength)) 76 | } 77 | public func makeThumb(configuration: OverflowSliderConfiguration) -> some View { 78 | RoundedRectangle(cornerRadius: 5) 79 | .fill(configuration.thumbIsActive ? Color.orange : Color.blue) 80 | .opacity(0.5) 81 | .frame(width: 20, height: 50) 82 | } 83 | } 84 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 85 | public struct OverflowSliderStyleKey: EnvironmentKey { 86 | public static let defaultValue: AnyOverflowSliderStyle = AnyOverflowSliderStyle(DefaultOverflowSliderStyle()) 87 | } 88 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 89 | extension EnvironmentValues { 90 | public var overflowSliderStyle: AnyOverflowSliderStyle { 91 | get { 92 | return self[OverflowSliderStyleKey.self] 93 | } 94 | set { 95 | self[OverflowSliderStyleKey.self] = newValue 96 | } 97 | } 98 | } 99 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 100 | extension View { 101 | public func overflowSliderStyle(_ style: S) -> some View where S: OverflowSliderStyle { 102 | self.environment(\.overflowSliderStyle, AnyOverflowSliderStyle(style)) 103 | } 104 | } 105 | 106 | 107 | /// # Overflow Slider 108 | /// 109 | /// A Slider which has a fixed frame but a movable track in the background. Used for values that have a discrete nature to them but would not necessarily fit on screen. 110 | /// Both the thumb and track can be dragged, if the track is dragged and thrown the velocity of the throw is added to the tracks velocity and it slows gradually to a stop. 111 | /// If the thumb is currently being dragged and reachs the minimum or maximum value of its bounds, velocity is added to the track in the opposite direction of the drag. 112 | /// 113 | /// - parameters: 114 | /// - value: `Binding` The value the slider should control 115 | /// - range: `ClosedRange` The minimum and maximum numbers that `value` can be 116 | /// - isDisabled: `Bool` Whether or not the slider should be disabled 117 | /// 118 | /// 119 | /// ## Styling The Slider 120 | /// 121 | /// To create a custom style for the slider you need to create a `OverflowSliderStyle` conforming struct. Conformnance requires implementation of 2 methods 122 | /// 1. `makeThumb`: which creates the draggable portion of the slider 123 | /// 2. `makeTrack`: which creates the draggable background track 124 | /// 125 | /// Both methods provide access to the sliders current state thru the `OverflowSliderConfiguration` of the `OverflowSlider `to be styled. 126 | /// 127 | /// struct OverflowSliderConfiguration { 128 | /// let isDisabled: Bool // Whether the control is disabled or not 129 | /// let thumbIsActive: Bool // Whether the thumb is currently dragging or not 130 | /// let thumbIsAtLimit: Bool // Whether the thumb has reached its min/max displacement 131 | /// let trackIsActive: Bool // Whether of not the track is dragging 132 | /// let trackIsAtLimit: Bool // Whether the track has reached its min/max position 133 | /// let value: Double // The current value of the slider 134 | /// let min: Double // The minimum value of the sliders range 135 | /// let max: Double // The maximum value of the sliders range 136 | /// let tickSpacing: Double // The spacing of the sliders tick marks 137 | /// } 138 | /// 139 | /// To make this easier just copy and paste the following style based on the `DefaultOverflowSliderStyle`. After creating your custom style 140 | /// apply it by calling the `overflowSliderStyle` method on the `OverflowSlider` or a view containing it. 141 | /// 142 | /// struct <#My OverflowSlider Style#>: OverflowSliderStyle { 143 | /// func makeThumb(configuration: OverflowSliderConfiguration) -> some View { 144 | /// RoundedRectangle(cornerRadius: 5) 145 | /// .fill(configuration.thumbIsActive ? Color.orange : Color.blue) 146 | /// .opacity(0.5) 147 | /// .frame(width: 20, height: 50) 148 | /// } 149 | /// func makeTrack(configuration: OverflowSliderConfiguration) -> some View { 150 | /// let totalLength = configuration.max-configuration.min 151 | /// let spacing = configuration.tickSpacing 152 | /// 153 | /// return TickMarks(spacing: CGFloat(spacing), ticks: Int(totalLength/Double(spacing))) 154 | /// .stroke(Color.gray) 155 | /// .frame(width: CGFloat(totalLength)) 156 | /// } 157 | /// } 158 | /// 159 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 160 | public struct OverflowSlider: View { 161 | private struct ThumbKey: PreferenceKey { 162 | static var defaultValue: CGRect { .zero } 163 | static func reduce(value: inout CGRect, nextValue: () -> CGRect) { 164 | value = nextValue() 165 | } 166 | } 167 | public enum SliderState { 168 | case inactive 169 | case dragging(time: Date, translation: CGFloat, startLocation: CGFloat, velocity: CGFloat) 170 | 171 | var isDragging: Bool { 172 | switch self { 173 | case .dragging(_, _, _, _): return true 174 | default: return false 175 | } 176 | } 177 | 178 | var isActive: Bool { 179 | switch self { 180 | case .inactive: return false 181 | default: return true 182 | } 183 | } 184 | 185 | var time: Date? { 186 | switch self { 187 | case .dragging( let time, _ , _, _): 188 | return time 189 | default: return nil 190 | } 191 | } 192 | 193 | var translation: CGFloat { 194 | switch self { 195 | case .dragging(_, let translation , _, _): 196 | return translation 197 | default: return .zero 198 | } 199 | } 200 | 201 | var startLocation: CGFloat? { 202 | switch self { 203 | case .dragging(_, _ , let start, _): 204 | return start 205 | default: return nil 206 | } 207 | } 208 | 209 | 210 | var velocity: CGFloat { 211 | switch self { 212 | case .dragging(_ , _ , _, let velocity): 213 | return velocity 214 | default: return .zero 215 | } 216 | } 217 | } 218 | @Environment(\.overflowSliderStyle) private var style: AnyOverflowSliderStyle 219 | @State private var currentVelocity: CGFloat = 0 220 | @State private var thumbOffset: CGFloat = 0.5 221 | @State private var thumbState: CGFloat = 0 222 | @State private var trackState: SliderState = .inactive 223 | @State private var trackOffset: CGFloat = 0 224 | private let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() 225 | 226 | @Binding public var value: Double 227 | public let range: ClosedRange 228 | public let spacing: Double 229 | public var isDisabled: Bool = false 230 | 231 | public init(value: Binding, range: ClosedRange, spacing: Double, isDisabled: Bool) { 232 | self._value = value 233 | self.range = range 234 | self.spacing = spacing 235 | self.isDisabled = isDisabled 236 | } 237 | 238 | private var configuration: OverflowSliderConfiguration { 239 | .init(isDisabled: isDisabled, 240 | thumbIsActive: thumbState != 0, 241 | thumbIsAtLimit: thumbOffset + thumbState <= 0 || thumbOffset + thumbState >= 1, 242 | trackIsActive: trackState.isActive, 243 | trackIsAtLimit: trackState.translation + trackOffset <= 0 || trackState.translation + trackOffset >= 1, 244 | value: value, 245 | min: range.lowerBound, 246 | max: range.upperBound, 247 | tickSpacing: spacing) 248 | } 249 | 250 | private func thumbHandler() { 251 | if self.thumbState != 0 { 252 | if self.thumbOffset + self.thumbState > 1 { 253 | self.currentVelocity += (self.thumbOffset + self.thumbState) 254 | } else if self.thumbOffset + self.thumbState < 0 { 255 | self.currentVelocity -= 1-(self.thumbOffset + self.thumbState) 256 | } else { 257 | self.currentVelocity *= 0.97 258 | } 259 | } else { 260 | self.currentVelocity *= 0.97 261 | } 262 | } 263 | private func makeThumb(_ proxy: GeometryProxy) -> some View { 264 | style.makeThumb(configuration: configuration) 265 | .allowsHitTesting(false) 266 | .opacity(0) 267 | .anchorPreference(key: ThumbKey.self, value: .bounds, transform: { proxy[$0]}) 268 | .overlayPreferenceValue(ThumbKey.self, { (rect) in 269 | self.style.makeThumb(configuration: self.configuration) 270 | .position(x: (proxy.size.width-rect.width)*min(max(self.thumbState + self.thumbOffset, 0), 1), 271 | y: proxy.size.height/2) 272 | .offset(x: rect.width/2, y: 0) 273 | .gesture( 274 | DragGesture() 275 | .onChanged({ (value) in 276 | self.thumbState = value.translation.width/proxy.size.width 277 | }).onEnded({ (value) in 278 | self.thumbState = 0 279 | self.thumbOffset = min(max(value.translation.width/proxy.size.width + self.thumbOffset, 0), 1) 280 | })) 281 | .onReceive(self.timer) { (time) in 282 | // stop velocity once value reaches limit 283 | if self.value == self.range.upperBound || self.value == self.range.lowerBound || abs(self.currentVelocity) < 1 { 284 | self.currentVelocity = 0 285 | } 286 | // allow velocity to displace track while not active 287 | if !self.trackState.isActive { 288 | self.trackOffset -= self.currentVelocity*0.01 289 | } 290 | 291 | self.thumbHandler() 292 | 293 | // Update value 294 | self.value = max(min(Double(-(self.trackState.translation + self.trackOffset) + (proxy.size.width-rect.width)*(self.thumbState + self.thumbOffset)), self.range.upperBound), self.range.lowerBound) 295 | }.onAppear { 296 | self.trackOffset = -(CGFloat(self.value) - (proxy.size.width-rect.width)*(self.thumbState + self.thumbOffset)) 297 | } 298 | }) 299 | } 300 | 301 | private func calculateVelocity(translation: CGFloat, time: Date) -> CGFloat { 302 | guard let last = trackState.time else {return .zero} 303 | let dx = translation-trackState.translation 304 | let dt = CGFloat(1/last.timeIntervalSince(time)) 305 | return dx*dt 306 | } 307 | private func makeTrack(_ proxy: GeometryProxy) -> some View { 308 | let offset = self.trackState.translation + self.trackOffset 309 | let w: CGFloat = proxy.size.width/2 310 | return style.makeTrack(configuration: configuration) 311 | .contentShape(Rectangle()) 312 | .offset(x: max(min(offset, -CGFloat(range.lowerBound)+w), -CGFloat(range.upperBound)+w), y: 0) 313 | .offset(x: -CGFloat(range.upperBound - range.lowerBound)/2 ) 314 | .gesture( 315 | DragGesture(coordinateSpace: .global) 316 | .onChanged({ (value) in 317 | let velocity = self.calculateVelocity(translation: value.translation.width, time: value.time) 318 | self.trackState = .dragging(time: value.time, translation: value.translation.width, startLocation: value.startLocation.x, velocity: velocity) 319 | }).onEnded({ (value) in 320 | self.currentVelocity += self.trackState.velocity 321 | self.trackState = .inactive 322 | self.trackOffset += value.translation.width 323 | self.trackOffset = max(min(self.trackOffset, CGFloat(-self.range.lowerBound)+proxy.size.width/2), CGFloat(-self.range.upperBound)+proxy.size.width/2) 324 | 325 | })) 326 | .animation(.linear) 327 | 328 | } 329 | 330 | public var body: some View { 331 | RoundedRectangle(cornerRadius: 5) 332 | .fill(Color.white.opacity(0.001)) 333 | .allowsHitTesting(false) 334 | .background(GeometryReader{ 335 | self.makeTrack($0)} 336 | .padding(.horizontal) 337 | .allowsHitTesting(!self.isDisabled)) 338 | .overlay(GeometryReader { proxy in 339 | ZStack { 340 | self.makeThumb(proxy) 341 | .allowsHitTesting(!self.isDisabled) 342 | }.offset(x: -proxy.size.width/2) 343 | }) 344 | .clipped() 345 | } 346 | } 347 | 348 | 349 | -------------------------------------------------------------------------------- /Sources/Sliders/PSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PSlider.swift 3 | // MyExamples 4 | // 5 | // Created by Kieran Brown on 4/6/20. 6 | // Copyright © 2020 BrownandSons. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Shapes 11 | import bez 12 | 13 | // MARK: - Configuration 14 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 15 | public struct PSliderConfiguration { 16 | /// whether or not the slider is current disables 17 | public let isDisabled: Bool 18 | /// whether or not the thumb is dragging or not 19 | public let isActive: Bool 20 | /// The percentage of the sliders track that is filled 21 | public let pctFill: Double 22 | /// The current value of the slider 23 | public let value: Double 24 | /// The direction between the current thumb location and the next sampled point 25 | public let angle: Angle 26 | /// The minimum value of the sliders range 27 | public let min: Double 28 | /// The maximum value of the sliders range 29 | public let max: Double 30 | /// The shape of the slider 31 | public let shape: AnyShape 32 | } 33 | // MARK: - Style 34 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 35 | public protocol PSliderStyle { 36 | associatedtype Thumb: View 37 | associatedtype Track: View 38 | 39 | func makeThumb(configuration: PSliderConfiguration) -> Self.Thumb 40 | func makeTrack(configuration: PSliderConfiguration) -> Self.Track 41 | } 42 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 43 | public extension PSliderStyle { 44 | func makeThumbTypeErased(configuration: PSliderConfiguration) -> AnyView { 45 | AnyView(self.makeThumb(configuration: configuration)) 46 | } 47 | func makeTrackTypeErased(configuration: PSliderConfiguration) -> AnyView { 48 | AnyView(self.makeTrack(configuration: configuration)) 49 | } 50 | } 51 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 52 | public struct AnyPSliderStyle: PSliderStyle { 53 | private let _makeThumb: (PSliderConfiguration) -> AnyView 54 | public func makeThumb(configuration: PSliderConfiguration) -> some View { 55 | self._makeThumb(configuration) 56 | } 57 | private let _makeTrack: (PSliderConfiguration) -> AnyView 58 | public func makeTrack(configuration: PSliderConfiguration) -> some View { 59 | self._makeTrack(configuration) 60 | } 61 | public init(_ style: S) { 62 | self._makeThumb = style.makeThumbTypeErased 63 | self._makeTrack = style.makeTrackTypeErased 64 | } 65 | } 66 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 67 | public struct PSliderStyleKey: EnvironmentKey { 68 | public static let defaultValue: AnyPSliderStyle = AnyPSliderStyle(DefaultPSliderStyle()) 69 | } 70 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 71 | extension EnvironmentValues { 72 | public var pathSliderStyle: AnyPSliderStyle { 73 | get { 74 | return self[PSliderStyleKey.self] 75 | } 76 | set { 77 | self[PSliderStyleKey] = newValue 78 | } 79 | } 80 | } 81 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 82 | extension View { 83 | public func pathSliderStyle(_ style: S) -> some View where S: PSliderStyle { 84 | self.environment(\.pathSliderStyle, AnyPSliderStyle(style)) 85 | } 86 | } 87 | // MARK: - Default Style 88 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 89 | public struct DefaultPSliderStyle: PSliderStyle { 90 | public init() {} 91 | 92 | public func makeThumb(configuration: PSliderConfiguration) -> some View { 93 | Circle() 94 | .frame(width: 30, height:30) 95 | .foregroundColor(configuration.isActive ? Color.yellow : Color.white) 96 | } 97 | public func makeTrack(configuration: PSliderConfiguration) -> some View { 98 | ZStack { 99 | configuration.shape 100 | .stroke(Color.gray, lineWidth: 8) 101 | configuration.shape 102 | .trim(from: 0, to: CGFloat(configuration.pctFill)) 103 | .stroke(Color.purple, lineWidth: 10) 104 | } 105 | } 106 | } 107 | 108 | 109 | 110 | /// # Path Slider 111 | /// A View that turns any `Shape` into a slider. Its great for creating unique user experiences 112 | /// 113 | /// - parameters: 114 | /// - value: a `Binding` value which represents the percent fill of the slider between (0,1). 115 | /// - shape: The `Shape` to be used as the sliders track 116 | /// - range: `ClosedRange` The minimum and maximum numbers that `value` can be 117 | /// - isDisabled: `Bool` Whether or not the slider should be disabled 118 | /// 119 | /// ## Styling The Slider 120 | /// 121 | /// To create a custom style for the slider you need to create a `PSliderStyle` conforming struct. Conformnance requires implementation of 2 methods 122 | /// 1. `makeThumb`: which creates the draggable portion of the slider 123 | /// 2. `makeTrack`: which creates the track which fills or emptys as the thumb is dragging within it 124 | /// 125 | /// Both methods provide access to state values through the `PSliderConfiguration` struct 126 | /// 127 | /// struct PSliderConfiguration { 128 | /// let isDisabled: Bool // whether or not the slider is disabled 129 | /// let isActive: Bool // whether or not the thumb is currently dragging 130 | /// let pctFill: Double // The percentage of the sliders track that is filled 131 | /// let value: Double // The current value of the slider 132 | /// let angle: Angle // Angle of the thumb 133 | /// let min: Double // The minimum value of the sliders range 134 | /// let max: Double // The maximum value of the sliders range 135 | /// } 136 | /// 137 | /// To make this easier just copy and paste the following style based on the `DefaultPSliderStyle`. After creating your custom style 138 | /// apply it by calling the `pathSliderStyle` method on the `PSlider` or a view containing it. 139 | /// 140 | /// ``` 141 | /// struct <#My PSlider Style#>: PSliderStyle { 142 | /// func makeThumb(configuration: PSliderConfiguration) -> some View { 143 | /// Circle() 144 | /// .frame(width: 30, height:30) 145 | /// .foregroundColor(configuration.isActive ? Color.yellow : Color.white) 146 | /// } 147 | /// 148 | /// func makeTrack(configuration: PSliderConfiguration) -> some View { 149 | /// configuration.shape 150 | /// .stroke(Color.gray, lineWidth: 8) 151 | /// .overlay( 152 | /// configuration.shape 153 | /// .trim(from: 0, to: CGFloat(configuration.pctFill)) 154 | /// .stroke(Color.purple, lineWidth: 10)) 155 | /// } 156 | /// } 157 | /// ``` 158 | /// 159 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 160 | public struct PSlider: View { 161 | enum DragState { 162 | case inactive 163 | case dragging(translation: CGSize) 164 | 165 | var translation: CGSize { 166 | switch self { 167 | case .inactive: 168 | return .zero 169 | case .dragging(let translation): 170 | return translation 171 | } 172 | } 173 | 174 | var isActive: Bool { 175 | switch self { 176 | case .inactive: 177 | return false 178 | case .dragging: 179 | return true 180 | } 181 | } 182 | } 183 | @Environment(\.pathSliderStyle) private var style: AnyPSliderStyle 184 | private let space: String = "Follow" 185 | 186 | @State private var dragState: DragState = .inactive 187 | @Binding public var value: Double 188 | public var shape: S 189 | public var range: ClosedRange 190 | public var isDisabled: Bool 191 | 192 | public init(_ value: Binding, shape: S) { 193 | self._value = value 194 | self.shape = shape 195 | self.range = 0...1 196 | self.isDisabled = false 197 | } 198 | public init(_ value: Binding, range: ClosedRange, shape: S) { 199 | self._value = value 200 | self.shape = shape 201 | self.range = range 202 | self.isDisabled = false 203 | } 204 | public init(_ value: Binding, range: ClosedRange, shape: S, isDisabled: Bool) { 205 | self._value = value 206 | self.shape = shape 207 | self.isDisabled = isDisabled 208 | self.range = range 209 | } 210 | public init(_ value: Binding, shape: S, isDisabled: Bool) { 211 | self._value = value 212 | self.shape = shape 213 | self.isDisabled = isDisabled 214 | self.range = 0...1 215 | } 216 | 217 | struct PThumb: View { 218 | 219 | @State private var position: CGPoint = .zero 220 | @Environment(\.pathSliderStyle) private var style: AnyPSliderStyle 221 | private let space: String = "Follow" 222 | @Binding var dragState: PSlider.DragState 223 | @Binding public var value: Double 224 | public let lookUpTable: [CGPoint] 225 | public let range: ClosedRange 226 | public let isDisabled: Bool 227 | public let shape: AnyShape 228 | 229 | func getDisplacement(closestPoint: CGPoint) -> CGSize { 230 | return CGSize(width: closestPoint.x - position.x, height: closestPoint.y - position.y) 231 | } 232 | func calculateDirection(_ pt1: CGPoint, _ pt2: CGPoint) -> Angle { 233 | let a = pt2.x - pt1.x 234 | let b = pt2.y - pt1.y 235 | 236 | let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi 237 | return Angle(radians: angle) 238 | } 239 | var angle: Angle { 240 | let num = Int(getPercent(self.position + self.dragState.translation.toPoint(), lookupTable: self.lookUpTable)*CGFloat(lookUpTable.count)) 241 | if self.lookUpTable.count < 3 { 242 | return .zero 243 | } 244 | if num > lookUpTable.count-2 { 245 | return calculateDirection(lookUpTable[num-2], lookUpTable[num-1]) 246 | } else { 247 | return calculateDirection(lookUpTable[num], lookUpTable[num+1]) 248 | } 249 | } 250 | var configuration: PSliderConfiguration { 251 | .init(isDisabled: isDisabled, 252 | isActive: dragState.isActive, 253 | pctFill: value, 254 | value: value, 255 | angle: angle, 256 | min: range.lowerBound, 257 | max: range.upperBound, 258 | shape: shape) 259 | } 260 | 261 | var body: some View { 262 | style 263 | .makeThumb(configuration: self.configuration) 264 | .position(position) 265 | .offset(dragState.translation) 266 | .gesture(self.dragGesture) 267 | .onAppear { 268 | let num = self.value*Double(self.lookUpTable.count) 269 | self.position = self.lookUpTable[Int(num)] 270 | } 271 | } 272 | 273 | private var dragGesture: some Gesture { 274 | DragGesture(minimumDistance: 0, coordinateSpace: .named(space)) 275 | .onChanged({ (drag) in 276 | let closestPoint = getClosestPoint(drag.location , lookupTable: self.lookUpTable) 277 | self.dragState = .dragging(translation: self.getDisplacement(closestPoint: closestPoint)) 278 | self.value = Double(getPercent(closestPoint, lookupTable: self.lookUpTable))*(self.range.upperBound-self.range.lowerBound) + self.range.lowerBound 279 | }) 280 | .onEnded { drag in 281 | let closestPoint = getClosestPoint(drag.location, lookupTable: self.lookUpTable) 282 | self.value = Double(getPercent(closestPoint, lookupTable: self.lookUpTable))*(self.range.upperBound-self.range.lowerBound) + self.range.lowerBound 283 | let displacement = self.getDisplacement(closestPoint: closestPoint) 284 | self.position.x += displacement.width 285 | self.position.y += displacement.height 286 | self.dragState = .inactive 287 | } 288 | } 289 | } 290 | 291 | private func makeThumb(_ proxy: GeometryProxy) -> some View { 292 | let rect = proxy.frame(in: .global) != .zero ? proxy.frame(in: .local) : CGRect(x: 0, y: 0, width: 100, height: 100) 293 | return PThumb(dragState: $dragState, 294 | value: $value, 295 | lookUpTable: generateLookupTable(path: shape.path(in: rect)), 296 | range: range, 297 | isDisabled: self.isDisabled, 298 | shape: AnyShape(shape)) 299 | } 300 | 301 | var configuration: PSliderConfiguration { 302 | .init(isDisabled: isDisabled, 303 | isActive: dragState.isActive, 304 | pctFill: (value-range.lowerBound)/(range.upperBound-range.lowerBound), 305 | value: value, 306 | angle: .zero, 307 | min: range.lowerBound, 308 | max: range.upperBound, 309 | shape: AnyShape(shape)) 310 | } 311 | 312 | public var body: some View { 313 | GeometryReader { proxy in 314 | self.style.makeTrack(configuration: self.configuration) 315 | .overlay( 316 | self.makeThumb(proxy) 317 | ).coordinateSpace(name: "Follow") 318 | } 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /Sources/Sliders/RadialPad.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RadialPad.swift 3 | // MyExamples 4 | // 5 | // Created by Kieran Brown on 4/7/20. 6 | // Copyright © 2020 BrownandSons. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import CGExtender 11 | 12 | // MARK: - Style 13 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 14 | public struct RadialPadConfiguration { 15 | /// whether or not the slider is current disables 16 | public let isDisabled: Bool 17 | /// whether or not the thumb is dragging or not 18 | public let isActive: Bool 19 | /// Is true if the radial offset is equal to the pads radius 20 | public let isAtLimit: Bool 21 | /// The angle of the line between the pads center and the thumbs location, measured from the vector pointing in the trailing direction 22 | public let angle: Angle 23 | /// The Thumb's distance from the Track's center 24 | public let radialOffset: Double 25 | 26 | public init(_ isDisabled: Bool ,_ isActive: Bool , _ isAtLimit: Bool, _ angle: Angle, _ radialOffset: Double) { 27 | self.isDisabled = isDisabled 28 | self.isActive = isActive 29 | self.isAtLimit = isAtLimit 30 | self.angle = angle 31 | self.radialOffset = radialOffset 32 | } 33 | } 34 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 35 | public protocol RadialPadStyle { 36 | associatedtype Track: View 37 | associatedtype Thumb: View 38 | 39 | func makeTrack(configuration: RadialPadConfiguration) -> Self.Track 40 | func makeThumb(configuration: RadialPadConfiguration) -> Self.Thumb 41 | } 42 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 43 | public extension RadialPadStyle { 44 | func makeTrackTypeErased(configuration: RadialPadConfiguration) -> AnyView { 45 | AnyView(self.makeTrack(configuration: configuration)) 46 | } 47 | func makeThumbTypeErased(configuration: RadialPadConfiguration) -> AnyView { 48 | AnyView(self.makeThumb(configuration: configuration)) 49 | } 50 | } 51 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 52 | public struct AnyRadialPadStyle: RadialPadStyle { 53 | private let _makeTrack: (RadialPadConfiguration) -> AnyView 54 | public func makeTrack(configuration: RadialPadConfiguration) -> some View { 55 | return self._makeTrack(configuration) 56 | } 57 | 58 | private let _makeThumb: (RadialPadConfiguration) -> AnyView 59 | public func makeThumb(configuration: RadialPadConfiguration) -> some View { 60 | return self._makeThumb(configuration) 61 | } 62 | 63 | public init(_ style: S) { 64 | self._makeTrack = style.makeTrackTypeErased 65 | self._makeThumb = style.makeThumbTypeErased 66 | } 67 | } 68 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 69 | public struct DefaultRadialPadStyle: RadialPadStyle { 70 | public init() { } 71 | 72 | public func makeTrack(configuration: RadialPadConfiguration) -> some View { 73 | Circle() 74 | .fill(Color.gray.opacity(0.4)) 75 | } 76 | public func makeThumb(configuration: RadialPadConfiguration) -> some View { 77 | Circle() 78 | .fill(Color.blue) 79 | .frame(width: 45, height: 45) 80 | 81 | } 82 | } 83 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 84 | public struct RadialPadStyleKey: EnvironmentKey { 85 | public static let defaultValue: AnyRadialPadStyle = AnyRadialPadStyle(DefaultRadialPadStyle()) 86 | } 87 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 88 | extension EnvironmentValues { 89 | public var radialPadStyle: AnyRadialPadStyle { 90 | get { 91 | return self[RadialPadStyleKey.self] 92 | } 93 | set { 94 | self[RadialPadStyleKey.self] = newValue 95 | } 96 | } 97 | } 98 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 99 | extension View { 100 | public func radialPadStyle(_ style: S) -> some View where S: RadialPadStyle { 101 | self.environment(\.radialPadStyle, AnyRadialPadStyle(style)) 102 | } 103 | } 104 | /// # Radial Track Pad 105 | /// 106 | /// A control that constrains the drag gesture of the thumb to be contained within the radius of the track. 107 | /// Similar to a joystick, with the difference being that the thumb stays fixed at the gestures end location when the drag is finished. 108 | /// - parameters: 109 | /// - offset: `Binding` The distance measured from the tracks center to the thumbs location 110 | /// - angle: `Binding` The angle of the line between the pads center and the thumbs location, measured from the vector pointing in the trailing direction 111 | /// - isDisabled: `Bool` value describing if the sliders state is disabled (default is `false`) 112 | /// 113 | /// - note: There is no need to define the radius of the track because the `RadialPad` automatically adjusts to the geometry of its container. 114 | /// 115 | /// ## Styling 116 | /// 117 | /// To create a custom style for the `RadialPad` you need to create a `RadialPadStyle` conforming struct. 118 | /// Conformance requires implementation of 2 methods 119 | /// 1. `makeThumb`: which creates the draggable portion of the `RadialPad` 120 | /// 2. `makeTrack`: which creates the background that the thumb will be contained in. 121 | /// 122 | /// Both methods provide read access to the state values of the `RadialPad` thru the `RadialPadConfiguration` struct 123 | /// 124 | /// struct RadialPadConfiguration { 125 | /// let isDisabled: Bool // whether or not the slider is current disables 126 | /// let isActive: Bool // whether or not the thumb is dragging or not 127 | /// let isAtLimit: Bool // Is true if the radial offset is equal to the pads radius 128 | /// let angle: Angle // The angle of the line between the pads center and the thumbs location, measured from the vector pointing in the trailing direction 129 | /// let radialOffset: Double // The Thumb's distance from the Track's center 130 | /// } 131 | /// 132 | /// To make this easier just copy and paste the following style based on the `DefaultRadialPadStyle`. After creating your custom style 133 | /// apply it by calling the `radialPadStyle` method on the `RadialPad` or a view containing it. 134 | /// 135 | /// struct <#My RadialPad Style#>: RadialPadStyle { 136 | /// func makeTrack(configuration: RadialPadConfiguration) -> some View { 137 | /// Circle() 138 | /// .fill(Color.gray.opacity(0.4)) 139 | /// } 140 | /// func makeThumb(configuration: RadialPadConfiguration) -> some View { 141 | /// Circle() 142 | /// .fill(Color.blue) 143 | /// .frame(width: 45, height: 45) 144 | /// } 145 | /// } 146 | /// 147 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 148 | public struct RadialPad: View { 149 | @Environment(\.radialPadStyle) private var style: AnyRadialPadStyle 150 | private let space: String = "Radial Pad" 151 | @Binding public var offset: Double 152 | @Binding public var angle: Angle 153 | @State private var isActive: Bool = false 154 | public var isDisabled: Bool = false 155 | public init(offset: Binding, angle: Binding) { 156 | self._offset = offset 157 | self._angle = angle 158 | } 159 | 160 | public init(offset: Binding, angle: Binding, isDisabled: Bool) { 161 | self._offset = offset 162 | self._angle = angle 163 | self.isDisabled = isDisabled 164 | } 165 | 166 | private func thumbOffset(_ proxy: GeometryProxy) -> CGSize { 167 | let radius = Double(proxy.size.width > proxy.size.height ? proxy.size.height/2 : proxy.size.width/2) 168 | let pX = radius*offset*cos(angle.radians) 169 | let pY = radius*offset*sin(angle.radians) 170 | return CGSize(width: pX, height: pY) 171 | } 172 | private var configuration: RadialPadConfiguration { 173 | return .init(isDisabled, isActive, offset == 1, angle, offset) 174 | } 175 | private func makeGesture(_ proxy: GeometryProxy) -> some Gesture { 176 | DragGesture(minimumDistance: 0, coordinateSpace: .named(self.space)) 177 | .onChanged({ 178 | let middle = CGPoint(x: proxy.size.width/2, y: proxy.size.height/2) 179 | let radius = Double(proxy.size.width > proxy.size.height ? proxy.size.height/2 : proxy.size.width/2) 180 | let off = sqrt((middle - $0.location).magnitudeSquared) 181 | self.offset = min(radius, off)/radius 182 | self.angle = Angle(degrees: calculateDirection(middle, $0.location)*360) 183 | self.isActive = true 184 | }) 185 | .onEnded({ 186 | let middle = CGPoint(x: proxy.size.width/2, y: proxy.size.height/2) 187 | let radius = Double(proxy.size.width > proxy.size.height ? proxy.size.height/2 : proxy.size.width/2) 188 | let off = sqrt((middle - $0.location).magnitudeSquared) 189 | self.offset = min(radius, off)/radius 190 | self.angle = Angle(degrees: calculateDirection(middle, $0.location)*360) 191 | self.isActive = false 192 | }) 193 | } 194 | 195 | public var body: some View { 196 | ZStack { 197 | style.makeTrack(configuration: configuration) 198 | .overlay(GeometryReader { proxy in 199 | ZStack(alignment: .center) { 200 | self.style.makeThumb(configuration: self.configuration) 201 | .offset(self.thumbOffset(proxy)) 202 | .gesture(self.makeGesture(proxy)) 203 | }.frame(width: proxy.size.width, height: proxy.size.height) 204 | }) 205 | }.coordinateSpace(name: space) 206 | } 207 | } 208 | 209 | -------------------------------------------------------------------------------- /Sources/Sliders/RadialSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RadialSlider.swift 3 | // MyExamples 4 | // 5 | // Created by Kieran Brown on 3/25/20. 6 | // Copyright © 2020 BrownandSons. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import CGExtender 11 | 12 | // MARK: - Configuration 13 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 14 | public struct RSliderConfiguration { 15 | /// whether or not the slider is current disables 16 | public let isDisabled: Bool 17 | /// whether or not the thumb is dragging or not 18 | public let isActive: Bool 19 | /// The percentage of the sliders track that is filled 20 | public let pctFill: Double 21 | /// The current value of the slider 22 | public let value: Double 23 | /// The direction from the thumb to the slider center 24 | public let angle: Angle 25 | /// The minimum value of the sliders range 26 | public let min: Double 27 | /// The maximum value of the sliders range 28 | public let max: Double 29 | } 30 | // MARK: - Style 31 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 32 | public protocol RSliderStyle { 33 | associatedtype Thumb: View 34 | associatedtype Track: View 35 | 36 | func makeThumb(configuration: RSliderConfiguration) -> Self.Thumb 37 | func makeTrack(configuration: RSliderConfiguration) -> Self.Track 38 | } 39 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 40 | public extension RSliderStyle { 41 | func makeThumbTypeErased(configuration: RSliderConfiguration) -> AnyView { 42 | AnyView(self.makeThumb(configuration: configuration)) 43 | } 44 | func makeTrackTypeErased(configuration: RSliderConfiguration) -> AnyView { 45 | AnyView(self.makeTrack(configuration: configuration)) 46 | } 47 | } 48 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 49 | public struct AnyRSliderStyle: RSliderStyle { 50 | private let _makeThumb: (RSliderConfiguration) -> AnyView 51 | public func makeThumb(configuration: RSliderConfiguration) -> some View { 52 | self._makeThumb(configuration) 53 | } 54 | private let _makeTrack: (RSliderConfiguration) -> AnyView 55 | public func makeTrack(configuration: RSliderConfiguration) -> some View { 56 | self._makeTrack(configuration) 57 | } 58 | public init(_ style: S) { 59 | self._makeThumb = style.makeThumbTypeErased 60 | self._makeTrack = style.makeTrackTypeErased 61 | } 62 | } 63 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 64 | public struct RSliderStyleKey: EnvironmentKey { 65 | public static let defaultValue: AnyRSliderStyle = AnyRSliderStyle(DefaultRSliderStyle()) 66 | } 67 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 68 | extension EnvironmentValues { 69 | public var radialSliderStyle: AnyRSliderStyle { 70 | get { 71 | return self[RSliderStyleKey.self] 72 | } 73 | set { 74 | self[RSliderStyleKey] = newValue 75 | } 76 | } 77 | } 78 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 79 | extension View { 80 | public func radialSliderStyle(_ style: S) -> some View where S: RSliderStyle { 81 | self.environment(\.radialSliderStyle, AnyRSliderStyle(style)) 82 | } 83 | } 84 | // MARK: - Default Style 85 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 86 | public struct DefaultRSliderStyle: RSliderStyle { 87 | public init() { } 88 | 89 | public func makeThumb(configuration: RSliderConfiguration) -> some View { 90 | Circle() 91 | .frame(width: 30, height:30) 92 | .foregroundColor(configuration.isActive ? Color.yellow : Color.white) 93 | } 94 | 95 | public func makeTrack(configuration: RSliderConfiguration) -> some View { 96 | ZStack { 97 | Circle() 98 | .stroke(Color.gray, style: StrokeStyle(lineWidth: 10, lineCap: .round)) 99 | Circle() 100 | .trim(from: 0, to: CGFloat(configuration.pctFill)) 101 | .stroke(Color.purple, style: StrokeStyle(lineWidth: 12, lineCap: .round)) 102 | } 103 | } 104 | } 105 | 106 | // MARK: - Knob Style 107 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 108 | public struct KnobStyle: RSliderStyle { 109 | public init() { } 110 | 111 | public func makeThumb(configuration: RSliderConfiguration) -> some View { 112 | let gradient = RadialGradient(gradient: Gradient(colors: [.gray, .white]), center: .center, startRadius: 0 , endRadius: 80) 113 | return Circle() 114 | .fill(gradient) 115 | .frame(width: 40) 116 | .shadow(radius: 1) 117 | 118 | } 119 | public func makeTrack(configuration: RSliderConfiguration) -> some View { 120 | Circle().frame(width: 150) 121 | .foregroundColor(.clear) 122 | .overlay(ZStack { 123 | Circle() 124 | .fill(RadialGradient(gradient: Gradient(colors: [.blue, Color(white: 0.2)]), center: .center, startRadius: 0 , endRadius: 300)) 125 | .drawingGroup(opaque: false, colorMode: .extendedLinear) 126 | .overlay( 127 | Circle().stroke(Color.white, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [6], dashPhase: 0)) 128 | ) 129 | } 130 | .rotationEffect(Angle(degrees: Double(360*configuration.pctFill))) 131 | .scaleEffect(1.50)) 132 | } 133 | 134 | } 135 | // MARK: Radial Slider 136 | 137 | 138 | /// # Radial Slider 139 | /// A Circular slider whose thumb is dragged causing it to follow the path of the circle 140 | /// 141 | /// - parameters: 142 | /// - value: a `Binding` value to be controlled. 143 | /// - range: a `ClosedRange` denoting the minimum and maximum values of the slider (default is `0...1`) 144 | /// - isDisabled: a `Bool` value describing if the sliders state is disabled (default is `false`) 145 | /// 146 | /// ## Styling The Slider 147 | /// 148 | /// To create a custom style for the slider you need to create a `RSliderStyle` conforming struct. Conformance requires implementation of 2 methods 149 | /// 1. `makeThumb`: which creates the draggable portion of the slider 150 | /// 2. `makeTrack`: which creates the track which fills or emptys as the thumb is dragging within it 151 | /// 152 | /// Both methods provide access to state values of the radial slider thru the `RSliderConfiguration` struct 153 | /// ``` 154 | /// struct RSliderConfiguration { 155 | /// let isDisabled: Bool // whether or not the slider is current disables 156 | /// let isActive: Bool // whether or not the thumb is dragging or not 157 | /// let pctFill: Double // The percentage of the sliders track that is filled 158 | /// let value: Double // The current value of the slider 159 | /// let angle: Angle // The direction from the thumb to the slider center 160 | /// let min: Double // The minimum value of the sliders range 161 | /// let max: Double // The maximum value of the sliders range 162 | /// } 163 | /// ``` 164 | /// To make this easier just copy and paste the following style based on the `DefaultRSliderStyle`. After creating your custom style 165 | /// apply it by calling the `radialSliderStyle` method on the `RSlider` or a view containing it. 166 | /// 167 | /// ``` 168 | /// struct <#My Slider Style #>: RSliderStyle { 169 | /// func makeThumb(configuration: RSliderConfiguration) -> some View { 170 | /// Circle() 171 | /// .frame(width: 30, height:30) 172 | /// .foregroundColor(configuration.isActive ? Color.yellow : Color.white) 173 | /// } 174 | /// 175 | /// func makeTrack(configuration: RSliderConfiguration) -> some View { 176 | /// Circle() 177 | /// .stroke(Color.gray, style: StrokeStyle(lineWidth: 10, lineCap: .round)) 178 | /// .overlay(Circle() 179 | /// .trim(from: 0, to: CGFloat(configuration.pctFill)) 180 | /// .stroke(Color.purple, style: StrokeStyle(lineWidth: 12, lineCap: .round))) 181 | /// 182 | /// } 183 | /// } 184 | /// ``` 185 | /// 186 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 187 | public struct RSlider: View { 188 | @Environment(\.radialSliderStyle) private var style: AnyRSliderStyle 189 | @State private var isActive = false 190 | @Binding public var value: Double 191 | public let range: ClosedRange 192 | public let isDisabled: Bool 193 | 194 | public init(_ value: Binding, isDisabled: Bool) { 195 | self._value = value 196 | self.isDisabled = isDisabled 197 | self.range = 0...1 198 | } 199 | public init(_ value: Binding, range: ClosedRange) { 200 | self._value = value 201 | self.range = range 202 | self.isDisabled = false 203 | 204 | } 205 | public init(_ value: Binding, range: ClosedRange, isDisabled: Bool) { 206 | self._value = value 207 | self.isDisabled = isDisabled 208 | self.range = range 209 | } 210 | public init(_ value: Binding) { 211 | self._value = value 212 | self.range = 0...1 213 | self.isDisabled = false 214 | } 215 | 216 | private func calculateDirection(_ pt1: CGPoint, _ pt2: CGPoint) -> Double { 217 | let a = pt2.x - pt1.x 218 | #if os(macOS) 219 | let b = -(pt2.y - pt1.y) 220 | #else 221 | let b = pt2.y - pt1.y 222 | #endif 223 | return Double(atanP(x: a, y: b)/(2 * .pi)) 224 | } 225 | private var configuration: RSliderConfiguration { 226 | let pct = (value-range.lowerBound)/(range.upperBound-range.lowerBound) 227 | 228 | return .init(isDisabled: isDisabled, 229 | isActive: isActive, 230 | pctFill: pct, 231 | value: value, 232 | angle: Angle(degrees: pct*360), 233 | min: range.lowerBound, 234 | max: range.upperBound) 235 | } 236 | private func makeThumb(_ proxy: GeometryProxy) -> some View { 237 | let radius = min(proxy.size.height, proxy.size.width)/2 238 | let middle = CGPoint(x: proxy.frame(in: .global).midX, y: proxy.frame(in: .global).midY) 239 | 240 | let gesture = DragGesture(minimumDistance: 0, coordinateSpace: .global) 241 | .onChanged { (value) in 242 | let direction = self.calculateDirection(middle, value.location) 243 | self.value = direction*(self.range.upperBound-self.range.lowerBound) + self.range.lowerBound 244 | self.isActive = true 245 | } 246 | .onEnded { (value) in 247 | let direction = self.calculateDirection(middle, value.location) 248 | self.value = direction*(self.range.upperBound-self.range.lowerBound) + self.range.lowerBound 249 | self.isActive = false 250 | } 251 | let pct = (value-range.lowerBound)/(range.upperBound-range.lowerBound) 252 | let pX = radius*CGFloat(cos(pct*2 * .pi )) 253 | let pY = radius*CGFloat(sin(pct*2 * .pi )) 254 | 255 | return style.makeThumb(configuration: configuration) 256 | .offset(x: pX, y: pY) 257 | .gesture(gesture) 258 | } 259 | public var body: some View { 260 | style.makeTrack(configuration: configuration).overlay(GeometryReader { proxy in 261 | ZStack(alignment: .center) { 262 | self.makeThumb(proxy) 263 | }.frame(width: proxy.size.width, height: proxy.size.height) 264 | }).padding() 265 | } 266 | } 267 | 268 | // MARK: - Double Radial Slider 269 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 270 | struct DoubleRSlider: View { 271 | @Environment(\.radialSliderStyle) private var style: AnyRSliderStyle 272 | @State public var startState: CGFloat = 0 273 | @State public var start: CGFloat = 0 274 | @State public var endState: CGFloat = 0 275 | @State public var end: CGFloat = 0.5 276 | private var e: CGFloat { endState + end } 277 | private var s: CGFloat { startState + start } 278 | private var configuration: RSliderConfiguration { 279 | .init(isDisabled: false, 280 | isActive: self.startState != 0 || self.endState != 0, 281 | pctFill: Double(s > e ? e+(1-s) : e-s), 282 | value: 0, 283 | angle: .zero, 284 | min: 0, 285 | max: 1) 286 | } 287 | 288 | 289 | public init() { 290 | 291 | } 292 | 293 | public func makeThumbs(_ proxy: GeometryProxy) -> some View { 294 | let radius = min(proxy.size.height, proxy.size.width)/2 295 | let middle = CGPoint(x: proxy.frame(in: .global).midX, y: proxy.frame(in: .global).midY) 296 | let upperGesture = DragGesture(minimumDistance: 0, coordinateSpace: .global).onChanged { (value) in 297 | self.endState = self.calculateDirection(middle, value.location) - self.end 298 | }.onEnded { (value) in 299 | self.endState = 0 300 | self.end = self.calculateDirection(middle, value.location) 301 | } 302 | 303 | let lowerGesture = DragGesture(minimumDistance: 0, coordinateSpace: .global).onChanged { (value) in 304 | self.startState = self.calculateDirection(middle, value.location) - self.start 305 | }.onEnded { (value) in 306 | self.startState = 0 307 | self.start = self.calculateDirection(middle, value.location) 308 | } 309 | 310 | let pX = radius*cos(e*2 * .pi) 311 | let pY = radius*sin(e*2 * .pi ) 312 | 313 | return Group { 314 | style.makeThumb(configuration: configuration) 315 | .offset(x: pX, y: pY) 316 | .gesture(upperGesture) 317 | 318 | style.makeThumb(configuration: configuration) 319 | .offset(x: radius, y: 0) 320 | .gesture(lowerGesture) 321 | .rotationEffect(Angle(radians: Double((s)*2*CGFloat.pi))) 322 | 323 | } 324 | } 325 | 326 | public func calculateDirection(_ pt1: CGPoint, _ pt2: CGPoint) -> CGFloat { 327 | let a = pt2.x - pt1.x 328 | let b = pt2.y - pt1.y 329 | 330 | return CGFloat(atanP(x: a, y: b) )/(2 * .pi) 331 | } 332 | 333 | public var body: some View { 334 | style.makeTrack(configuration: configuration) 335 | .rotationEffect(Angle(radians: Double(s*2*CGFloat.pi))) 336 | .overlay(GeometryReader { proxy in 337 | ZStack { 338 | self.makeThumbs(proxy) 339 | } 340 | }) 341 | 342 | .padding() 343 | } 344 | } 345 | 346 | -------------------------------------------------------------------------------- /Sources/Sliders/TrackPad.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackPad.swift 3 | // MyExamples 4 | // 5 | // Created by Kieran Brown on 4/6/20. 6 | // Copyright © 2020 BrownandSons. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | // MARK: - Configuration 11 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 12 | public struct TrackPadConfiguration { 13 | /// Whether or not the trackpad is disabled 14 | public let isDisabled: Bool 15 | /// whether or not the thumb is dragging 16 | public let isActive: Bool 17 | /// `(valueX-minX)/(maxX-minX)` 18 | public let pctX: Double 19 | /// `(valueY-minY)/(maxY-minY)` 20 | public let pctY: Double 21 | /// The current value in the x direction 22 | public let valueX: Double 23 | /// The current value in the y direction 24 | public let valueY: Double 25 | /// The minimum value from rangeX 26 | public let minX: Double 27 | /// The maximum value from rangeX 28 | public let maxX: Double 29 | /// The minimum value from rangeY 30 | public let minY: Double 31 | /// The maximum value from rangeY 32 | public let maxY: Double 33 | } 34 | // MARK: - Style 35 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 36 | public protocol TrackPadStyle { 37 | associatedtype Thumb: View 38 | associatedtype Track: View 39 | 40 | func makeThumb(configuration: TrackPadConfiguration) -> Self.Thumb 41 | func makeTrack(configuration: TrackPadConfiguration) -> Self.Track 42 | } 43 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 44 | public extension TrackPadStyle { 45 | func makeThumbTypeErased(configuration: TrackPadConfiguration) -> AnyView { 46 | AnyView(self.makeThumb(configuration: configuration)) 47 | } 48 | func makeTrackTypeErased(configuration: TrackPadConfiguration) -> AnyView { 49 | AnyView(self.makeTrack(configuration: configuration)) 50 | } 51 | } 52 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 53 | public struct AnyTrackPadStyle: TrackPadStyle { 54 | private let _makeThumb: (TrackPadConfiguration) -> AnyView 55 | public func makeThumb(configuration: TrackPadConfiguration) -> some View { 56 | self._makeThumb(configuration) 57 | } 58 | private let _makeTrack: (TrackPadConfiguration) -> AnyView 59 | public func makeTrack(configuration: TrackPadConfiguration) -> some View { 60 | self._makeTrack(configuration) 61 | } 62 | 63 | public init(_ style: S) { 64 | self._makeThumb = style.makeThumbTypeErased 65 | self._makeTrack = style.makeTrackTypeErased 66 | } 67 | } 68 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 69 | public struct TrackPadStyleKey: EnvironmentKey { 70 | public static let defaultValue: AnyTrackPadStyle = AnyTrackPadStyle(DefaultTrackPadStyle()) 71 | } 72 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 73 | extension EnvironmentValues { 74 | public var trackPadStyle: AnyTrackPadStyle { 75 | get { 76 | return self[TrackPadStyleKey.self] 77 | } 78 | set { 79 | self[TrackPadStyleKey] = newValue 80 | } 81 | } 82 | } 83 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 84 | extension View { 85 | public func trackPadStyle(_ style: S) -> some View where S: TrackPadStyle { 86 | self.environment(\.trackPadStyle, AnyTrackPadStyle(style)) 87 | } 88 | } 89 | // MARK: Default Style 90 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 91 | public struct DefaultTrackPadStyle: TrackPadStyle { 92 | public init() { } 93 | public func makeThumb(configuration: TrackPadConfiguration) -> some View { 94 | Circle() 95 | .fill(configuration.isActive ? Color.yellow : Color.black) 96 | .frame(width: 40, height: 40) 97 | } 98 | 99 | public func makeTrack(configuration: TrackPadConfiguration) -> some View { 100 | RoundedRectangle(cornerRadius: 5) 101 | .fill(Color.gray) 102 | .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.blue)) 103 | } 104 | } 105 | 106 | 107 | 108 | // MARK: - TrackPad 109 | 110 | /// # Track Pad 111 | /// 112 | /// Essentially the 2D equaivalent of a normal `Slider`, This creates a draggable thumb and a rectangular area that the thumbs translation is restricted within 113 | /// 114 | /// - parameters: 115 | /// - value: A `CGPoint ` representing the two values being controlled by the trackpad in the x, and y directions 116 | /// - rangeX: A `ClosedRange` defining the minimum and maximum of the `value` parameters x component 117 | /// - rangeY: A `ClosedRange` defining the minimum and maximum of the `value` parameters y component 118 | /// - isDisabled: A `Bool` value describing whether the track pad responds to user input or not 119 | /// 120 | /// ## Styling 121 | /// To create a custom style for the `TrackPad` you need to create a `TrackPadStyle` conforming struct. Conformance requires implementation of 2 methods 122 | /// 1. `makeThumb`: which creates the draggable portion of the trackpad 123 | /// 2. `makeTrack`: which creates view containing the thumb 124 | /// 125 | /// Both methods provide access to state values of the track pad thru the `TrackPadConfiguration` struct 126 | /// 127 | /// struct TrackPadConfiguration { 128 | /// let isDisabled: Bool // Whether or not the trackpad is disabled 129 | /// let isActive: Bool // whether or not the thumb is dragging 130 | /// let pctX: Double // (valueX-minX)/(maxX-minX) 131 | /// let pctY: Double // (valueY-minY)/(maxY-minY) 132 | /// let valueX: Double // The current value in the x direction 133 | /// let valueY: Double // The current value in the y direction 134 | /// let minX: Double // The minimum value from rangeX 135 | /// let maxX: Double // The maximum value from rangeX 136 | /// let minY: Double // The minimum value from rangeY 137 | /// let maxY: Double // The maximum value from rangeY 138 | /// } 139 | /// 140 | /// To make this easier just copy and paste the following style based on the `DefaultTrackPadStyle`. After creating your custom style 141 | /// apply it by calling the `trackPadStyle` method on the `TrackPad` or a view containing it. 142 | /// 143 | /// ``` 144 | /// struct <#My TrackPad Style #>: TrackPadStyle { 145 | /// func makeThumb(configuration: TrackPadConfiguration) -> some View { 146 | /// Circle() 147 | /// .fill(configuration.isActive ? Color.yellow : Color.black) 148 | /// .frame(width: 40, height: 40) 149 | /// } 150 | /// 151 | /// func makeTrack(configuration: TrackPadConfiguration) -> some View { 152 | /// RoundedRectangle(cornerRadius: 5) 153 | /// .fill(Color.gray) 154 | /// .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.blue)) 155 | /// } 156 | /// } 157 | /// ``` 158 | /// 159 | @available(iOS 13.0, macOS 10.15, watchOS 6.0 , *) 160 | public struct TrackPad: View { 161 | // MARK: State and Setup 162 | @Environment(\.trackPadStyle) private var style: AnyTrackPadStyle 163 | private let space: String = "Track Pad" 164 | @State private var isActive: Bool = false 165 | @State private var atXLimit: Bool = false 166 | @State private var atYLimit: Bool = false 167 | // MARK: Inputs 168 | @Binding public var value: CGPoint 169 | public var rangeX: ClosedRange = 0...1 170 | public var rangeY: ClosedRange = 0...1 171 | public var isDisabled: Bool = false 172 | public init(value: Binding, rangeX: ClosedRange, rangeY: ClosedRange, isDisabled: Bool = false){ 173 | self._value = value 174 | self.rangeX = rangeX 175 | self.rangeY = rangeY 176 | self.isDisabled = isDisabled 177 | } 178 | 179 | public init(_ value: Binding){ 180 | self._value = value 181 | } 182 | /// Use this initializer for when the x and y ranges are the same 183 | public init(_ value: Binding, range: ClosedRange){ 184 | self._value = value 185 | self.rangeX = range 186 | self.rangeY = range 187 | } 188 | 189 | 190 | private var configuration: TrackPadConfiguration { 191 | .init(isDisabled: isDisabled, 192 | isActive: isActive, 193 | pctX: Double((value.x - rangeX.lowerBound)/(rangeX.upperBound - rangeX.lowerBound)), 194 | pctY: Double((value.y - rangeY.lowerBound)/(rangeY.upperBound - rangeY.lowerBound)), 195 | valueX: Double(value.x), 196 | valueY: Double(value.y), 197 | minX: Double(rangeX.lowerBound), 198 | maxX: Double(rangeX.upperBound), 199 | minY: Double(rangeY.lowerBound), 200 | maxY: Double(rangeY.upperBound)) 201 | } 202 | 203 | // MARK: Calculations 204 | // Limits the value of the drag gesture to be within the frame of the trackpad 205 | // If the gesture hits an edge of the trackpad a haptic impact is played, an state 206 | // variable for whether or not the impact has been played prevents the impact from 207 | // being played multiple times while dragging along an edge 208 | private func constrainValue(_ proxy: GeometryProxy, _ location: CGPoint) { 209 | let w = proxy.size.width 210 | let h = proxy.size.height 211 | // convert location to percentage form [0,1] 212 | let pctX = (location.x/w).clamped(to: 0...1) 213 | let pctY = (location.y/h).clamped(to: 0...1) 214 | // Horizontal haptic handling 215 | if pctX == 1 || pctX == 0 { 216 | if !self.atXLimit { 217 | self.impactOccured() 218 | } 219 | self.atXLimit = true 220 | } else { 221 | self.atXLimit = false 222 | } 223 | // vertical haptic handling 224 | if pctY == 1 || pctY == 0 { 225 | if !self.atYLimit { 226 | self.impactOccured() 227 | } 228 | self.atYLimit = true 229 | } else { 230 | self.atYLimit = false 231 | } 232 | // convert percentage to a value within the ranges provided 233 | let newX = pctX*(rangeX.upperBound-rangeX.lowerBound) + rangeX.lowerBound 234 | let newY = pctY*(rangeY.upperBound-rangeY.lowerBound) + rangeY.lowerBound 235 | self.value = CGPoint(x: newX, y: newY) 236 | } 237 | private func thumbOffset(_ proxy: GeometryProxy) -> CGSize { 238 | let w = proxy.size.width 239 | let h = proxy.size.height 240 | let pctX = (value.x - rangeX.lowerBound)/(rangeX.upperBound - rangeX.lowerBound) 241 | let pctY = (value.y - rangeY.lowerBound)/(rangeY.upperBound - rangeY.lowerBound) 242 | return CGSize(width: w*(pctX-0.5), height: h*(pctY-0.5)) 243 | } 244 | 245 | // MARK: Haptics 246 | private func impactOccured() { 247 | #if os(macOS) 248 | #else 249 | let generator = UIImpactFeedbackGenerator(style: .medium) 250 | generator.impactOccurred() 251 | #endif 252 | } 253 | // MARK: View 254 | public var body: some View { 255 | ZStack { 256 | style.makeTrack(configuration: configuration) 257 | GeometryReader { proxy in 258 | ZStack(alignment: .center) { 259 | self.style.makeThumb(configuration: self.configuration) 260 | .offset(self.thumbOffset(proxy)) 261 | .gesture( 262 | DragGesture(minimumDistance: 0, coordinateSpace: .named(self.space)) 263 | .onChanged({ 264 | self.constrainValue(proxy, $0.location) 265 | self.isActive = true 266 | }) 267 | .onEnded({ 268 | self.constrainValue(proxy, $0.location) 269 | self.isActive = false 270 | })) 271 | 272 | }.frame(width: proxy.size.width, height: proxy.size.height) 273 | } 274 | }.coordinateSpace(name: space) 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Tests/SlidersTests/SlidersTests.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Tests/SlidersTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SlidersTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /sliders-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kieranb662/Sliders-SwiftUI/f03607928dd641ecb6f9b4d244bf154d985a1295/sliders-logo.png --------------------------------------------------------------------------------