-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit cb95d92
Showing
6 changed files
with
195 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
8 changes: 8 additions & 0 deletions
8
.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"), | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
|
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.