Skip to content

Range Slider

A slider with two thumbs that picks a range, like the lowest and highest price of a filter.

Range Slider, playing
Download .swift
Origin
Original work
Found by
range slider · double slider · two thumbs · price filter · min max · dual slider · interval · slider de rango · doble slider · dos asas · filtro de precio · mínimo y máximo · intervalo · deslizador

A slider with two thumbs that picks a range, like a price filter.

Drag a thumb, or the track near it: the nearest thumb moves with the finger without jumping to it. Set the color with tint. VoiceOver reads each thumb as an adjustable value, described by valueText, and the slider follows the layout direction.

struct RangeSliderDemo: View {
private let bounds = 20.0...300.0
/// How many stays there are in each slice of the price range, from the cheapest.
private let stays = [2, 5, 9, 14, 22, 30, 34, 29, 24, 18, 15, 11, 8, 6, 4, 3, 2, 2, 1, 1]
@State private var price = 60.0...180.0
var body: some View {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 4) {
Text("Price range")
.font(.title2.bold())
Text("Nightly prices before fees and taxes")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(spacing: 0) {
histogram
RangeSlider(range: $price, bounds: bounds, step: 5, minimumSpan: 30, lowerLabel: "Minimum price", upperLabel: "Maximum price", valueText: dollars)
}
HStack {
amount("Minimum", price.lowerBound)
Spacer()
amount("Maximum", price.upperBound)
}
Text("^[\(matches) stay](inflect: true)")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.foregroundStyle(.white)
.background(.tint, in: .capsule)
.contentTransition(.numericText())
.animation(.snappy, value: matches)
}
.padding(24)
.background(.background, in: .rect(cornerRadius: 24))
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.tint(.pink)
}
private var histogram: some View {
HStack(alignment: .bottom, spacing: 3) {
ForEach(stays.indices, id: \.self) { index in
RoundedRectangle(cornerRadius: 2)
.fill(isSelected(index) ? AnyShapeStyle(.tint) : AnyShapeStyle(.fill.tertiary))
.frame(height: CGFloat(stays[index]) * 3)
}
}
.padding(.horizontal, 14)
.accessibilityHidden(true)
}
private var matches: Int {
stays.indices.filter(isSelected).map { stays[$0] }.reduce(0, +)
}
/// Whether the middle of a slice of the price range is inside the chosen prices.
private func isSelected(_ index: Int) -> Bool {
let slice = (bounds.upperBound - bounds.lowerBound) / Double(stays.count)
return price.contains(bounds.lowerBound + (Double(index) + 0.5) * slice)
}
private func amount(_ title: String, _ value: Double) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(dollars(value))
.font(.title3.weight(.semibold))
.monospacedDigit()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.fill.quaternary, in: .rect(cornerRadius: 12))
.accessibilityElement(children: .combine)
}
private func dollars(_ value: Double) -> String {
value.formatted(.currency(code: "USD").precision(.fractionLength(0)))
}
}
ParameterTypeDefaultDescription
rangeBinding<ClosedRange<Double>>Required
boundsClosedRange<Double>0...1
stepDouble0.0The thumbs stop at multiples of it. Zero lets them stop anywhere.
minimumSpanDouble0.0The shortest distance the thumbs keep between them.
lowerLabelString"Minimum"
upperLabelString"Maximum"
valueText(Double) -> String{ $0.formatted() }

Copy this code into your project, or download the file.

RangeSlider.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum RangeSliderThumb: Equatable, Sendable {
case lower, upper
}
nonisolated enum RangeSliderMath {
/// Where `value` is along `bounds`, from 0 to 1.
static func fraction(of value: Double, in bounds: ClosedRange<Double>) -> Double {
let length = bounds.upperBound - bounds.lowerBound
guard length > 0, value.isFinite else { return 0 }
return min(max((value - bounds.lowerBound) / length, 0), 1)
}
/// `value` moved to the nearest multiple of `step`, counted from the start of `bounds`.
static func snapped(_ value: Double, in bounds: ClosedRange<Double>, step: Double) -> Double {
guard step > 0 else { return value }
return bounds.lowerBound + ((value - bounds.lowerBound) / step).rounded() * step
}
/// The lower end a drag asks for, kept inside `bounds` and `minimumSpan` below the upper end.
static func lower(_ value: Double, upper: Double, in bounds: ClosedRange<Double>, step: Double = 0, minimumSpan: Double = 0) -> Double {
guard value.isFinite else { return min(bounds.lowerBound, upper) }
let highest = max(upper - max(minimumSpan, 0), bounds.lowerBound)
return min(min(max(snapped(value, in: bounds, step: step), bounds.lowerBound), highest), upper)
}
/// The upper end a drag asks for, kept inside `bounds` and `minimumSpan` above the lower end.
static func upper(_ value: Double, lower: Double, in bounds: ClosedRange<Double>, step: Double = 0, minimumSpan: Double = 0) -> Double {
guard value.isFinite else { return max(bounds.upperBound, lower) }
let lowest = min(lower + max(minimumSpan, 0), bounds.upperBound)
return max(max(min(snapped(value, in: bounds, step: step), bounds.upperBound), lowest), lower)
}
/// The thumb a touch takes, with every position as a fraction of the track.
///
/// It is the nearest one. When both sit on the same spot it is the one that can still move
/// toward the touch, so two thumbs at one end never get stuck.
static func thumb(nearest touch: Double, lower: Double, upper: Double) -> RangeSliderThumb {
let toLower = abs(touch - lower)
let toUpper = abs(touch - upper)
if toLower != toUpper { return toLower < toUpper ? .lower : .upper }
if touch != lower { return touch < lower ? .lower : .upper }
return lower > 0.5 ? .lower : .upper
}
}
/// A slider with two thumbs that picks a range, like a price filter.
///
/// Drag a thumb, or the track near it: the nearest thumb moves with the finger without jumping
/// to it. Set the color with `tint`. VoiceOver reads each thumb as an adjustable value, described
/// by `valueText`, and the slider follows the layout direction.
struct RangeSlider: View {
@Binding var range: ClosedRange<Double>
var bounds: ClosedRange<Double> = 0...1
/// The thumbs stop at multiples of it. Zero lets them stop anywhere.
var step = 0.0
/// The shortest distance the thumbs keep between them.
var minimumSpan = 0.0
var lowerLabel = "Minimum"
var upperLabel = "Maximum"
var valueText: (Double) -> String = { $0.formatted() }
private let thumbSize: CGFloat = 28
private let trackHeight: CGFloat = 4
private let touchHeight: CGFloat = 44
@State private var width: CGFloat = 0
@State private var grab: RangeSliderGrab?
@GestureState private var isDragging = false
@Environment(\.layoutDirection) private var layoutDirection
/// How far the center of a thumb travels from one end of the track to the other.
private var travel: CGFloat { max(width - thumbSize, 0) }
var body: some View {
let lower = RangeSliderMath.fraction(of: range.lowerBound, in: bounds)
let upper = RangeSliderMath.fraction(of: range.upperBound, in: bounds)
ZStack(alignment: .leading) {
Capsule()
.fill(.fill.tertiary)
.frame(height: trackHeight)
Capsule()
.fill(.tint)
.frame(width: CGFloat(upper - lower) * travel + thumbSize, height: trackHeight)
.offset(x: CGFloat(lower) * travel)
thumb(.lower, at: lower)
thumb(.upper, at: upper)
}
.frame(height: touchHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
.contentShape(.rect)
.gesture(drag(lower: lower, upper: upper))
.sensoryFeedback(trigger: range) { _, _ in step > 0 ? .selection : nil }
.onChange(of: isDragging) { _, dragging in
if !dragging { grab = nil }
}
}
private func thumb(_ thumb: RangeSliderThumb, at fraction: Double) -> some View {
let value = thumb == .lower ? range.lowerBound : range.upperBound
return Circle()
.fill(.white)
.shadow(color: .black.opacity(0.2), radius: 3, y: 1)
.frame(width: thumbSize, height: thumbSize)
.offset(x: CGFloat(fraction) * travel)
.accessibilityElement()
.accessibilityLabel(thumb == .lower ? lowerLabel : upperLabel)
.accessibilityValue(valueText(value))
.accessibilityAdjustableAction { direction in
let stride = step > 0 ? step : (bounds.upperBound - bounds.lowerBound) / 20
switch direction {
case .increment: set(thumb, to: value + stride)
case .decrement: set(thumb, to: value - stride)
@unknown default: break
}
}
}
private func drag(lower: Double, upper: Double) -> some Gesture {
DragGesture(minimumDistance: 2)
.updating($isDragging) { _, state, _ in state = true }
.onChanged { value in
guard travel > 0 else { return }
// The track is mirrored in a right-to-left layout and the gesture is not.
let isMirrored = layoutDirection == .rightToLeft
if grab == nil {
let x = isMirrored ? width - value.startLocation.x : value.startLocation.x
let thumb = RangeSliderMath.thumb(nearest: Double((x - thumbSize / 2) / travel), lower: lower, upper: upper)
grab = RangeSliderGrab(thumb: thumb, value: thumb == .lower ? range.lowerBound : range.upperBound)
}
guard let grab else { return }
let moved = Double((isMirrored ? -value.translation.width : value.translation.width) / travel)
set(grab.thumb, to: grab.value + moved * (bounds.upperBound - bounds.lowerBound))
}
.onEnded { _ in grab = nil }
}
private func set(_ thumb: RangeSliderThumb, to value: Double) {
let new = thumb == .lower
? RangeSliderMath.lower(value, upper: range.upperBound, in: bounds, step: step, minimumSpan: minimumSpan)...range.upperBound
: range.lowerBound...RangeSliderMath.upper(value, lower: range.lowerBound, in: bounds, step: step, minimumSpan: minimumSpan)
if new != range { range = new }
}
}
private struct RangeSliderGrab {
var thumb: RangeSliderThumb
/// The value the thumb had when the drag began.
var value: Double
}
// MARK: - Demo
struct RangeSliderDemo: View {
private let bounds = 20.0...300.0
/// How many stays there are in each slice of the price range, from the cheapest.
private let stays = [2, 5, 9, 14, 22, 30, 34, 29, 24, 18, 15, 11, 8, 6, 4, 3, 2, 2, 1, 1]
@State private var price = 60.0...180.0
var body: some View {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 4) {
Text("Price range")
.font(.title2.bold())
Text("Nightly prices before fees and taxes")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(spacing: 0) {
histogram
RangeSlider(range: $price, bounds: bounds, step: 5, minimumSpan: 30, lowerLabel: "Minimum price", upperLabel: "Maximum price", valueText: dollars)
}
HStack {
amount("Minimum", price.lowerBound)
Spacer()
amount("Maximum", price.upperBound)
}
Text("^[\(matches) stay](inflect: true)")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.foregroundStyle(.white)
.background(.tint, in: .capsule)
.contentTransition(.numericText())
.animation(.snappy, value: matches)
}
.padding(24)
.background(.background, in: .rect(cornerRadius: 24))
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.tint(.pink)
}
private var histogram: some View {
HStack(alignment: .bottom, spacing: 3) {
ForEach(stays.indices, id: \.self) { index in
RoundedRectangle(cornerRadius: 2)
.fill(isSelected(index) ? AnyShapeStyle(.tint) : AnyShapeStyle(.fill.tertiary))
.frame(height: CGFloat(stays[index]) * 3)
}
}
.padding(.horizontal, 14)
.accessibilityHidden(true)
}
private var matches: Int {
stays.indices.filter(isSelected).map { stays[$0] }.reduce(0, +)
}
/// Whether the middle of a slice of the price range is inside the chosen prices.
private func isSelected(_ index: Int) -> Bool {
let slice = (bounds.upperBound - bounds.lowerBound) / Double(stays.count)
return price.contains(bounds.lowerBound + (Double(index) + 0.5) * slice)
}
private func amount(_ title: String, _ value: Double) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(dollars(value))
.font(.title3.weight(.semibold))
.monospacedDigit()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.fill.quaternary, in: .rect(cornerRadius: 12))
.accessibilityElement(children: .combine)
}
private func dollars(_ value: Double) -> String {
value.formatted(.currency(code: "USD").precision(.fractionLength(0)))
}
}
#Preview {
RangeSliderDemo()
}