Slider:
import SwiftUIimport Combine//SliderValue to restrict double range: 0.0 to 1.0@propertyWrapperstruct SliderValue {var value: Doubleinit(wrappedValue: Double) {self.value = wrappedValue}var wrappedValue: Double {get { value }set { value = min(max(0.0, newValue), 1.0) }}}class SliderHandle: ObservableObject {//Slider Sizelet sliderWidth: CGFloatlet sliderHeight: CGFloat//Slider Rangelet sliderValueStart: Doublelet sliderValueRange: Double//Slider Handlevar diameter: CGFloat = 40var startLocation: CGPoint//Current Value@Published var currentPercentage: SliderValue//Slider Button Location@Published var onDrag: Bool@Published var currentLocation: CGPointinit(sliderWidth: CGFloat, sliderHeight: CGFloat, sliderValueStart: Double, sliderValueEnd: Double, startPercentage: SliderValue) {self.sliderWidth = sliderWidthself.sliderHeight = sliderHeightself.sliderValueStart = sliderValueStartself.sliderValueRange = sliderValueEnd - sliderValueStartlet startLocation = CGPoint(x: (CGFloat(startPercentage.wrappedValue)/1.0)*sliderWidth, y: sliderHeight/2)self.startLocation = startLocationself.currentLocation = startLocationself.currentPercentage = startPercentageself.onDrag = false}lazy var sliderDragGesture: _EndedGesture<_ChangedGesture<DragGesture>> = DragGesture().onChanged { value inself.onDrag = truelet dragLocation = value.location//Restrict possible drag areaself.restrictSliderBtnLocation(dragLocation)//Get current valueself.currentPercentage.wrappedValue = Double(self.currentLocation.x / self.sliderWidth)}.onEnded { _ inself.onDrag = false}private func restrictSliderBtnLocation(_ dragLocation: CGPoint) {//On Slider Widthif 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 Valuevar currentValue: Double {return sliderValueStart + currentPercentage.wrappedValue * sliderValueRange}}class CustomSlider: ObservableObject {//Slider Sizelet width: CGFloat = 300let lineWidth: CGFloat = 8//Slider value range from valueStart to valueEndlet valueStart: Doublelet 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.0var anyCancellableHigh: AnyCancellable?var anyCancellableLow: AnyCancellable?init(start: Double, end: Double) {valueStart = startvalueEnd = endhighHandle = 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 { _ inself.objectWillChange.send()}anyCancellableLow = lowHandle.objectWillChange.sink { _ inself.objectWillChange.send()}}//Percentages between high and low handlevar percentagesBetween: String {return String(format: "%.2f", highHandle.currentPercentage.wrappedValue - lowHandle.currentPercentage.wrappedValue)}//Value between high and low handlevar valueBetween: String {return String(format: "%.2f", highHandle.currentValue - lowHandle.currentValue)}}
Slider implementation:
import SwiftUIstruct 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)")//SliderSliderView(slider: slider)}}}struct SliderView: View {@ObservedObject var slider: CustomSlidervar 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 handlesSliderPathBetweenView(slider: slider)//Low HandleSliderHandleView(handle: slider.lowHandle).highPriorityGesture(slider.lowHandle.sliderDragGesture)//High HandleSliderHandleView(handle: slider.highHandle).highPriorityGesture(slider.highHandle.sliderDragGesture)})}}struct SliderHandleView: View {@ObservedObject var handle: SliderHandlevar 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: CustomSlidervar body: some View {Path { path inpath.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...8RangedSliderView(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 = valueself.sliderBounds = bounds}var body: some View {GeometryReader { geomentry insliderView(sliderSize: geomentry.size)}}@ViewBuilder private func sliderView(sliderSize: CGSize) -> some View {let sliderViewYCenter = sliderSize.height / 2ZStack {RoundedRectangle(cornerRadius: 2).fill(Color.nojaPrimary30).frame(height: 4)ZStack {let sliderBoundDifference = sliderBounds.countlet stepWidthInPixel = CGFloat(sliderSize.width) / CGFloat(sliderBoundDifference)// Calculate Left Thumb initial positionlet leftThumbLocation: CGFloat = currentValue.wrappedValue.lowerBound == Float(sliderBounds.lowerBound)? 0: CGFloat(currentValue.wrappedValue.lowerBound - Float(sliderBounds.lowerBound)) * stepWidthInPixel// Calculate right thumb initial positionlet rightThumbLocation = CGFloat(currentValue.wrappedValue.upperBound) * stepWidthInPixel// Path between both handleslineBetweenThumbs(from: .init(x: leftThumbLocation, y: sliderViewYCenter), to: .init(x: rightThumbLocation, y: sliderViewYCenter))// Left Thumb Handlelet leftThumbPoint = CGPoint(x: leftThumbLocation, y: sliderViewYCenter)thumbView(position: leftThumbPoint, value: Float(currentValue.wrappedValue.lowerBound)).highPriorityGesture(DragGesture().onChanged { dragValue inlet dragLocation = dragValue.locationlet xThumbOffset = min(max(0, dragLocation.x), sliderSize.width)let newValue = Float(sliderBounds.lowerBound) + Float(xThumbOffset / stepWidthInPixel)// Stop the range thumbs from colliding each otherif newValue < currentValue.wrappedValue.upperBound {currentValue.wrappedValue = newValue...currentValue.wrappedValue.upperBound}})// Right Thumb HandlethumbView(position: CGPoint(x: rightThumbLocation, y: sliderViewYCenter), value: currentValue.wrappedValue.upperBound).highPriorityGesture(DragGesture().onChanged { dragValue inlet dragLocation = dragValue.locationlet xThumbOffset = min(max(CGFloat(leftThumbLocation), dragLocation.x), sliderSize.width)var newValue = Float(xThumbOffset / stepWidthInPixel) // convert back the value boundnewValue = min(newValue, Float(sliderBounds.upperBound))// Stop the range thumbs from colliding each otherif newValue > currentValue.wrappedValue.lowerBound {currentValue.wrappedValue = currentValue.wrappedValue.lowerBound...newValue}})}}}@ViewBuilder func lineBetweenThumbs(from: CGPoint, to: CGPoint) -> some View {Path { path inpath.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 addede.g adding a step property oralso implementing the slider with a generic init to support Int, Float and other number typesuse
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 SwiftUIstruct RangeSlider: View {@ObservedObject var viewModel: ViewModel@State private var isActive: Bool = falselet sliderPositionChanged: (ClosedRange<Float>) -> Voidvar body: some View {GeometryReader { geometry insliderView(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 inlet newValue = viewModel.newThumbLocation(dragLocation: dragValue.location,width: sliderSize.width)if newValue < viewModel.sliderPosition.upperBound {viewModel.sliderPosition = newValue...viewModel.sliderPosition.upperBoundsliderPositionChanged(viewModel.sliderPosition)isActive = true}})thumbView(position: viewModel.rightThumbLocation(width: sliderSize.width,sliderViewYCenter: sliderViewYCenter),value: Float(viewModel.sliderPosition.upperBound)).highPriorityGesture(DragGesture().onChanged { dragValue inlet newValue = viewModel.newThumbLocation(dragLocation: dragValue.location,width: sliderSize.width)if newValue > viewModel.sliderPosition.lowerBound {viewModel.sliderPosition = viewModel.sliderPosition.lowerBound...newValuesliderPositionChanged(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 inpath.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: Intinit(sliderPosition: ClosedRange<Float>,sliderBounds: ClosedRange<Int>) {self.sliderPosition = sliderPositionself.sliderBounds = sliderBoundsself.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 })}}
I know this question is older, but for any future reference, the above answers did not work well with negative numbers in my experience. Here is the code I ended up using.
struct RangeSliderView: View {@Binding var value: ClosedRange<Int>let bounds: ClosedRange<Int>@State private var viewSize = CGSize(width: 0, height: 0)@State private var leftX: CGFloat = 11var body: some View {let pixelsPerStep = (viewSize.width - 22) / CGFloat(bounds.count - 1)let offset = -bounds.lowerBoundZStack {// SizeGeometryReader { geo inColor.clear.onAppear {viewSize = geo.size}}// LineRectangle().foregroundColor(AppColors.contrast).frame(width: viewSize.width, height: 6).cornerRadius(4)// Leftslider(bounds: bounds,pixelsPerStep: pixelsPerStep,offset: offset,viewMid: viewSize.height / 2,value: $value,isLower: true)// Rightslider(bounds: bounds,pixelsPerStep: pixelsPerStep,offset: offset,viewMid: viewSize.height / 2,value: $value,isLower: false)}}private struct slider: View {let bounds: ClosedRange<Int>let pixelsPerStep: CGFloatlet offset: Intlet viewMid: CGFloat@Binding var value: ClosedRange<Int>let isLower: Bool@State private var sliderX: CGFloat = 11@State private var recentMove = false@State private var hideNow = falsevar body: some View {if hideNow {Color.clear} else {Circle().foregroundColor(isLower ? Color(red: 0.5, green: 0.1, blue: 0.15) : Color(red: 0.7, green: 0.15, blue: 0.25)).frame(width: 22, height: 22).position(x: sliderX, y: viewMid).highPriorityGesture(DragGesture().onChanged({ drag invar location = Int(round((drag.location.x - 11) / pixelsPerStep)) - offsetlocation = max(isLower ? bounds.lowerBound : value.lowerBound + 1, min(isLower ? value.upperBound - 1 : bounds.upperBound, location))value = isLower ? location...value.upperBound : value.lowerBound...locationsliderX = CGFloat((isLower ? value.lowerBound : value.upperBound) + offset) * pixelsPerStep + 11if !recentMove {withAnimation(Animation.linear(duration: 0.2)) {recentMove = true}withAnimation(Animation.linear(duration: 0.2).delay(1)) {recentMove = false}}})).onChange(of: pixelsPerStep) { pix insliderX = CGFloat((isLower ? value.lowerBound : value.upperBound) + offset) * pix + 11}.overlay {let half = (bounds.upperBound + offset) / 2let lowerCase: Bool = isLower && (value.upperBound + offset) > halflet upperCase: Bool = !isLower && (value.lowerBound + offset) < halflet lowerOut = min(sliderX, (pixelsPerStep * CGFloat(value.upperBound + offset)) - 10)let upperOut = max(sliderX, (pixelsPerStep * CGFloat(value.lowerBound + offset)) + 40)let x = lowerCase ? lowerOut : upperCase ? upperOut : sliderXText(String(isLower ? value.lowerBound : value.upperBound)).opacity(recentMove ? 1 : 0.5).scaleEffect(recentMove ? 1.5 : 1).font(.system(size: 12, weight: .bold)).padding(8).background(.black.opacity(recentMove ? 1 : 0)).cornerRadius(4).position(x: x, y: viewMid - (recentMove ? 28 : 18))}.zIndex((!isLower && value.upperBound == bounds.upperBound) ? 0 : 2)}}}}
Usage
@State private var value: ClosedRange<Int> = ClosedRange(uncheckedBounds: (-40, 50))let bounds: ClosedRange<Int> = ClosedRange(uncheckedBounds: (-50, 70))RangeSliderView(value: $value, bounds: range)
Slider demo on iPhone 14