Is there a way, using SwiftUI, to create a Slider with 2 handles?
I'm working on a project that that requires settings a low and high point for a random value to be created between, and sliders seem to fit that need perfectly. I currently have it implemented as 2 separate sliders, but it would much rather have it 1 slider with 2 handles.
I've been searching and I cannot find any examples of it in SwiftUI, but I did find a webpage example of what I'm looking to do here: https://jqueryui.com/slider/#range
Is this possible in iOS via SwiftUI?
I've created a custom slider for you. I hope that's enough for your needs. Let me know if there is anything else I can do.
Slider:
import SwiftUI
import Combine
//SliderValue to restrict double range: 0.0 to 1.0
@propertyWrapper
struct SliderValue {
var value: Double
init(wrappedValue: Double) {
self.value = wrappedValue
}
var wrappedValue: Double {
get { value }
set { value = min(max(0.0, newValue), 1.0) }
}
}
class SliderHandle: ObservableObject {
//Slider Size
let sliderWidth: CGFloat
let sliderHeight: CGFloat
//Slider Range
let sliderValueStart: Double
let sliderValueRange: Double
//Slider Handle
var diameter: CGFloat = 40
var startLocation: CGPoint
//Current Value
@Published var currentPercentage: SliderValue
//Slider Button Location
@Published var onDrag: Bool
@Published var currentLocation: CGPoint
init(sliderWidth: CGFloat, sliderHeight: CGFloat, sliderValueStart: Double, sliderValueEnd: Double, startPercentage: SliderValue) {
self.sliderWidth = sliderWidth
self.sliderHeight = sliderHeight
self.sliderValueStart = sliderValueStart
self.sliderValueRange = sliderValueEnd - sliderValueStart
let startLocation = CGPoint(x: (CGFloat(startPercentage.wrappedValue)/1.0)*sliderWidth, y: sliderHeight/2)
self.startLocation = startLocation
self.currentLocation = startLocation
self.currentPercentage = startPercentage
self.onDrag = false
}
lazy var sliderDragGesture: _EndedGesture<_ChangedGesture<DragGesture>> = DragGesture()
.onChanged { value in
self.onDrag = true
let dragLocation = value.location
//Restrict possible drag area
self.restrictSliderBtnLocation(dragLocation)
//Get current value
self.currentPercentage.wrappedValue = Double(self.currentLocation.x / self.sliderWidth)
}.onEnded { _ in
self.onDrag = false
}
private func restrictSliderBtnLocation(_ dragLocation: CGPoint) {
//On Slider Width
if dragLocation.x > CGPoint.zero.x && dragLocation.x < sliderWidth {
calcSliderBtnLocation(dragLocation)
}
}
private func calcSliderBtnLocation(_ dragLocation: CGPoint) {
if dragLocation.y != sliderHeight/2 {
currentLocation = CGPoint(x: dragLocation.x, y: sliderHeight/2)
} else {
currentLocation = dragLocation
}
}
//Current Value
var currentValue: Double {
return sliderValueStart + currentPercentage.wrappedValue * sliderValueRange
}
}
class CustomSlider: ObservableObject {
//Slider Size
let width: CGFloat = 300
let lineWidth: CGFloat = 8
//Slider value range from valueStart to valueEnd
let valueStart: Double
let valueEnd: Double
//Slider Handle
@Published var highHandle: SliderHandle
@Published var lowHandle: SliderHandle
//Handle start percentage (also for starting point)
@SliderValue var highHandleStartPercentage = 1.0
@SliderValue var lowHandleStartPercentage = 0.0
var anyCancellableHigh: AnyCancellable?
var anyCancellableLow: AnyCancellable?
init(start: Double, end: Double) {
valueStart = start
valueEnd = end
highHandle = SliderHandle(sliderWidth: width,
sliderHeight: lineWidth,
sliderValueStart: valueStart,
sliderValueEnd: valueEnd,
startPercentage: _highHandleStartPercentage
)
lowHandle = SliderHandle(sliderWidth: width,
sliderHeight: lineWidth,
sliderValueStart: valueStart,
sliderValueEnd: valueEnd,
startPercentage: _lowHandleStartPercentage
)
anyCancellableHigh = highHandle.objectWillChange.sink { _ in
self.objectWillChange.send()
}
anyCancellableLow = lowHandle.objectWillChange.sink { _ in
self.objectWillChange.send()
}
}
//Percentages between high and low handle
var percentagesBetween: String {
return String(format: "%.2f", highHandle.currentPercentage.wrappedValue - lowHandle.currentPercentage.wrappedValue)
}
//Value between high and low handle
var valueBetween: String {
return String(format: "%.2f", highHandle.currentValue - lowHandle.currentValue)
}
}
Slider implementation:
import SwiftUI
struct ContentView: View {
@ObservedObject var slider = CustomSlider(start: 10, end: 100)
var body: some View {
VStack {
Text("Value: " + slider.valueBetween)
Text("Percentages: " + slider.percentagesBetween)
Text("High Value: \(slider.highHandle.currentValue)")
Text("Low Value: \(slider.lowHandle.currentValue)")
//Slider
SliderView(slider: slider)
}
}
}
struct SliderView: View {
@ObservedObject var slider: CustomSlider
var body: some View {
RoundedRectangle(cornerRadius: slider.lineWidth)
.fill(Color.gray.opacity(0.2))
.frame(width: slider.width, height: slider.lineWidth)
.overlay(
ZStack {
//Path between both handles
SliderPathBetweenView(slider: slider)
//Low Handle
SliderHandleView(handle: slider.lowHandle)
.highPriorityGesture(slider.lowHandle.sliderDragGesture)
//High Handle
SliderHandleView(handle: slider.highHandle)
.highPriorityGesture(slider.highHandle.sliderDragGesture)
}
)
}
}
struct SliderHandleView: View {
@ObservedObject var handle: SliderHandle
var body: some View {
Circle()
.frame(width: handle.diameter, height: handle.diameter)
.foregroundColor(.white)
.shadow(color: Color.black.opacity(0.15), radius: 8, x: 0, y: 0)
.scaleEffect(handle.onDrag ? 1.3 : 1)
.contentShape(Rectangle())
.position(x: handle.currentLocation.x, y: handle.currentLocation.y)
}
}
struct SliderPathBetweenView: View {
@ObservedObject var slider: CustomSlider
var body: some View {
Path { path in
path.move(to: slider.lowHandle.currentLocation)
path.addLine(to: slider.highHandle.currentLocation)
}
.stroke(Color.green, lineWidth: slider.lineWidth)
}
}
This range slider will work with any width provided.
GeometryReader
to get the slider widthRangeSliderView Usage
@State var sliderPosition: ClosedRange<Float> = 3...8
RangedSliderView(value: $sliderPosition, bounds: 1...10)
RangeSlideView Implementation
struct RangedSliderView: View {
let currentValue: Binding<ClosedRange<Float>>
let sliderBounds: ClosedRange<Int>
public init(value: Binding<ClosedRange<Float>>, bounds: ClosedRange<Int>) {
self.currentValue = value
self.sliderBounds = bounds
}
var body: some View {
GeometryReader { geomentry in
sliderView(sliderSize: geomentry.size)
}
}
@ViewBuilder private func sliderView(sliderSize: CGSize) -> some View {
let sliderViewYCenter = sliderSize.height / 2
ZStack {
RoundedRectangle(cornerRadius: 2)
.fill(Color.nojaPrimary30)
.frame(height: 4)
ZStack {
let sliderBoundDifference = sliderBounds.count
let stepWidthInPixel = CGFloat(sliderSize.width) / CGFloat(sliderBoundDifference)
// Calculate Left Thumb initial position
let leftThumbLocation: CGFloat = currentValue.wrappedValue.lowerBound == Float(sliderBounds.lowerBound)
? 0
: CGFloat(currentValue.wrappedValue.lowerBound - Float(sliderBounds.lowerBound)) * stepWidthInPixel
// Calculate right thumb initial position
let rightThumbLocation = CGFloat(currentValue.wrappedValue.upperBound) * stepWidthInPixel
// Path between both handles
lineBetweenThumbs(from: .init(x: leftThumbLocation, y: sliderViewYCenter), to: .init(x: rightThumbLocation, y: sliderViewYCenter))
// Left Thumb Handle
let leftThumbPoint = CGPoint(x: leftThumbLocation, y: sliderViewYCenter)
thumbView(position: leftThumbPoint, value: Float(currentValue.wrappedValue.lowerBound))
.highPriorityGesture(DragGesture().onChanged { dragValue in
let dragLocation = dragValue.location
let xThumbOffset = min(max(0, dragLocation.x), sliderSize.width)
let newValue = Float(sliderBounds.lowerBound) + Float(xThumbOffset / stepWidthInPixel)
// Stop the range thumbs from colliding each other
if newValue < currentValue.wrappedValue.upperBound {
currentValue.wrappedValue = newValue...currentValue.wrappedValue.upperBound
}
})
// Right Thumb Handle
thumbView(position: CGPoint(x: rightThumbLocation, y: sliderViewYCenter), value: currentValue.wrappedValue.upperBound)
.highPriorityGesture(DragGesture().onChanged { dragValue in
let dragLocation = dragValue.location
let xThumbOffset = min(max(CGFloat(leftThumbLocation), dragLocation.x), sliderSize.width)
var newValue = Float(xThumbOffset / stepWidthInPixel) // convert back the value bound
newValue = min(newValue, Float(sliderBounds.upperBound))
// Stop the range thumbs from colliding each other
if newValue > currentValue.wrappedValue.lowerBound {
currentValue.wrappedValue = currentValue.wrappedValue.lowerBound...newValue
}
})
}
}
}
@ViewBuilder func lineBetweenThumbs(from: CGPoint, to: CGPoint) -> some View {
Path { path in
path.move(to: from)
path.addLine(to: to)
}.stroke(Color.nojaPrimary, lineWidth: 4)
}
@ViewBuilder func thumbView(position: CGPoint, value: Float) -> some View {
ZStack {
Text(String(value))
.font(.secondaryFont(weight: .semibold, size: 10))
.offset(y: -20)
Circle()
.frame(width: 24, height: 24)
.foregroundColor(.nojaPrimary)
.shadow(color: Color.black.opacity(0.16), radius: 8, x: 0, y: 2)
.contentShape(Rectangle())
}
.position(x: position.x, y: position.y)
}
}
There are some improvements that can be added e.g adding a step property or also implementing the slider with a generic init to support Int, Float and other number types use
ViewBuilder
to build a custom label for the slider
I modified the code from @culjo. This code supports preview
and the logical parts are moved to viewModel
.
import SwiftUI
struct RangeSlider: View {
@ObservedObject var viewModel: ViewModel
@State private var isActive: Bool = false
let sliderPositionChanged: (ClosedRange<Float>) -> Void
var body: some View {
GeometryReader { geometry in
sliderView(sliderSize: geometry.size,
sliderViewYCenter: geometry.size.height / 2)
}
.frame(height: ** insert your height of range slider **)
}
@ViewBuilder private func sliderView(sliderSize: CGSize, sliderViewYCenter: CGFloat) -> some View {
lineBetweenThumbs(from: viewModel.leftThumbLocation(width: sliderSize.width,
sliderViewYCenter: sliderViewYCenter),
to: viewModel.rightThumbLocation(width: sliderSize.width,
sliderViewYCenter: sliderViewYCenter))
thumbView(position: viewModel.leftThumbLocation(width: sliderSize.width,
sliderViewYCenter: sliderViewYCenter),
value: Float(viewModel.sliderPosition.lowerBound))
.highPriorityGesture(DragGesture().onChanged { dragValue in
let newValue = viewModel.newThumbLocation(dragLocation: dragValue.location,
width: sliderSize.width)
if newValue < viewModel.sliderPosition.upperBound {
viewModel.sliderPosition = newValue...viewModel.sliderPosition.upperBound
sliderPositionChanged(viewModel.sliderPosition)
isActive = true
}
})
thumbView(position: viewModel.rightThumbLocation(width: sliderSize.width,
sliderViewYCenter: sliderViewYCenter),
value: Float(viewModel.sliderPosition.upperBound))
.highPriorityGesture(DragGesture().onChanged { dragValue in
let newValue = viewModel.newThumbLocation(dragLocation: dragValue.location,
width: sliderSize.width)
if newValue > viewModel.sliderPosition.lowerBound {
viewModel.sliderPosition = viewModel.sliderPosition.lowerBound...newValue
sliderPositionChanged(viewModel.sliderPosition)
isActive = true
}
})
}
@ViewBuilder func lineBetweenThumbs(from: CGPoint, to: CGPoint) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(** insert your color **)
.frame(height: 4)
Path { path in
path.move(to: from)
path.addLine(to: to)
}
.stroke(isActive ? ** insert your color ** : ** insert your color **,
lineWidth: 4)
}
}
@ViewBuilder func thumbView(position: CGPoint, value: Float) -> some View {
Circle()
.frame(size: .rangeSliderThumb)
.foregroundColor(isActive ? ** insert your color ** : ** insert your color **)
.contentShape(Rectangle())
.position(x: position.x, y: position.y)
.animation(.spring(), value: isActive)
}
}
extension RangeSlider {
final class ViewModel: ObservableObject {
@Published var sliderPosition: ClosedRange<Float>
let sliderBounds: ClosedRange<Int>
let sliderBoundDifference: Int
init(sliderPosition: ClosedRange<Float>,
sliderBounds: ClosedRange<Int>) {
self.sliderPosition = sliderPosition
self.sliderBounds = sliderBounds
self.sliderBoundDifference = sliderBounds.count - 1
}
func leftThumbLocation(width: CGFloat, sliderViewYCenter: CGFloat = 0) -> CGPoint {
let sliderLeftPosition = CGFloat(sliderPosition.lowerBound - Float(sliderBounds.lowerBound))
return .init(x: sliderLeftPosition * stepWidthInPixel(width: width),
y: sliderViewYCenter)
}
func rightThumbLocation(width: CGFloat, sliderViewYCenter: CGFloat = 0) -> CGPoint {
let sliderRightPosition = CGFloat(sliderPosition.upperBound - Float(sliderBounds.lowerBound))
return .init(x: sliderRightPosition * stepWidthInPixel(width: width),
y: sliderViewYCenter)
}
func newThumbLocation(dragLocation: CGPoint, width: CGFloat) -> Float {
let xThumbOffset = min(max(0, dragLocation.x), width)
return Float(sliderBounds.lowerBound) + Float(xThumbOffset / stepWidthInPixel(width: width))
}
private func stepWidthInPixel(width: CGFloat) -> CGFloat {
width / CGFloat(sliderBoundDifference)
}
}
}
struct RangeSlider_Previews: PreviewProvider {
static var previews: some View {
RangeSlider(viewModel: .init(sliderPosition: 2...8,
sliderBounds: 1...10),
sliderPositionChanged: { _ in })
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With