diff --git a/Sources/Sliders/Base/PrecisionScrubbing.swift b/Sources/Sliders/Base/PrecisionScrubbing.swift new file mode 100644 index 0000000..1a467b7 --- /dev/null +++ b/Sources/Sliders/Base/PrecisionScrubbing.swift @@ -0,0 +1,47 @@ +import SwiftUI + +public struct PrecisionScrubbingConfig { + let scrubValue: (Float) -> Float + let onChange: ((Float?) -> Void)? +} + +struct PrecisionScrubbingKey: EnvironmentKey { + typealias Value = PrecisionScrubbingConfig + + static let defaultValue: PrecisionScrubbingConfig = PrecisionScrubbingConfig( + scrubValue: { _ in 1 }, + onChange: nil + ) +} + +extension EnvironmentValues { + var precisionScrubbing: PrecisionScrubbingConfig { + get { self[PrecisionScrubbingKey.self] } + set { self[PrecisionScrubbingKey.self] = newValue } + } +} + +public extension View { + func precisionScrubbing>( + _ scrubValue: @escaping (Float) -> ScrubValue, + onChange: ((ScrubValue?) -> Void)? = nil + ) -> some View { + self.environment( + \.precisionScrubbing, + PrecisionScrubbingConfig { offset in + scrubValue(offset).rawValue + } onChange: { value in + guard let value else { + onChange?(nil) + return + } + + if let value = ScrubValue(rawValue: value) { + onChange?(value) + } else { + print("Invalid conversion") + } + } + ) + } +} diff --git a/Sources/Sliders/Base/SliderGestureState.swift b/Sources/Sliders/Base/SliderGestureState.swift new file mode 100644 index 0000000..56dadd6 --- /dev/null +++ b/Sources/Sliders/Base/SliderGestureState.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftUI + +public struct SliderGestureState: Equatable { + var speed: Float? + private var lastOffset: CGFloat + private var accumulations: [Float:CGFloat] = [1: 0] + + var offset: CGFloat { + accumulations.reduce(0) { accum, tuple in + let (speed, value) = tuple + return accum + value / CGFloat(speed) + } + } + + init(initialOffset: CGFloat) { + self.lastOffset = initialOffset + } + + func updating(with offset: CGFloat, speed: Float) -> Self { + var mutSelf = self + + mutSelf.speed = speed + + var accumulations = self.accumulations.reduce([:]) { (accum: [Float:CGFloat], element) in + let (elementSpeed, elementValue) = element + + var out = accum + + let appliedSpeed = min(elementSpeed, speed) + out[appliedSpeed] = (out[appliedSpeed] ?? 0) + elementValue + + return out + } + accumulations[speed] = (accumulations[speed] ?? 0) + offset - lastOffset + mutSelf.accumulations = accumulations + + mutSelf.lastOffset = offset + + return mutSelf + } +} diff --git a/Sources/Sliders/PointSlider/Styles/Rectangular/RectangularPointSliderStyle.swift b/Sources/Sliders/PointSlider/Styles/Rectangular/RectangularPointSliderStyle.swift index 6b0e9e2..bb1fa57 100644 --- a/Sources/Sliders/PointSlider/Styles/Rectangular/RectangularPointSliderStyle.swift +++ b/Sources/Sliders/PointSlider/Styles/Rectangular/RectangularPointSliderStyle.swift @@ -79,7 +79,7 @@ public struct RectangularPointSliderStyle: PointSlider ) ) .gesture( - DragGesture() + DragGesture(minimumDistance: 0) .onChanged { gestureValue in configuration.onEditingChanged(true) diff --git a/Sources/Sliders/RangeSlider/RangeSlider.swift b/Sources/Sliders/RangeSlider/RangeSlider.swift index a49eac1..0403464 100644 --- a/Sources/Sliders/RangeSlider/RangeSlider.swift +++ b/Sources/Sliders/RangeSlider/RangeSlider.swift @@ -3,12 +3,20 @@ import SwiftUI public struct RangeSlider: View { @Environment(\.rangeSliderStyle) private var style @State private var dragOffset: CGFloat? + @Environment(\.precisionScrubbing) private var precisionScrubbing + @GestureState private var lowerGestureState: SliderGestureState? + @GestureState private var upperGestureState: SliderGestureState? private var configuration: RangeSliderStyleConfiguration public var body: some View { self.style.makeBody(configuration: - self.configuration.with(dragOffset: self.$dragOffset) + self.configuration.with( + precisionScrubbing: self.precisionScrubbing, + dragOffset: self.$dragOffset, + lowerGestureState: self.$lowerGestureState, + upperGestureState: self.$upperGestureState + ) ) } } @@ -37,7 +45,10 @@ extension RangeSlider { step: CGFloat(step), distance: CGFloat(distance.lowerBound) ... CGFloat(distance.upperBound), onEditingChanged: onEditingChanged, - dragOffset: .constant(0) + precisionScrubbing: PrecisionScrubbingKey.defaultValue, + dragOffset: .constant(0), + lowerGestureState: .init(initialValue: nil), + upperGestureState: .init(initialValue: nil) ) ) } @@ -61,7 +72,10 @@ extension RangeSlider { step: CGFloat(step), distance: CGFloat(distance.lowerBound) ... CGFloat(distance.upperBound), onEditingChanged: onEditingChanged, - dragOffset: .constant(0) + precisionScrubbing: PrecisionScrubbingKey.defaultValue, + dragOffset: .constant(0), + lowerGestureState: .init(initialValue: nil), + upperGestureState: .init(initialValue: nil) ) ) } diff --git a/Sources/Sliders/RangeSlider/Style/RangeSliderStyleConfiguration.swift b/Sources/Sliders/RangeSlider/Style/RangeSliderStyleConfiguration.swift index 639634c..0e10dde 100644 --- a/Sources/Sliders/RangeSlider/Style/RangeSliderStyleConfiguration.swift +++ b/Sources/Sliders/RangeSlider/Style/RangeSliderStyleConfiguration.swift @@ -6,11 +6,17 @@ public struct RangeSliderStyleConfiguration { public let step: CGFloat public let distance: ClosedRange public let onEditingChanged: (Bool) -> Void + public var precisionScrubbing: PrecisionScrubbingConfig public var dragOffset: Binding + public var lowerGestureState: GestureState + public var upperGestureState: GestureState - func with(dragOffset: Binding) -> Self { + func with(precisionScrubbing: PrecisionScrubbingConfig, dragOffset: Binding, lowerGestureState: GestureState, upperGestureState: GestureState) -> Self { var mutSelf = self + mutSelf.precisionScrubbing = precisionScrubbing mutSelf.dragOffset = dragOffset + mutSelf.lowerGestureState = lowerGestureState + mutSelf.upperGestureState = upperGestureState return mutSelf } } diff --git a/Sources/Sliders/RangeSlider/Styles/Horizontal/HorizontalRangeSliderStyle.swift b/Sources/Sliders/RangeSlider/Styles/Horizontal/HorizontalRangeSliderStyle.swift index 5ceb961..1d6707d 100644 --- a/Sources/Sliders/RangeSlider/Styles/Horizontal/HorizontalRangeSliderStyle.swift +++ b/Sources/Sliders/RangeSlider/Styles/Horizontal/HorizontalRangeSliderStyle.swift @@ -16,8 +16,40 @@ public struct HorizontalRangeSliderStyle Void let onSelectUpper: () -> Void + private func lowerX(configuration: Self.Configuration, geometry: GeometryProxy) -> CGFloat { + distanceFrom( + value: configuration.range.wrappedValue.lowerBound, + availableDistance: geometry.size.width - upperThumbSize.width, + bounds: configuration.bounds, + leadingOffset: lowerThumbSize.width / 2, + trailingOffset: lowerThumbSize.width / 2 + ) + } + + private func upperX(configuration: Self.Configuration, geometry: GeometryProxy) -> CGFloat { + distanceFrom( + value: configuration.range.wrappedValue.upperBound, + availableDistance: geometry.size.width, + bounds: configuration.bounds, + leadingOffset: lowerThumbSize.width + upperThumbSize.width / 2, + trailingOffset: upperThumbSize.width / 2 + ) + } + public func makeBody(configuration: Self.Configuration) -> some View { - GeometryReader { geometry in + let editing = configuration.lowerGestureState.wrappedValue != nil || configuration.upperGestureState.wrappedValue != nil + let precisionScrubbingSpeed = { () -> Float? in + switch ( + configuration.lowerGestureState.wrappedValue?.speed, + configuration.upperGestureState.wrappedValue?.speed + ) { + case (let lower?, let upper?): return min(lower, upper) + case (let only?, nil), (nil, let only?): return only + case (nil, nil): return nil + } + }() + + return GeometryReader { geometry in ZStack { self.track .environment(\.trackRange, configuration.range.wrappedValue) @@ -36,56 +68,26 @@ public struct HorizontalRangeSliderStyle SliderGestureState in + let x = upperX(configuration: configuration, geometry: geometry) + return SliderGestureState(initialOffset: value.location.x - x) + }()).updating( + with: value.location.x, + speed: configuration.precisionScrubbing.scrubValue(Float(value.translation.height)) ) + }, + TapGesture() + .onEnded { _ in + self.onSelectUpper() } - - let computedUpperBound = valueFrom( - distance: gestureValue.location.x - (configuration.dragOffset.wrappedValue ?? 0), - availableDistance: geometry.size.width, - bounds: configuration.bounds, - step: configuration.step, - leadingOffset: self.lowerThumbSize.width + self.upperThumbSize.width / 2, - trailingOffset: self.upperThumbSize.width / 2 - ) - - configuration.range.wrappedValue = rangeFrom( - lowerBound: configuration.range.wrappedValue.lowerBound, - updatedUpperBound: computedUpperBound, - bounds: configuration.bounds, - distance: configuration.distance, - forceAdjacent: options.contains(.forceAdjacentValue) - ) - } - .onEnded { _ in - configuration.dragOffset.wrappedValue = nil - configuration.onEditingChanged(false) - } + ) ) } .frame(height: geometry.size.height) + .onChange(of: configuration.lowerGestureState.wrappedValue) { state in + guard let state else { return } + + let computedLowerBound = valueFrom( + distance: state.offset - (configuration.dragOffset.wrappedValue ?? 0), + availableDistance: geometry.size.width, + bounds: configuration.bounds, + step: configuration.step, + leadingOffset: lowerThumbSize.width / 2, + trailingOffset: lowerThumbSize.width / 2 + ) + + configuration.range.wrappedValue = rangeFrom( + updatedLowerBound: computedLowerBound, + upperBound: configuration.range.wrappedValue.upperBound, + bounds: configuration.bounds, + distance: configuration.distance, + forceAdjacent: options.contains(.forceAdjacentValue) + ) + } + .onChange(of: configuration.upperGestureState.wrappedValue) { state in + guard let state else { return } + + let computedUpperBound = valueFrom( + distance: state.offset - (configuration.dragOffset.wrappedValue ?? 0), + availableDistance: geometry.size.width, + bounds: configuration.bounds, + step: configuration.step, + leadingOffset: lowerThumbSize.width + upperThumbSize.width / 2, + trailingOffset: upperThumbSize.width / 2 + ) + + configuration.range.wrappedValue = rangeFrom( + lowerBound: configuration.range.wrappedValue.lowerBound, + updatedUpperBound: computedUpperBound, + bounds: configuration.bounds, + distance: configuration.distance, + forceAdjacent: options.contains(.forceAdjacentValue) + ) + } + .onChange(of: editing) { editing in + configuration.onEditingChanged(editing) + } + .onChange(of: precisionScrubbingSpeed) { precisionScrubbingSpeed in + configuration.precisionScrubbing.onChange?(precisionScrubbingSpeed) + } } .frame(minHeight: max(self.lowerThumbInteractiveSize.height, self.upperThumbInteractiveSize.height)) } diff --git a/Sources/Sliders/RangeSlider/Styles/Vertical/VerticalRangeSliderStyle.swift b/Sources/Sliders/RangeSlider/Styles/Vertical/VerticalRangeSliderStyle.swift index 96da8be..f1aa30f 100644 --- a/Sources/Sliders/RangeSlider/Styles/Vertical/VerticalRangeSliderStyle.swift +++ b/Sources/Sliders/RangeSlider/Styles/Vertical/VerticalRangeSliderStyle.swift @@ -43,7 +43,7 @@ public struct VerticalRangeSliderStyle public let step: CGFloat public let onEditingChanged: (Bool) -> Void + public var precisionScrubbing: PrecisionScrubbingConfig public var dragOffset: Binding + public var thumbGestureState: GestureState + public var trackGestureState: GestureState - public init(value: Binding, bounds: ClosedRange, step: CGFloat, onEditingChanged: @escaping (Bool) -> Void, dragOffset: Binding) { + public init( + value: Binding, + bounds: ClosedRange, + step: CGFloat, + onEditingChanged: @escaping (Bool) -> Void, + precisionScrubbing: PrecisionScrubbingConfig, + dragOffset: Binding, + thumbGestureState: GestureState, + trackGestureState: GestureState + ) { self.value = value self.bounds = bounds self.step = step self.onEditingChanged = onEditingChanged + self.precisionScrubbing = precisionScrubbing self.dragOffset = dragOffset + self.thumbGestureState = thumbGestureState + self.trackGestureState = trackGestureState } - func with(dragOffset: Binding) -> Self { + func with( + precisionScrubbing: PrecisionScrubbingConfig, + dragOffset: Binding, + thumbGestureState: GestureState, + trackGestureState: GestureState + ) -> Self { var mutSelf = self + mutSelf.precisionScrubbing = precisionScrubbing mutSelf.dragOffset = dragOffset + mutSelf.thumbGestureState = thumbGestureState + mutSelf.trackGestureState = trackGestureState return mutSelf } } diff --git a/Sources/Sliders/ValueSlider/Styles/Horizontal/HorizontalValueSliderStyle.swift b/Sources/Sliders/ValueSlider/Styles/Horizontal/HorizontalValueSliderStyle.swift index 5eb9d61..ad82959 100644 --- a/Sources/Sliders/ValueSlider/Styles/Horizontal/HorizontalValueSliderStyle.swift +++ b/Sources/Sliders/ValueSlider/Styles/Horizontal/HorizontalValueSliderStyle.swift @@ -7,7 +7,18 @@ public struct HorizontalValueSliderStyle: ValueSliderS private let thumbInteractiveSize: CGSize private let options: ValueSliderOptions + private func x(configuration: Self.Configuration, geometry: GeometryProxy) -> CGFloat { + distanceFrom( + value: configuration.value.wrappedValue, + availableDistance: geometry.size.width, + bounds: configuration.bounds, + leadingOffset: thumbSize.width / 2, + trailingOffset: thumbSize.width / 2 + ) + } + public func makeBody(configuration: Self.Configuration) -> some View { + let prominentGesture = configuration.thumbGestureState.wrappedValue ?? configuration.trackGestureState.wrappedValue let track = self.track .environment(\.trackValue, configuration.value.wrappedValue) .environment(\.valueTrackConfiguration, ValueTrackConfiguration( @@ -22,20 +33,11 @@ public struct HorizontalValueSliderStyle: ValueSliderS if self.options.contains(.interactiveTrack) { track.gesture( DragGesture(minimumDistance: 0) - .onChanged { gestureValue in - configuration.onEditingChanged(true) - let computedValue = valueFrom( - distance: gestureValue.location.x, - availableDistance: geometry.size.width, - bounds: configuration.bounds, - step: configuration.step, - leadingOffset: self.thumbSize.width / 2, - trailingOffset: self.thumbSize.width / 2 + .updating(configuration.trackGestureState) { value, state, transaction in + state = (state ?? SliderGestureState(initialOffset: 0)).updating( + with: value.location.x, + speed: configuration.precisionScrubbing.scrubValue(Float(value.translation.height)) ) - configuration.value.wrappedValue = computedValue - } - .onEnded { _ in - configuration.onEditingChanged(false) } ) } else { @@ -48,48 +50,41 @@ public struct HorizontalValueSliderStyle: ValueSliderS } .frame(minWidth: self.thumbInteractiveSize.width, minHeight: self.thumbInteractiveSize.height) .position( - x: distanceFrom( - value: configuration.value.wrappedValue, - availableDistance: geometry.size.width, - bounds: configuration.bounds, - leadingOffset: self.thumbSize.width / 2, - trailingOffset: self.thumbSize.width / 2 - ), + x: x(configuration: configuration, geometry: geometry), y: geometry.size.height / 2 ) .gesture( DragGesture(minimumDistance: 0) - .onChanged { gestureValue in - configuration.onEditingChanged(true) - - if configuration.dragOffset.wrappedValue == nil { - configuration.dragOffset.wrappedValue = gestureValue.startLocation.x - distanceFrom( - value: configuration.value.wrappedValue, - availableDistance: geometry.size.width, - bounds: configuration.bounds, - leadingOffset: self.thumbSize.width / 2, - trailingOffset: self.thumbSize.width / 2 - ) - } - - let computedValue = valueFrom( - distance: gestureValue.location.x - (configuration.dragOffset.wrappedValue ?? 0), - availableDistance: geometry.size.width, - bounds: configuration.bounds, - step: configuration.step, - leadingOffset: self.thumbSize.width / 2, - trailingOffset: self.thumbSize.width / 2 + .updating(configuration.thumbGestureState) { value, state, transaction in + state = (state ?? { + let x = x(configuration: configuration, geometry: geometry) + return SliderGestureState(initialOffset: value.location.x - x) + }()).updating( + with: value.location.x, + speed: configuration.precisionScrubbing.scrubValue(Float(value.translation.height)) ) - - configuration.value.wrappedValue = computedValue - } - .onEnded { _ in - configuration.dragOffset.wrappedValue = nil - configuration.onEditingChanged(false) } ) } .frame(height: geometry.size.height) + .onChange(of: prominentGesture) { state in + guard let state else { return } + + configuration.value.wrappedValue = valueFrom( + distance: state.offset - (configuration.dragOffset.wrappedValue ?? 0), + availableDistance: geometry.size.width, + bounds: configuration.bounds, + step: configuration.step, + leadingOffset: self.thumbSize.width / 2, + trailingOffset: self.thumbSize.width / 2 + ) + } + .onChange(of: prominentGesture != nil) { editing in + configuration.onEditingChanged(editing) + } + .onChange(of: prominentGesture?.speed) { speed in + configuration.precisionScrubbing.onChange?(speed) + } } .frame(minHeight: self.thumbInteractiveSize.height) } diff --git a/Sources/Sliders/ValueSlider/ValueSlider.swift b/Sources/Sliders/ValueSlider/ValueSlider.swift index d7b4a3f..fda558e 100644 --- a/Sources/Sliders/ValueSlider/ValueSlider.swift +++ b/Sources/Sliders/ValueSlider/ValueSlider.swift @@ -2,13 +2,21 @@ import SwiftUI public struct ValueSlider: View { @Environment(\.valueSliderStyle) private var style + @Environment(\.precisionScrubbing) private var precisionScrubbing @State private var dragOffset: CGFloat? + @GestureState private var thumbGestureState: SliderGestureState? + @GestureState private var trackGestureState: SliderGestureState? private var configuration: ValueSliderStyleConfiguration public var body: some View { self.style.makeBody(configuration: - self.configuration.with(dragOffset: self.$dragOffset) + self.configuration.with( + precisionScrubbing: self.precisionScrubbing, + dragOffset: self.$dragOffset, + thumbGestureState: self.$thumbGestureState, + trackGestureState: self.$trackGestureState + ) ) } } @@ -20,37 +28,56 @@ extension ValueSlider { } extension ValueSlider { - public init(value: Binding, in bounds: ClosedRange = 0.0...1.0, step: V.Stride = 0.001, onEditingChanged: @escaping (Bool) -> Void = { _ in }) where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint { - + public init( + value: Binding, + in bounds: ClosedRange = 0.0...1.0, + step: V.Stride = 0.001, + onEditingChanged: @escaping (Bool) -> Void = { _ in } + ) where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint { self.init( ValueSliderStyleConfiguration( value: Binding(get: { CGFloat(value.wrappedValue.clamped(to: bounds)) }, set: { value.wrappedValue = V($0) }), bounds: CGFloat(bounds.lowerBound)...CGFloat(bounds.upperBound), step: CGFloat(step), onEditingChanged: onEditingChanged, - dragOffset: .constant(0) + precisionScrubbing: PrecisionScrubbingKey.defaultValue, + dragOffset: .constant(0), + thumbGestureState: .init(initialValue: nil), + trackGestureState: .init(initialValue: nil) ) ) } } extension ValueSlider { - public init(value: Binding, in bounds: ClosedRange = 0...1, step: V.Stride = 1, onEditingChanged: @escaping (Bool) -> Void = { _ in }) where V : FixedWidthInteger, V.Stride : FixedWidthInteger { + public init( + value: Binding, + in bounds: ClosedRange = 0...1, + step: V.Stride = 1, + onEditingChanged: @escaping (Bool) -> Void = { _ in } + ) where V : FixedWidthInteger, V.Stride : FixedWidthInteger { self.init( ValueSliderStyleConfiguration( value: Binding(get: { CGFloat(value.wrappedValue.clamped(to: bounds)) }, set: { value.wrappedValue = V($0) }), bounds: CGFloat(bounds.lowerBound)...CGFloat(bounds.upperBound), step: CGFloat(step), onEditingChanged: onEditingChanged, - dragOffset: .constant(0) + precisionScrubbing: PrecisionScrubbingKey.defaultValue, + dragOffset: .constant(0), + thumbGestureState: .init(initialValue: nil), + trackGestureState: .init(initialValue: nil) ) ) } } extension ValueSlider { - public init(value: Binding>, in bounds: ClosedRange>, step: Measurement, onEditingChanged: @escaping (Bool) -> Void = { _ in }) { - + public init( + value: Binding>, + in bounds: ClosedRange>, + step: Measurement, + onEditingChanged: @escaping (Bool) -> Void = { _ in } + ) { self.init( ValueSliderStyleConfiguration( value: Binding(get: { @@ -62,7 +89,10 @@ extension ValueSlider { bounds: CGFloat(bounds.lowerBound.value)...CGFloat(bounds.upperBound.value), step: CGFloat(step.value), onEditingChanged: onEditingChanged, - dragOffset: .constant(0) + precisionScrubbing: PrecisionScrubbingKey.defaultValue, + dragOffset: .constant(0), + thumbGestureState: .init(initialValue: nil), + trackGestureState: .init(initialValue: nil) ) ) }