swiftswiftuislider

How to implement a range slider using swiftUI where the starting value is not zero


I'm trying to create a range slider for a person's height where the minimum height of 3ft shall also be the starting point of the range slider.

The code below shows a range slider with a starting point of 0ft how can I modify it to start at 3ft

Expected Range Slider Design

Expected Range slider Design

Current Range Slider

Current Range slider based on code

Code for Current Range Slider

import SwiftUI

struct ContentView: View {
    @State var heightMinValue: Double = 0.4 * Constants.Range.size // 40% of Constants.Range.size
    @State var heightMaxValue: Double = Constants.Range.size
    var body: some View {
        VStack {
            HeightRangeSliderView(scaleMinValue: $heightMinValue, scaleMaxValue: $heightMaxValue, minimumpercentageVal: 40, lableWidth: 89)
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct HeightRangeSliderView: View {
    /// ` Slider` Binding min & max values
    @Binding var scaleMinValue: Double
    @Binding var scaleMaxValue: Double
    var minimumpercentageVal: Double
    var leftKnobWidth: Double = 28
    var leftKnobHeight: Double = 28
    var rightKnobWidth: Double = 28
    var rightKnobHeight: Double = 28
    var barWidth: Double = Constants.Range.size
    var leftKnobColor = Color.white
    var rightKnobColor = Color.white
    var backgroundTrackColor = Color.DesignSystem.Divider
    var selectedTrackColor = Color.DesignSystem.Active
    /// Slider min & max static and dynamic labels value color
    var sliderMinMaxValuesColor = Color.DesignSystem.Active
    /// Set slider min & max Label values
    var lableWidth: Double = 55
    var lableColor = Color.DesignSystem.FieldBg

    var minLabel: String {
        String(format: "%.0f", (CGFloat(scaleMinValue) / barWidth) * 100)
    }
    var maxLabel: String {
        String(format: "%.0f", (CGFloat(scaleMaxValue) / barWidth) * 100)
    }
    private let heightformatStyle = Measurement<UnitLength>.FormatStyle(width: .abbreviated, usage: .personHeight)
    var heightMinValue: Measurement<UnitLength> {
        Measurement(value: (scaleMinValue / barWidth) * 100, unit: UnitLength.inches)
    }
    var heightMaxValue: Measurement<UnitLength> {
        Measurement(value: (scaleMaxValue / barWidth) * 100, unit: UnitLength.inches)
    }
    var body: some View {
        VStack {
            /// `Slider` start & end static values show in view
            HStack {
                // start value
                Text(heightMinValue, format: heightformatStyle)
                    .monospacedDigit()
                    .frame(width: lableWidth, height: 40, alignment: .center)
                    .foregroundColor(sliderMinMaxValuesColor)
                    .background(lableColor, in: RoundedRectangle(cornerRadius: 8))
                Spacer()
                // end value
                Text(heightMaxValue, format: heightformatStyle)
                    .monospacedDigit()
                    .frame(width: lableWidth, height: 40, alignment: .center)
                    .foregroundColor(sliderMinMaxValuesColor)
                    .background(lableColor, in: RoundedRectangle(cornerRadius: 8))
            }
            .frame(width: (barWidth + (rightKnobWidth * 2)))
            .padding(.horizontal)

            /// `Slider` track view
            ZStack(alignment: .leading) {
                // background track view
                Capsule()
                    .fill(backgroundTrackColor)
                    .frame(width: (barWidth + (rightKnobWidth * 2)), height: 6)

                // selected track view
                Capsule()
                    .fill(selectedTrackColor)
                    .frame(width: scaleMaxValue - scaleMinValue, height: 6)
                    .offset(x: scaleMinValue + rightKnobWidth)

                HStack(spacing: 0) {
                    // minimum value glob view
                    Circle()
                        .fill(leftKnobColor)
                        .shadow(color: Color.black.opacity(0.2), radius: 12, x: 0, y: 6)
                        .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 0.5)
                        .frame(width: leftKnobWidth, height: leftKnobHeight)
                        .offset(x: scaleMinValue)
                        .gesture(DragGesture().onChanged({ value in
                            /// drag validation
                            if value.location.x >= 0 && value.location.x <= scaleMaxValue {
                                scaleMinValue = value.location.x
                            }
                        })
                        )

                    // maximum value glob view
                    Circle()
                        .fill(rightKnobColor)
                        .shadow(color: Color.black.opacity(0.2), radius: 12, x: 0, y: 6)
                        .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 0.5)
                        .frame(width: rightKnobWidth, height: rightKnobHeight)
                        .offset(x: scaleMaxValue)
                        .gesture(DragGesture().onChanged({ value in
                            /// drag validation
                            if value.location.x <= barWidth && value.location.x >= scaleMinValue {
                                scaleMaxValue = value.location.x
                            }
                        })
                        )
                }
            }
        }
    }
}

enum Constants {
    enum Range {
        static let size = UIScreen.main.bounds.width - 100
    }
}

extension Color {
    enum DesignSystem {
        public static let Active = Color.green
        public static let FieldBg = Color.black
        public static let Divider = Color.gray
    }
}


Solution

  • I have made this one open-source... you can check out its code as it implements very much what you are looking for github.com/diegotid/circular-range-slider

    The slider components must have both the selected range and the bounds as separate parameters and map them

    public struct CircularRangeSlider: View {
        @Binding var range: ClosedRange<Double>
        var bounds: ClosedRange<Double>
    

    Then add something to remap if bounds change

            .onChange(of: bounds) { oldValue, _ in
                clampRangeIfNeeded(fromPriorBounds: oldValue)
            }
    

    And finally, update the range parameter on dragging...

            DragGesture()
                .onChanged { value in
                    let angle: Angle = angleFromDrag(location: value.location)
                    if draggingHandle == handle || draggingHandle == nil {
                        if let last: Angle = lastHapticAngle {
                            if abs(angle.degrees - last.degrees) > 4 {
                                UIImpactFeedbackGenerator(style: .light).impactOccurred()
                                lastHapticAngle = angle
                            }
                        } else {
                            UIImpactFeedbackGenerator(style: .light).impactOccurred()
                            lastHapticAngle = angle
                        }
                        var arcMoves: Bool = true
                        var newLower: Double = range.lowerBound
                        var newUpper: Double = range.upperBound
                        if [.start, .arc].contains(handle) {
                            if draggingHandle == nil {
                                startDragOffset = Angle(degrees: angle.degrees - rangeDegrees.lowerBound)
                            }
                            newLower = moveStartHandleResultingInValue(to: angle)
                            arcMoves = arcMoves
                                && newLower != range.lowerBound
                                && newLower > bounds.lowerBound
                        }
                        if [.end, .arc].contains(handle) {
                            if draggingHandle == nil {
                                endDragOffset = Angle(degrees: angle.degrees - rangeDegrees.upperBound)
                            }
                            newUpper = moveEndHandleResultingInValue(to: angle)
                            arcMoves = arcMoves
                                && newUpper != range.upperBound
                                && newUpper < bounds.upperBound
                        }
                        if handle != .arc || arcMoves {
                            newLower = snapToStep(newLower, for: .start)
                            newUpper = snapToStep(newUpper, for: .end)
                            self.range = newLower...newUpper
                        }
                        if draggingHandle == nil {
                            draggingHandle = handle
                        }
                    }
                }
                .onEnded { _ in
                    draggingHandle = nil
                    lastHapticAngle = nil
                }