├── .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 |
7 |
8 |
9 |
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 |
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+xml Thumb
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
--------------------------------------------------------------------------------