diff --git a/README.md b/README.md index 47739fa..76cc78e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ This package allows you to build highly customizable sliders and tracks for iOS, - Build your own sliders and tracks using composition - Highly customizable - Horizontal and Vertical styles -- Range and XY values +- Range sliders with minimum/maximum value distance +- XY sliders - Different sizes for lower and upper range thumbs
@@ -53,7 +54,7 @@ See the preview of each file to see an example Use any SwiftUI view modifiers to create custom tracks and thumbs. ```swift -RangeSlider(range: $model.range2) +RangeSlider(range: $model.range2, distance: 0.1 ... 1.0) .rangeSliderStyle( HorizontalRangeSliderStyle( track: @@ -84,4 +85,4 @@ RangeSlider(range: $model.range2) Feel free to contribute via fork/pull request to master branch. If you want to request a feature or report a bug please start a new issue. ## Coffee Contributions -If you find this project useful please consider becoming my GitHub sponsor. +If you find this project useful please consider becoming our GitHub sponsor. diff --git a/Sources/Sliders/Base/LinearRangeMath.swift b/Sources/Sliders/Base/LinearRangeMath.swift index f19d6b9..9456027 100644 --- a/Sources/Sliders/Base/LinearRangeMath.swift +++ b/Sources/Sliders/Base/LinearRangeMath.swift @@ -7,3 +7,27 @@ import SwiftUI let offsetUpperValue = distanceFrom(value: range.upperBound, availableDistance: overallLength, bounds: bounds, leadingOffset: upperStartOffset, trailingOffset: upperEndOffset) return max(0, offsetUpperValue - offsetLowerValue) } + +@inlinable func rangeFrom(updatedLowerBound: CGFloat, upperBound: CGFloat, bounds: ClosedRange, distance: ClosedRange, forceAdjacent: Bool) -> ClosedRange { + if forceAdjacent { + let finalLowerBound = min(updatedLowerBound, bounds.upperBound - distance.lowerBound) + let finalUpperBound = min(min(max(updatedLowerBound + distance.lowerBound, upperBound), updatedLowerBound + distance.upperBound), bounds.upperBound) + return finalLowerBound ... finalUpperBound + } else { + let finalLowerBound = min(updatedLowerBound, upperBound - distance.lowerBound) + let finalUpperBound = min(upperBound, updatedLowerBound + distance.upperBound) + return finalLowerBound ... finalUpperBound + } +} + +@inlinable func rangeFrom(lowerBound: CGFloat, updatedUpperBound: CGFloat, bounds: ClosedRange, distance: ClosedRange, forceAdjacent: Bool) -> ClosedRange { + if forceAdjacent { + let finalLowerBound = max(max(min(lowerBound, updatedUpperBound - distance.lowerBound), updatedUpperBound - distance.upperBound), bounds.lowerBound) + let finalUpperBound = max(updatedUpperBound, bounds.lowerBound + distance.lowerBound) + return finalLowerBound ... finalUpperBound + } else { + let finalLowerBound = max(lowerBound, updatedUpperBound - distance.upperBound) + let finalUpperBound = max(lowerBound + distance.lowerBound, updatedUpperBound) + return finalLowerBound ... finalUpperBound + } +} diff --git a/Sources/Sliders/PointSlider/PointSlider.swift b/Sources/Sliders/PointSlider/PointSlider.swift index 4260d01..ed6125b 100644 --- a/Sources/Sliders/PointSlider/PointSlider.swift +++ b/Sources/Sliders/PointSlider/PointSlider.swift @@ -38,7 +38,7 @@ extension PointSlider { } extension PointSlider { - public init(x: Binding, xBounds: ClosedRange = 0...1, xStep: V.Stride = 1, y: Binding, yBounds: ClosedRange = 0...1, yStep: V.Stride = 1, onEditingChanged: @escaping (Bool) -> Void = { _ in }) where V : BinaryInteger, V.Stride : BinaryInteger { + public init(x: Binding, xBounds: ClosedRange = 0...1, xStep: V.Stride = 1, y: Binding, yBounds: ClosedRange = 0...1, yStep: V.Stride = 1, onEditingChanged: @escaping (Bool) -> Void = { _ in }) where V : FixedWidthInteger, V.Stride : FixedWidthInteger { self.init( PointSliderStyleConfiguration( diff --git a/Sources/Sliders/RangeSlider/RangeSlider.swift b/Sources/Sliders/RangeSlider/RangeSlider.swift index 7a7aa45..7b72ee5 100644 --- a/Sources/Sliders/RangeSlider/RangeSlider.swift +++ b/Sources/Sliders/RangeSlider/RangeSlider.swift @@ -20,16 +20,22 @@ extension RangeSlider { } extension RangeSlider { - public init(range: 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( + range: Binding>, + in bounds: ClosedRange = 0.0...1.0, + step: V.Stride = 0.001, + distance: ClosedRange = 0.0 ... .infinity, + onEditingChanged: @escaping (Bool) -> Void = { _ in } + ) where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint { self.init( RangeSliderStyleConfiguration( range: Binding( - get: { CGFloat(range.wrappedValue.clamped(to: bounds).lowerBound)...CGFloat(range.wrappedValue.clamped(to: bounds).upperBound) }, - set: { range.wrappedValue = V($0.lowerBound)...V($0.upperBound) } + get: { CGFloat(range.wrappedValue.clamped(to: bounds).lowerBound) ... CGFloat(range.wrappedValue.clamped(to: bounds).upperBound) }, + set: { range.wrappedValue = V($0.lowerBound) ... V($0.upperBound) } ), - bounds: CGFloat(bounds.lowerBound)...CGFloat(bounds.upperBound), + bounds: CGFloat(bounds.lowerBound) ... CGFloat(bounds.upperBound), step: CGFloat(step), + distance: CGFloat(distance.lowerBound) ... CGFloat(distance.upperBound), onEditingChanged: onEditingChanged, dragOffset: .constant(0) ) @@ -38,16 +44,22 @@ extension RangeSlider { } extension RangeSlider { - public init(range: Binding>, in bounds: ClosedRange = 0...1, step: V.Stride = 1, onEditingChanged: @escaping (Bool) -> Void = { _ in }) where V : BinaryInteger, V.Stride : BinaryInteger { - + public init( + range: Binding>, + in bounds: ClosedRange = 0...1, + step: V.Stride = 1, + distance: ClosedRange = 0 ... .max, + onEditingChanged: @escaping (Bool) -> Void = { _ in } + ) where V : FixedWidthInteger, V.Stride : FixedWidthInteger { self.init( RangeSliderStyleConfiguration( range: Binding( - get: { CGFloat(range.wrappedValue.lowerBound)...CGFloat(range.wrappedValue.upperBound) }, - set: { range.wrappedValue = V($0.lowerBound)...V($0.upperBound) } + get: { CGFloat(range.wrappedValue.lowerBound) ... CGFloat(range.wrappedValue.upperBound) }, + set: { range.wrappedValue = V($0.lowerBound) ... V($0.upperBound) } ), - bounds: CGFloat(bounds.lowerBound)...CGFloat(bounds.upperBound), + bounds: CGFloat(bounds.lowerBound) ... CGFloat(bounds.upperBound), step: CGFloat(step), + distance: CGFloat(distance.lowerBound) ... CGFloat(distance.upperBound), onEditingChanged: onEditingChanged, dragOffset: .constant(0) ) @@ -59,7 +71,9 @@ struct RangeSlider_Previews: PreviewProvider { static var previews: some View { Group { HorizontalRangeSlidersPreview() + .previewDisplayName("Horizontal Range Sliders") VerticalRangeSlidersPreview() + .previewDisplayName("Vertical Range Sliders") } } } @@ -76,7 +90,7 @@ private struct HorizontalRangeSlidersPreview: View { VStack { RangeSlider(range: $range1) - RangeSlider(range: $range2) + RangeSlider(range: $range2, distance: 0.3 ... 1.0) .rangeSliderStyle( HorizontalRangeSliderStyle( track: @@ -180,7 +194,7 @@ private struct VerticalRangeSlidersPreview: View { VerticalRangeSliderStyle() ) - RangeSlider(range: $range2) + RangeSlider(range: $range2, distance: 0.5 ... 0.7) .rangeSliderStyle( VerticalRangeSliderStyle( track: diff --git a/Sources/Sliders/RangeSlider/Style/RangeSliderStyleConfiguration.swift b/Sources/Sliders/RangeSlider/Style/RangeSliderStyleConfiguration.swift index 7632655..639634c 100644 --- a/Sources/Sliders/RangeSlider/Style/RangeSliderStyleConfiguration.swift +++ b/Sources/Sliders/RangeSlider/Style/RangeSliderStyleConfiguration.swift @@ -4,6 +4,7 @@ public struct RangeSliderStyleConfiguration { public let range: Binding> public let bounds: ClosedRange public let step: CGFloat + public let distance: ClosedRange public let onEditingChanged: (Bool) -> Void public var dragOffset: Binding diff --git a/Sources/Sliders/RangeSlider/Styles/Horizontal/HorizontalRangeSliderStyle.swift b/Sources/Sliders/RangeSlider/Styles/Horizontal/HorizontalRangeSliderStyle.swift index d361625..5ceb961 100644 --- a/Sources/Sliders/RangeSlider/Styles/Horizontal/HorizontalRangeSliderStyle.swift +++ b/Sources/Sliders/RangeSlider/Styles/Horizontal/HorizontalRangeSliderStyle.swift @@ -13,8 +13,8 @@ public struct HorizontalRangeSliderStyle Void - let onSelectUpper: () -> Void + let onSelectLower: () -> Void + let onSelectUpper: () -> Void public func makeBody(configuration: Self.Configuration) -> some View { GeometryReader { geometry in @@ -46,7 +46,7 @@ public struct HorizontalRangeSliderStyle(value: Binding, in bounds: ClosedRange = 0...1, step: V.Stride = 1, onEditingChanged: @escaping (Bool) -> Void = { _ in }) where V : BinaryInteger, V.Stride : BinaryInteger { + 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) }, set: { value.wrappedValue = V($0) }), diff --git a/Tests/SlidersTests/RangeDistanceTests.swift b/Tests/SlidersTests/RangeDistanceTests.swift index 3b4997b..f84f8d2 100644 --- a/Tests/SlidersTests/RangeDistanceTests.swift +++ b/Tests/SlidersTests/RangeDistanceTests.swift @@ -36,4 +36,149 @@ class RangeDistanceTests: XCTestCase { let fullRangeDistance = rangeDistance(overallLength: 100, range: -2.0...2.0, bounds: -2.0...2.0, lowerStartOffset: 5, lowerEndOffset: 15, upperStartOffset: 5, upperEndOffset: 15) XCTAssert(fullRangeDistance == 80) } + + func testRangeUpdatingLowerBoundWithUnlimitedDistance() { + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.0, upperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: true), + 0.0 ... 1.0 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.5, upperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: true), + 0.5 ... 0.5 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.6, upperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: true), + 0.6 ... 0.6 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.0, upperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: false), + 0.0 ... 1.0 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.5, upperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: false), + 0.5 ... 0.5 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.6, upperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: false), + 0.5 ... 0.5 + ) + } + + func testRangeUpdatingUpperBoundWithUnlimitedDistance() { + XCTAssertEqual( + rangeFrom(lowerBound: 0.0, updatedUpperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: true), + 0.0 ... 1.0 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: true), + 0.5 ... 0.5 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.4, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: true), + 0.4 ... 0.4 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.0, updatedUpperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: false), + 0.0 ... 1.0 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: false), + 0.5 ... 0.5 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.4, bounds: 0.0 ... 1.0, distance: 0.0 ... 1.0, forceAdjacent: false), + 0.5 ... 0.5 + ) + } + + func testRangeUpdatingLowerBoundWithDistance() { + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.95, upperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true), + 0.9 ... 1.0 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.0, upperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true), + 0.0 ... 0.5 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.5, upperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true), + 0.5 ... 0.6 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.6, upperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true), + 0.6 ... 0.7 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.0, upperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: false), + 0.0 ... 0.5 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.5, upperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: false), + 0.4 ... 0.5 + ) + + XCTAssertEqual( + rangeFrom(updatedLowerBound: 0.6, upperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: false), + 0.4 ... 0.5 + ) + } + + func testRangeUpdatingUpperBoundWithDistance() { + XCTAssertEqual( + rangeFrom(lowerBound: 0.0, updatedUpperBound: 0.05, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true), + 0.0 ... 0.1 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.0, updatedUpperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true), + 0.5 ... 1.0 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true), + 0.4 ... 0.5 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.4, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true).lowerBound, + 0.3, + accuracy: 0.0001 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.4, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: true).upperBound, + 0.4, + accuracy: 0.0001 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.0, updatedUpperBound: 1.0, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: false), + 0.5 ... 1.0 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.5, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: false), + 0.5 ... 0.6 + ) + + XCTAssertEqual( + rangeFrom(lowerBound: 0.5, updatedUpperBound: 0.4, bounds: 0.0 ... 1.0, distance: 0.1 ... 0.5, forceAdjacent: false), + 0.5 ... 0.6 + ) + } }