Skip to content

Commit

Permalink
BPKSlider setups (#1640)
Browse files Browse the repository at this point in the history
* BPKSlider setups

* some improvements

* separate into files

* slider component

* upsies
  • Loading branch information
frugoman committed Apr 18, 2023
1 parent ab8100a commit 279bdab
Show file tree
Hide file tree
Showing 21 changed files with 836 additions and 9 deletions.
2 changes: 0 additions & 2 deletions Backpack-SwiftUI/ExampleComponent/README.md

This file was deleted.

232 changes: 232 additions & 0 deletions Backpack-SwiftUI/Slider/Classes/BPKRangeSlider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018-2022 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

/// A view that displays a horizontal slider with two thumbs.
public struct BPKRangeSlider: View {
@Binding private var selectedRange: ClosedRange<Float>
private let sliderBounds: ClosedRange<Float>
private let step: Float
private let minSpacing: Float

private let sliderHeight: CGFloat = 4
private let thumbSize: CGFloat = 20
private var trailingAccessibilityLabel = ""
private var leadingAccessibilityLabel = ""

/// Creates a new instance of `BPKRangeSlider`.
///
/// If the selected range is outside the bounds of the slider, it will be clamped to the bounds.
///
/// - Parameters:
/// - selectedRange: Binding of the selected range of the slider.
/// - sliderBounds: The bounds of the slider.
/// - step: The step size of the slider. Defaults to 1.
/// - minSpacing: The minimum spacing between the two thumbs. Defaults to 0.
public init(
selectedRange: Binding<ClosedRange<Float>>,
sliderBounds: ClosedRange<Float>,
step: Float = 1,
minSpacing: Float = 0
) {
self._selectedRange = selectedRange
self.sliderBounds = sliderBounds
self.step = step
self.minSpacing = minSpacing
}

public var body: some View {
GeometryReader { geomentry in
sliderView(sliderSize: geomentry.size)
}
.fixedSize(horizontal: false, vertical: true)
.frame(height: thumbSize)
.padding([.leading, .trailing], thumbSize / 2)
.onAppear(perform: clampSelectedRangeToBounds)
}

private func clampSelectedRangeToBounds() {
if selectedRange.lowerBound < sliderBounds.lowerBound {
$selectedRange.wrappedValue = sliderBounds.lowerBound...$selectedRange.wrappedValue.upperBound
}
if selectedRange.upperBound > sliderBounds.upperBound {
$selectedRange.wrappedValue = $selectedRange.wrappedValue.lowerBound...sliderBounds.upperBound
}
}

@ViewBuilder private func sliderView(sliderSize: CGSize) -> some View {
ZStack {
Capsule()
.fill(Color(.lineColor))
.frame(width: sliderSize.width, height: sliderHeight)
Rectangle()
.fill(Color(.coreAccentColor))
.frame(width: fillLineWidth(sliderSize: sliderSize), height: sliderHeight)
.offset(x: fillLineOffset(sliderSize: sliderSize))
SliderThumbView(
size: thumbSize,
offset: trailingThumbOffset(sliderSize: sliderSize)
) { value in
handleTrailingThumbDrag(value: value, sliderSize: sliderSize)
}
.accessibilityLabel(trailingAccessibilityLabel)
.accessibility(value: Text("\(selectedRange.upperBound)"))
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: incrementTrailing()
case .decrement: decrementTrailing()
@unknown default: break
}
}
SliderThumbView(
size: thumbSize,
offset: leadingThumbOffset(sliderSize: sliderSize)
) { value in
handleLeadingThumbDrag(value: value, sliderSize: sliderSize)
}
.accessibilityLabel(leadingAccessibilityLabel)
.accessibility(value: Text("\(selectedRange.lowerBound)"))
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: incrementLeading()
case .decrement: decrementLeading()
@unknown default: break
}
}
}
}

/// Sets the accessibility label for the trailing thumb.
public func trailingAccessibility(label: String) -> BPKRangeSlider {
var result = self
result.trailingAccessibilityLabel = label
return result
}

/// Sets the accessibility label for the leading thumb.
public func leadingAccessibility(label: String) -> BPKRangeSlider {
var result = self
result.leadingAccessibilityLabel = label
return result
}

private func incrementLeading() {
let newValue = min($selectedRange.wrappedValue.lowerBound + step, selectedRange.upperBound)
$selectedRange.wrappedValue = newValue...$selectedRange.wrappedValue.upperBound
}

private func decrementLeading() {
let newValue = max($selectedRange.wrappedValue.lowerBound - step, sliderBounds.lowerBound)
$selectedRange.wrappedValue = newValue...$selectedRange.wrappedValue.upperBound
}

private func incrementTrailing() {
let newValue = min($selectedRange.wrappedValue.upperBound + step, sliderBounds.upperBound)
$selectedRange.wrappedValue = $selectedRange.wrappedValue.lowerBound...newValue
}

private func decrementTrailing() {
let newValue = max($selectedRange.wrappedValue.upperBound - step, selectedRange.lowerBound)
$selectedRange.wrappedValue = $selectedRange.wrappedValue.lowerBound...newValue
}

private func handleTrailingThumbDrag(value: DragGesture.Value, sliderSize: CGSize) {
let roundedValue = BPKSliderHelpers.calculateNewValueFromDrag(
xLocation: value.location.x,
sliderWidth: sliderSize.width,
thumbSize: thumbSize,
sliderBounds: sliderBounds,
step: step
)
let isGreaterThanLeadingThumb = roundedValue >= selectedRange.lowerBound
let isSmallerThanUpperBound = roundedValue <= sliderBounds.upperBound
let isWithinMinSpacing = roundedValue - selectedRange.lowerBound - minSpacing >= 0
if isGreaterThanLeadingThumb && isSmallerThanUpperBound && isWithinMinSpacing {
$selectedRange.wrappedValue = $selectedRange.wrappedValue.lowerBound...roundedValue
}
}

private func handleLeadingThumbDrag(value: DragGesture.Value, sliderSize: CGSize) {
let roundedValue = BPKSliderHelpers.calculateNewValueFromDrag(
xLocation: value.location.x,
sliderWidth: sliderSize.width,
thumbSize: thumbSize,
sliderBounds: sliderBounds,
step: step
)
let isSmallerThanTrailingThumb = roundedValue <= selectedRange.upperBound
let isGreaterThanLowerBound = roundedValue >= sliderBounds.lowerBound
let isWithinMinSpacing = selectedRange.upperBound - roundedValue - minSpacing >= 0
if isSmallerThanTrailingThumb && isGreaterThanLowerBound && isWithinMinSpacing {
$selectedRange.wrappedValue = roundedValue...$selectedRange.wrappedValue.upperBound
}
}

private func leadingThumbOffset(sliderSize: CGSize) -> CGFloat {
thumbOffset(
forBound: selectedPercentageBounds().lower,
sliderSize: sliderSize
)
}

private func trailingThumbOffset(sliderSize: CGSize) -> CGFloat {
thumbOffset(
forBound: selectedPercentageBounds().upper,
sliderSize: sliderSize
)
}

private func thumbOffset(forBound bound: Float, sliderSize: CGSize) -> CGFloat {
sliderSize.width * CGFloat(bound) - (sliderSize.width / 2)
}

private func fillLineOffset(sliderSize: CGSize) -> CGFloat {
let (lowerBound, upperBound) = selectedPercentageBounds()
let centerPercentagePoint = (lowerBound + upperBound) / 2
return sliderSize.width * CGFloat(centerPercentagePoint) - (sliderSize.width / 2)
}

private func fillLineWidth(sliderSize: CGSize) -> CGFloat {
let (lowerBound, upperBound) = selectedPercentageBounds()
return sliderSize.width * CGFloat(upperBound - lowerBound)
}

private func selectedPercentageBounds() -> (lower: Float, upper: Float) {
let selectedLowerBoundPercentage = BPKSliderHelpers.percentageOfValue(
value: $selectedRange.wrappedValue.lowerBound,
sliderBounds: sliderBounds
)
let selectedUpperBoundPercentage = BPKSliderHelpers.percentageOfValue(
value: $selectedRange.wrappedValue.upperBound,
sliderBounds: sliderBounds
)
return (selectedLowerBoundPercentage, selectedUpperBoundPercentage)
}
}

struct BPKRangeSlider_Previews: PreviewProvider {
static var previews: some View {
BPKRangeSlider(
selectedRange: .constant(30...70),
sliderBounds: 0...100,
step: 1,
minSpacing: 10
)
}
}
147 changes: 147 additions & 0 deletions Backpack-SwiftUI/Slider/Classes/BPKSlider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2018-2022 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

/// A view that displays a horizontal slider with a thumb that
/// can be dragged to select a value.
public struct BPKSlider: View {
@Binding private var value: Float
private let sliderBounds: ClosedRange<Float>
private let step: Float

private let sliderHeight: CGFloat = 4
private let thumbSize: CGFloat = 20
private var thumbAccessibilityLabel = ""

/// Creates a new instance of `BPKSlider`.
///
/// If the value is outside the bounds of the slider, it will be clamped to the bounds.
///
/// - Parameters:
/// - value: Binding of the value of the slider.
/// - sliderBounds: The bounds of the slider.
/// - step: The step size of the slider. Defaults to 1.
public init(
value: Binding<Float>,
sliderBounds: ClosedRange<Float>,
step: Float = 1
) {
self._value = value
self.sliderBounds = sliderBounds
self.step = step
}

public var body: some View {
GeometryReader { geomentry in
sliderView(sliderSize: geomentry.size)
}
.fixedSize(horizontal: false, vertical: true)
.frame(height: thumbSize)
.padding([.leading, .trailing], thumbSize / 2)
}

@ViewBuilder private func sliderView(sliderSize: CGSize) -> some View {
ZStack {
Capsule()
.fill(Color(.lineColor))
.frame(width: sliderSize.width, height: sliderHeight)
Rectangle()
.fill(Color(.coreAccentColor))
.frame(width: fillLineWidth(sliderSize: sliderSize), height: sliderHeight)
.offset(x: fillLineOffset(sliderSize: sliderSize))
SliderThumbView(
size: thumbSize,
offset: thumbOffset(sliderSize: sliderSize)
) { dragValue in
handleThumbDrag(value: dragValue, sliderSize: sliderSize)
}
.accessibilityLabel(thumbAccessibilityLabel)
.accessibility(value: Text("\(value)"))
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: increment()
case .decrement: decrement()
@unknown default: break
}
}
}
}

/// Sets the accessibility label for the thumb.
public func thumbAccessibility(label: String) -> BPKSlider {
var result = self
result.thumbAccessibilityLabel = label
return result
}

private func increment() {
value = min(value + step, sliderBounds.upperBound)
}

private func decrement() {
value = max(value - step, sliderBounds.lowerBound)
}

private func fillLineWidth(sliderSize: CGSize) -> CGFloat {
let percentage = BPKSliderHelpers.percentageOfValue(
value: value,
sliderBounds: sliderBounds
)
return sliderSize.width * CGFloat(percentage)
}

private func fillLineOffset(sliderSize: CGSize) -> CGFloat {
let percentage = BPKSliderHelpers.percentageOfValue(
value: value,
sliderBounds: sliderBounds
)
return (sliderSize.width * CGFloat(percentage) / 2) - (sliderSize.width / 2)
}

private func handleThumbDrag(value dragValue: DragGesture.Value, sliderSize: CGSize) {
let roundedValue = BPKSliderHelpers.calculateNewValueFromDrag(
xLocation: dragValue.location.x,
sliderWidth: sliderSize.width,
thumbSize: thumbSize,
sliderBounds: sliderBounds,
step: step
)
if roundedValue >= sliderBounds.lowerBound && roundedValue <= sliderBounds.upperBound {
value = roundedValue
}
}

private func thumbOffset(sliderSize: CGSize) -> CGFloat {
let percentage = BPKSliderHelpers.percentageOfValue(
value: value,
sliderBounds: sliderBounds
)
return sliderSize.width * CGFloat(percentage) - (sliderSize.width / 2)
}
}

struct BPKSlider_Previews: PreviewProvider {
static var previews: some View {
VStack {
BPKSlider(value: .constant(-25), sliderBounds: -50...50)
BPKSlider(value: .constant(50), sliderBounds: 0...100)
BPKSlider(value: .constant(75), sliderBounds: 0...100)
}
}
}

0 comments on commit 279bdab

Please sign in to comment.