swiftui

How to create a two-handle range slider?


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?


Solution

  • 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.

    enter image description here

    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)
        }
    }