21
Jul
2021
Custom Slider in SwiftUI
Reading time: 6 min
This recipe shows how to implement a custom Slider in SwiftUI. The default Slider
view isn't particularly customisable, so any stepping outside the norm requires custom code.
Here's what the end result will look like:
- On the top you have the default
Slider
. - In the middle there's our custom slider, made to look like the default one.
- The bottom row has an even more custom (and ugly) slider implementation.
This recipe is a part of our Road to custom ColorPicker series.
The recipe
OK, the recipe goes like this:
- A Slider consists of three components, all of which should be custom views:
- Track on which you slide (the grey line),
- Thumb which you "grab" in order to slide (the white circle),
- Fill which optionally showcases how much progress have you made (the blue line).
- The thumb is moved around using a
DragGesture
and can't leave the track. Moving the thumb also resizes the fill view. - The offset of the thumb relative to the bounds of the track is bound to the value. You can also specify the bounds and step to dictate how will the mapping work. Normally, a slider maps values from 0 to 1 with a very small discrete step.
- There should be an optional callback, onEditingChanged, that triggers when the user starts or stops moving the thumb.
- You can optionally specify labels to appear on the left and right hand side to indicate minimum and maximum values, respectively.
Note that we use the code to get size of a View at runtime (the measureSize
modifier) from this recipe.
Here's the commented code for the custom slider view:
struct CustomSlider<Value, Track, Fill, Thumb>: View
where Value: BinaryFloatingPoint, Value.Stride: BinaryFloatingPoint, Track: View, Fill: View, Thumb: View {
// the value of the slider, inside `bounds`
@Binding var value: Value
// range to which the thumb offset is mapped
let bounds: ClosedRange<Value>
// tells how discretely does the value change
let step: Value
// left-hand label
let minimumValueLabel: Text?
// right-hand label
let maximumValueLabel: Text?
// called with `true` when sliding starts and with `false` when it stops
let onEditingChanged: ((Bool) -> Void)?
// the track view
let track: () -> Track
// the fill view
let fill: (() -> Fill)?
// the thumb view
let thumb: () -> Thumb
// tells how big the thumb is. This is here because there's no good
// way in SwiftUI to get the thumb size at runtime, and its an important
// to know it in order to compute its insets in the track overlay.
let thumbSize: CGSize
// x offset of the thumb from the track left-hand side
@State private var xOffset: CGFloat = 0
// last moved offset, used to decide if sliding has started
@State private var lastOffset: CGFloat = 0
// the size of the track view. This can be obtained at runtime.
@State private var trackSize: CGSize = .zero
// initializer allows us to set default values for some view params
init(value: Binding<Value>,
in bounds: ClosedRange<Value> = 0...1,
step: Value = 0.001,
minimumValueLabel: Text? = nil,
maximumValueLabel: Text? = nil,
onEditingChanged: ((Bool) -> Void)? = nil,
track: @escaping () -> Track,
fill: (() -> Fill)?,
thumb: @escaping () -> Thumb,
thumbSize: CGSize) {
_value = value
self.bounds = bounds
self.step = step
self.minimumValueLabel = minimumValueLabel
self.maximumValueLabel = maximumValueLabel
self.onEditingChanged = onEditingChanged
self.track = track
self.fill = fill
self.thumb = thumb
self.thumbSize = thumbSize
}
// where does the current value sit, percentage-wise, in the provided bounds
private var percentage: Value {
1 - (bounds.upperBound - value) / (bounds.upperBound - bounds.lowerBound)
}
// how wide the should the fill view be
private var fillWidth: CGFloat {
trackSize.width * CGFloat(percentage)
}
var body: some View {
// the HStack orders minimumValueLabel, the slider and maximumValueLabel horizontally
HStack {
minimumValueLabel
// Represent the custom slider. ZStack overlays `fill` on top of `track`,
// while the `thumb` is in their `overlay`.
ZStack {
track()
// get the size of the track at runtime as it
// defines all the other functionality
.measureSize {
// if this is the first time trackSize is computed,
// update the offset to reflect the current `value`
let firstInit = (trackSize == .zero)
trackSize = $0
if firstInit {
xOffset = (trackSize.width - thumbSize.width) * CGFloat(percentage)
lastOffset = xOffset
}
}
fill?()
// `fill` changes both its position and frame as its
// anchor point is in its middle (at (0.5, 0.5)).
.position(x: fillWidth - trackSize.width / 2, y: trackSize.height / 2)
.frame(width: fillWidth, height: trackSize.height)
}
// make sure the entire ZStack is the same size as `track`
.frame(width: trackSize.width, height: trackSize.height)
// the thumb lives in the ZStack overlay
.overlay(thumb()
// adjust the insets so that `thumb` doesn't sit outside the `track`
.position(x: thumbSize.width / 2,
y: thumbSize.height / 2)
// set the size here to make sure it's really the same as the
// provided `thumbSize` parameter
.frame(width: thumbSize.width, height: thumbSize.height)
// set the offset to, well, the stored xOffset
.offset(x: xOffset)
// use the DragGesture to move the `thumb` around as adjust xOffset
.gesture(DragGesture(minimumDistance: 0).onChanged({ gestureValue in
// make sure at least some dragging was done to trigger `onEditingChanged`
if abs(gestureValue.translation.width) < 0.1 {
lastOffset = xOffset
onEditingChanged?(true)
}
// update xOffset by the gesture translation, making sure it's within the view's bounds
let availableWidth = trackSize.width - thumbSize.width
xOffset = max(0, min(lastOffset + gestureValue.translation.width, availableWidth))
// update the value by mapping xOffset to the track width and then to the provided bounds
// also make sure that the value changes discretely based on the `step` para
let newValue = (bounds.upperBound - bounds.lowerBound) * Value(xOffset / availableWidth) + bounds.lowerBound
let steppedNewValue = (round(newValue / step) * step)
value = min(bounds.upperBound, max(bounds.lowerBound, steppedNewValue))
}).onEnded({ _ in
// once the gesture ends, trigger `onEditingChanged` again
onEditingChanged?(false)
})),
alignment: .leading)
maximumValueLabel
}
// manually set the height of the entire view to account for thumb height
.frame(height: max(trackSize.height, thumbSize.height))
}
}
Sample slider
Here's how you can easily re-create the default Slider
view using our custom code. Its track is a thin light grey capsule, its fill a blue capsule and its thumb a white circle with a drop shadow:
struct DefaultSliderImpl: View {
private let thumbRadius: CGFloat = 30
@State private var value = 100.0
var body: some View {
Text("Custom slider: \(value)")
CustomSlider(value: $value,
in: 0...255,
minimumValueLabel: Text("Min"),
maximumValueLabel: Text("Max"),
onEditingChanged: { started in
print("started custom slider: \(started)")
}, track: {
Capsule()
.foregroundColor(.init(red: 0.9, green: 0.9, blue: 0.9))
.frame(width: 200, height: 5)
}, fill: {
Capsule()
.foregroundColor(.blue)
}, thumb: {
Circle()
.foregroundColor(.white)
.shadow(radius: thumbRadius / 1)
}, thumbSize: CGSize(width: thumbRadius, height: thumbRadius))
}
}