├── .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 | ![Two JunoSliders vertically stacked, the first narrower than the second, being slid and updating corresponding Text views](slider.gif) 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 --------------------------------------------------------------------------------