Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
christianselig committed Feb 16, 2024
0 parents commit cb95d92
Show file tree
Hide file tree
Showing 6 changed files with 195 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
24 changes: 24 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "JunoUI",
platforms: [
.visionOS(.v1),
.iOS(.v17),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "JunoUI",
targets: ["JunoUI"]),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "JunoUI"),
]
)
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# JunoSlider

![Two JunoSliders vertically stacked, the first narrower than the second, being slid and updating corresponding Text views](slider.gif)

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`.

It's built in SwiftUI and customizable, with both the collapsed and expanded height being values you can change.

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.

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.

## Example Usage

```swift
import JunoUI

struct ContentView: View {
@State var sliderValue: CGFloat = 0.5
@State var isSliderActive = false

var body: some View {
JunoSlider(sliderValue: $sliderValue, maxSliderValue: 1.0, baseHeight: 10.0, expandedHeight: 22.0, label: "Video volume") { editingChanged in
isSliderActive = editingChanged
}
}
}
```
127 changes: 127 additions & 0 deletions Sources/JunoUI/JunoSlider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import SwiftUI

/// A slider that expands on selection.
public struct JunoSlider: View {
@Binding var sliderValue: CGFloat
let maxSliderValue: CGFloat
let baseHeight: CGFloat
let expandedHeight: CGFloat
let label: String
let editingChanged: ((Bool) -> Void)?

@State private var isGestureActive: Bool = false
@State private var startingSliderValue: CGFloat?
@State private var sliderWidth = 10.0 // Just an initial value to prevent division by 0
@State private var isAtTrackExtremity = false

/// Create a slider that expands on selection.
/// - Parameters:
/// - sliderValue: Binding for the current value of the slider
/// - maxSliderValue: The highest value the slider can be
/// - baseHeight: The slider's height when not expanded
/// - expandedHeight: The slider's height when selected (thus expanded)
/// - label: A string to describe what the data the slider represents
/// - editingChanged: An optional block that is called when the slider updates to sliding and when it stops
public init(sliderValue: Binding<CGFloat>, maxSliderValue: CGFloat, baseHeight: CGFloat = 9.0, expandedHeight: CGFloat = 20.0, label: String, editingChanged: ((Bool) -> Void)? = nil) {
self._sliderValue = sliderValue
self.maxSliderValue = maxSliderValue
self.baseHeight = baseHeight
self.expandedHeight = expandedHeight
self.label = label
self.editingChanged = editingChanged
}

public var body: some View {
ZStack {
// 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.
Color.orange.opacity(0.0001)
.frame(height: 40.0)

Capsule()
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
sliderWidth = proxy.size.width
}
}
}
.frame(height: isGestureActive ? expandedHeight : baseHeight)
.foregroundStyle(
Color(white: 0.1, opacity: 0.5)
.shadow(.inner(color: .black.opacity(0.3), radius: 3.0, y: 2.0))
)
.shadow(color: .white.opacity(0.2), radius: 1, y: 1)
.overlay(alignment: .leading) {
Capsule()
.overlay(alignment: .trailing) {
Circle()
.foregroundStyle(Color.white)
.shadow(radius: 1.0)
.padding(innerCirclePadding)
.opacity(isGestureActive ? 1.0 : 0.0)
}
.foregroundStyle(Color(white: isGestureActive ? 0.85 : 1.0))
.frame(width: calculateProgressWidth(), height: isGestureActive ? expandedHeight : baseHeight)
}
.clipShape(.capsule) // Best attempt at fixing a bug https://twitter.com/ChristianSelig/status/1757139789457829902
.contentShape(.hoverEffect, .capsule)
}
.gesture(DragGesture(minimumDistance: 0.0)
.onChanged { value in
if startingSliderValue == nil {
startingSliderValue = sliderValue
isGestureActive = true
editingChanged?(true)
}

let percentagePointsIncreased = value.translation.width / sliderWidth
let initialPercentage = (startingSliderValue ?? sliderValue) / maxSliderValue
let newPercentage = min(1.0, max(0.0, initialPercentage + percentagePointsIncreased))
sliderValue = newPercentage * maxSliderValue

if newPercentage == 0.0 && !isAtTrackExtremity {
isAtTrackExtremity = true
} else if newPercentage == 1.0 && !isAtTrackExtremity {
isAtTrackExtremity = true
} else if newPercentage > 0.0 && newPercentage < 1.0 {
isAtTrackExtremity = false
}
}
.onEnded { value in
// Check if they just tapped somewhere on the bar rather than actually dragging, in which case update the progress to the position they tapped
if value.translation.width == 0.0 {
let newPercentage = value.location.x / sliderWidth

withAnimation {
sliderValue = newPercentage * maxSliderValue
}
}

startingSliderValue = nil
isGestureActive = false
editingChanged?(false)
}
)
.hoverEffect(.highlight)
.animation(.default, value: isGestureActive)
.accessibilityRepresentation {
Slider(value: $sliderValue, in: 0.0 ... maxSliderValue, label: {
Text(label)
}, onEditingChanged: { editingChanged in
self.editingChanged?(editingChanged)
})
}
}

private var innerCirclePadding: CGFloat { expandedHeight * 0.15 }

private func calculateProgressWidth() -> CGFloat {
let minimumWidth = isGestureActive ? expandedHeight : baseHeight
let calculatedWidth = (sliderValue / maxSliderValue) * sliderWidth

// Don't let the bar get so small that it disappears
return max(minimumWidth, calculatedWidth)
}
}

Binary file added slider.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit cb95d92

Please sign in to comment.