├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── JunoUI
│ └── JunoSlider.swift
└── slider.gif
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.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) 2024 Christian Selig
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.9
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: "JunoUI",
8 | platforms: [
9 | .visionOS(.v1),
10 | .iOS(.v17),
11 | ],
12 | products: [
13 | // Products define the executables and libraries a package produces, making them visible to other packages.
14 | .library(
15 | name: "JunoUI",
16 | targets: ["JunoUI"]),
17 | ],
18 | targets: [
19 | // Targets are the basic building blocks of a package, defining a module or a test suite.
20 | // Targets can depend on other targets in this package and products from dependencies.
21 | .target(
22 | name: "JunoUI"),
23 | ]
24 | )
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # JunoSlider
2 |
3 | 
4 |
5 | JunoSlider is a custom slider for visionOS (probably works with iOS fine, though!) to mimic the style of Apple's expanding sliders in views like `AVPlayer` in instances where you're unable to use `AVPlayer`.
6 |
7 | It's built in SwiftUI and customizable, with both the collapsed and expanded height being values you can change.
8 |
9 | Apple's built-in `Slider` control may be a better fit for a lot of cases especially those where you don't require the expansion effect. The built-in `Slider` *can* animate its `controlSize` property, but the animation is a little weird on visionOS. Also, the height is not super customizable, even `.mini` with `Slider` does not allow you to get as narrow as Apple's `AVPlayer` slider, for instance.
10 |
11 | Big thanks to Matthew Skiles and Ed Sanchez for helping me with the inner and drop shadows on the control. Also thank you to kind Twitter and Mastodon folks for helping me [debug an animation issue with this control](https://mastodon.social/@christianselig/111920403265826138), it *seems* like a SwiftUI bug and the idea of just cropping out the animation jump seemed to be the best tradeoff.
12 |
13 | ## Example Usage
14 |
15 | ```swift
16 | import JunoUI
17 |
18 | struct ContentView: View {
19 | @State var sliderValue: CGFloat = 0.5
20 | @State var isSliderActive = false
21 |
22 | var body: some View {
23 | JunoSlider(sliderValue: $sliderValue, maxSliderValue: 1.0, baseHeight: 10.0, expandedHeight: 22.0, label: "Video volume") { editingChanged in
24 | isSliderActive = editingChanged
25 | }
26 | }
27 | }
28 | ```
29 |
--------------------------------------------------------------------------------
/Sources/JunoUI/JunoSlider.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A slider that expands on selection.
4 | public struct JunoSlider: View {
5 | @Binding var sliderValue: CGFloat
6 | let maxSliderValue: CGFloat
7 | let baseHeight: CGFloat
8 | let expandedHeight: CGFloat
9 | let label: String
10 | let editingChanged: ((Bool) -> Void)?
11 |
12 | @State private var isGestureActive: Bool = false
13 | @State private var startingSliderValue: CGFloat?
14 | @State private var sliderWidth = 10.0 // Just an initial value to prevent division by 0
15 | @State private var isAtTrackExtremity = false
16 |
17 | /// Create a slider that expands on selection.
18 | /// - Parameters:
19 | /// - sliderValue: Binding for the current value of the slider
20 | /// - maxSliderValue: The highest value the slider can be
21 | /// - baseHeight: The slider's height when not expanded
22 | /// - expandedHeight: The slider's height when selected (thus expanded)
23 | /// - label: A string to describe what the data the slider represents
24 | /// - editingChanged: An optional block that is called when the slider updates to sliding and when it stops
25 | public init(sliderValue: Binding, maxSliderValue: CGFloat, baseHeight: CGFloat = 9.0, expandedHeight: CGFloat = 20.0, label: String, editingChanged: ((Bool) -> Void)? = nil) {
26 | self._sliderValue = sliderValue
27 | self.maxSliderValue = maxSliderValue
28 | self.baseHeight = baseHeight
29 | self.expandedHeight = expandedHeight
30 | self.label = label
31 | self.editingChanged = editingChanged
32 | }
33 |
34 | public var body: some View {
35 | ZStack {
36 | // visionOS (on device) does not like when drag targets are smaller than 40pt tall, so add an almost-transparent (as it still needs to be interactive) that enforces an effective minimum height. If the slider is tall than this on its own it's essentially just ignored.
37 | Color.orange.opacity(0.0001)
38 | .frame(height: 40.0)
39 |
40 | Capsule()
41 | .background {
42 | GeometryReader { proxy in
43 | Color.clear
44 | .onAppear {
45 | sliderWidth = proxy.size.width
46 | }
47 | }
48 | }
49 | .frame(height: isGestureActive ? expandedHeight : baseHeight)
50 | .foregroundStyle(
51 | Color(white: 0.1, opacity: 0.5)
52 | .shadow(.inner(color: .black.opacity(0.3), radius: 3.0, y: 2.0))
53 | )
54 | .shadow(color: .white.opacity(0.2), radius: 1, y: 1)
55 | .overlay(alignment: .leading) {
56 | Capsule()
57 | .overlay(alignment: .trailing) {
58 | Circle()
59 | .foregroundStyle(Color.white)
60 | .shadow(radius: 1.0)
61 | .padding(innerCirclePadding)
62 | .opacity(isGestureActive ? 1.0 : 0.0)
63 | }
64 | .foregroundStyle(Color(white: isGestureActive ? 0.85 : 1.0))
65 | .frame(width: calculateProgressWidth(), height: isGestureActive ? expandedHeight : baseHeight)
66 | }
67 | .clipShape(.capsule) // Best attempt at fixing a bug https://twitter.com/ChristianSelig/status/1757139789457829902
68 | .contentShape(.hoverEffect, .capsule)
69 | }
70 | .gesture(DragGesture(minimumDistance: 0.0)
71 | .onChanged { value in
72 | if startingSliderValue == nil {
73 | startingSliderValue = sliderValue
74 | isGestureActive = true
75 | editingChanged?(true)
76 | }
77 |
78 | let percentagePointsIncreased = value.translation.width / sliderWidth
79 | let initialPercentage = (startingSliderValue ?? sliderValue) / maxSliderValue
80 | let newPercentage = min(1.0, max(0.0, initialPercentage + percentagePointsIncreased))
81 | sliderValue = newPercentage * maxSliderValue
82 |
83 | if newPercentage == 0.0 && !isAtTrackExtremity {
84 | isAtTrackExtremity = true
85 | } else if newPercentage == 1.0 && !isAtTrackExtremity {
86 | isAtTrackExtremity = true
87 | } else if newPercentage > 0.0 && newPercentage < 1.0 {
88 | isAtTrackExtremity = false
89 | }
90 | }
91 | .onEnded { value in
92 | // Check if they just tapped somewhere on the bar rather than actually dragging, in which case update the progress to the position they tapped
93 | if value.translation.width == 0.0 {
94 | let newPercentage = value.location.x / sliderWidth
95 |
96 | withAnimation {
97 | sliderValue = newPercentage * maxSliderValue
98 | }
99 | }
100 |
101 | startingSliderValue = nil
102 | isGestureActive = false
103 | editingChanged?(false)
104 | }
105 | )
106 | .hoverEffect(.highlight)
107 | .animation(.default, value: isGestureActive)
108 | .accessibilityRepresentation {
109 | Slider(value: $sliderValue, in: 0.0 ... maxSliderValue, label: {
110 | Text(label)
111 | }, onEditingChanged: { editingChanged in
112 | self.editingChanged?(editingChanged)
113 | })
114 | }
115 | }
116 |
117 | private var innerCirclePadding: CGFloat { expandedHeight * 0.15 }
118 |
119 | private func calculateProgressWidth() -> CGFloat {
120 | let minimumWidth = isGestureActive ? expandedHeight : baseHeight
121 | let calculatedWidth = (sliderValue / maxSliderValue) * sliderWidth
122 |
123 | // Don't let the bar get so small that it disappears
124 | return max(minimumWidth, calculatedWidth)
125 | }
126 | }
127 |
128 |
--------------------------------------------------------------------------------
/slider.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/christianselig/JunoSlider/c7c9967c0107b3753bf45f23babba7f94d82a684/slider.gif
--------------------------------------------------------------------------------