Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Add ticker to snapping UISlider

I am having some issues creating a custom UISlider that snaps at certain values, and also have tick marks at those values. I have the snapping part working, but the tick marks is an issue. The Slider snaps in 12 positions, every 5 from 15-70. I've tried a variety of approaches, including adding 12 subviews to a stack view and placing that on the slider, and also calculating the step width between each. Both approaches placed the ticks not where the thumb snaps to. Does anyone know how to do this correctly?

Here's the second approach:

        let stepCount = 12
        print("THE WIDTH: \(self.bounds.width)")
        guard let imageWidth = self.currentThumbImage?.size.width else {return}

        guard let imageWidth = self.currentThumbImage?.size.width else {return}
        let stepWidth = bounds.width / CGFloat(stepCount)
        for i in 0..<stepCount {
            let view = UIView(frame: CGRect(x: stepWidth / 2 + stepWidth * CGFloat(i) - imageWidth / 2, y: 0, width: 1, height: 29))
            view.backgroundColor = .lightGray
            self.insertSubview(view, at: 0)
        }

Here is the snapping code:

@objc func valueChanged(_ sender: UISlider){
        let step: Float = 5
        let roundedValue = round(sender.value / step) * step
        self.value = roundedValue
        delegate?.sliderChanged(Int(roundedValue), sender)
    }
like image 511
aFella Avatar asked Sep 15 '25 08:09

aFella


1 Answers

There are various issues you need to deal width.

To begin with, take a look at these three "default" UISlider controls. The thumb is offset vertically so we can see the track, and the dashed red outline is the slider frame:

enter image description here

  • The top one is at min value (as far left as it goes)
  • the middle one is at 50%
  • and the bottom one is at max value (as far right as it goes)

As we can see, the horizontal center of the thumb is NOT at the ends (the bounds) of the track rect.

If we want the tick marks to line up with the horizontal center of the thumb, we'll need to calculate the x-positions based on the origin and width of the thumb-centers at minimum and maximum values:

enter image description here

The next issue is that you may not want the track rect / images to extend to the left/right of the tick marks.

In that case, we need to clear the built-in track images and draw our own:

enter image description here

Here is some example code that you could try working with:

protocol TickerSliderDelegate: NSObject {
    func sliderChanged(_ newValue: Int, sender: Any)
}
extension TickerSliderDelegate {
    // make this delegate func optional
    func sliderChanged(_ newValue: Int, sender: Any) {}
}

class TickerSlider: UISlider {
    
    var delegate: TickerSliderDelegate?
    
    var stepCount = 12
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        // clear min and max track images
        //  because we'll be drawing our own
        setMinimumTrackImage(UIImage(), for: [])
        setMaximumTrackImage(UIImage(), for: [])
    }
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        
        // get the track rect
        let trackR: CGRect = self.trackRect(forBounds: bounds)
        
        // get the thumb rect at min and max values
        let minThumbR: CGRect = self.thumbRect(forBounds: bounds, trackRect: trackR, value: minimumValue)
        let maxThumbR: CGRect = self.thumbRect(forBounds: bounds, trackRect: trackR, value: maximumValue)
        
        // usable width is center of thumb to center of thumb at min and max values
        let usableWidth: CGFloat = maxThumbR.midX - minThumbR.midX
        
        // Tick Height (or use desired explicit height)
        let tickHeight: CGFloat = bounds.height
        
        // "gap" between tick marks
        let stepWidth: CGFloat = usableWidth / CGFloat(stepCount)
        
        // a reusable path
        var pth: UIBezierPath!
        
        // a reusable point
        var pt: CGPoint!
                
        // new path
        pth = UIBezierPath()
        
        // left end of our track rect
        pt = CGPoint(x: minThumbR.midX, y: bounds.height * 0.5)
        
        // top of vertical tick lines
        pt.y = (bounds.height - tickHeight) * 0.5
        
        // we have to draw stepCount + 1 lines
        //  so use
        //      0...stepCount
        //  not
        //      0..<stepCount
        for _ in 0...stepCount {
            pth.move(to: pt)
            pth.addLine(to: CGPoint(x: pt.x, y: pt.y + tickHeight))
            pt.x += stepWidth
        }
        UIColor.lightGray.setStroke()
        pth.stroke()
        
        // new path
        pth = UIBezierPath()
        
        // left end of our track lines
        pt = CGPoint(x: minThumbR.midX, y: bounds.height * 0.5)
        
        // move to left end
        pth.move(to: pt)
        
        // draw the "right-side" of the track first
        //  it will be the full width of "our track"
        pth.addLine(to: CGPoint(x: pt.x + usableWidth, y: pt.y))
        pth.lineWidth = 3
        UIColor.lightGray.setStroke()
        pth.stroke()
        
        // new path
        pth = UIBezierPath()
        
        // move to left end
        pth.move(to: pt)
        
        // draw the "left-side" of the track on top of the "right-side"
        //  at percentage width
        let rng: Float = maximumValue - minimumValue
        let val: Float = value - minimumValue
        let pct: Float = val / rng
        pth.addLine(to: CGPoint(x: pt.x + (usableWidth * CGFloat(pct)), y: pt.y))
        pth.lineWidth = 3
        UIColor.systemBlue.setStroke()
        pth.stroke()

    }
    
    override func setValue(_ value: Float, animated: Bool) {
        // don't allow value outside range of min and max values
        let newVal: Float = min(max(minimumValue, value), maximumValue)
        super.setValue(newVal, animated: animated)
        
        // we need to trigger draw() when the value changes
        setNeedsDisplay()
        let steps: Float = Float(stepCount)
        let rng: Float = maximumValue - minimumValue
        // get the percentage along the track
        let pct: Float = newVal / rng
        // use that pct to get the rounded step position
        let pos: Float = round(steps * pct)
        // tell the delegate which Tick the thumb snapped to
        delegate?.sliderChanged(Int(pos), sender: self)
    }
    override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
        super.endTracking(touch, with: event)
        
        let steps: Float = Float(stepCount)
        let rng: Float = maximumValue - minimumValue
        // get the percentage along the track
        let pct: Float = value / rng
        // use that pct to get the rounded step position
        let pos: Float = round(steps * pct)
        // use that pos to calculate the new percentage
        let newPct: Float = (pos / steps)
        let newVal: Float = minimumValue + (rng * newPct)
        self.value = newVal
    }
    override var bounds: CGRect {
        willSet {
            // we need to trigger draw() when the bounds changes
            setNeedsDisplay()
        }
    }
    
}

Note: this is just one approach to this task (and is Example Code Only). Searching turns up many other approaches / examples / etc that you may want to take a look at.


Edit

This is a very slightly modified version to get a more "point accurate" tick-mark / thumb alignment:

class TickerSlider: UISlider {
    
    var delegate: TickerSliderDelegate?
    
    var stepCount = 12
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {

        // clear min and max track images
        //  because we'll be drawing our own
        setMinimumTrackImage(UIImage(), for: [])
        setMaximumTrackImage(UIImage(), for: [])
        
        // if we're using a custom thumb image
        if let img = UIImage(named: "CustomThumbA") {
            self.setThumbImage(img, for: [])
        }

    }
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        
        // get the track rect
        let trackR: CGRect = self.trackRect(forBounds: bounds)
        
        // get the thumb rect at min and max values
        let minThumbR: CGRect = self.thumbRect(forBounds: bounds, trackRect: trackR, value: minimumValue)
        let maxThumbR: CGRect = self.thumbRect(forBounds: bounds, trackRect: trackR, value: maximumValue)
        
        // usable width is center of thumb to center of thumb at min and max values
        let usableWidth: CGFloat = maxThumbR.midX - minThumbR.midX
        
        // Tick Height (or use desired explicit height)
        let tickHeight: CGFloat = bounds.height
        
        // a reusable path
        var pth: UIBezierPath!
        
        // a reusable point
        var pt: CGPoint!
        
        // new path
        pth = UIBezierPath()
        
        pt = .zero
        
        // top of vertical tick lines
        pt.y = (bounds.height - tickHeight) * 0.5
        
        // we have to draw stepCount + 1 lines
        //  so use
        //      0...stepCount
        //  not
        //      0..<stepCount
        for i in 0...stepCount {
            // get center of Thumb at each "step"
            let aThumbR: CGRect = self.thumbRect(forBounds: bounds, trackRect: trackR, value: Float(i) / Float(stepCount))
            pt.x = aThumbR.midX
            pth.move(to: pt)
            pth.addLine(to: CGPoint(x: pt.x, y: pt.y + tickHeight))
        }
        UIColor.lightGray.setStroke()
        pth.stroke()
        
        // new path
        pth = UIBezierPath()
        
        // left end of our track lines
        pt = CGPoint(x: minThumbR.midX, y: bounds.height * 0.5)
        
        // move to left end
        pth.move(to: pt)
        
        // draw the "right-side" of the track first
        //  it will be the full width of "our track"
        pth.addLine(to: CGPoint(x: pt.x + usableWidth, y: pt.y))
        pth.lineWidth = 3
        UIColor.lightGray.setStroke()
        pth.stroke()
        
        // new path
        pth = UIBezierPath()
        
        // move to left end
        pth.move(to: pt)
        
        // draw the "left-side" of the track on top of the "right-side"
        //  at percentage width
        let rng: Float = maximumValue - minimumValue
        let val: Float = value - minimumValue
        let pct: Float = val / rng
        pth.addLine(to: CGPoint(x: pt.x + (usableWidth * CGFloat(pct)), y: pt.y))
        pth.lineWidth = 3
        UIColor.systemBlue.setStroke()
        pth.stroke()
        
    }
    
    override func setValue(_ value: Float, animated: Bool) {
        // don't allow value outside range of min and max values
        let newVal: Float = min(max(minimumValue, value), maximumValue)
        super.setValue(newVal, animated: animated)
        
        // we need to trigger draw() when the value changes
        setNeedsDisplay()
        let steps: Float = Float(stepCount)
        let rng: Float = maximumValue - minimumValue
        // get the percentage along the track
        let pct: Float = newVal / rng
        // use that pct to get the rounded step position
        let pos: Float = round(steps * pct)
        // tell the delegate which Tick the thumb snapped to
        delegate?.sliderChanged(Int(pos), sender: self)
    }
    override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
        super.endTracking(touch, with: event)
        
        let steps: Float = Float(stepCount)
        let rng: Float = maximumValue - minimumValue
        // get the percentage along the track
        let pct: Float = value / rng
        // use that pct to get the rounded step position
        let pos: Float = round(steps * pct)
        // use that pos to calculate the new percentage
        let newPct: Float = (pos / steps)
        let newVal: Float = minimumValue + (rng * newPct)
        self.value = newVal
    }
    override var bounds: CGRect {
        willSet {
            // we need to trigger draw() when the bounds changes
            setNeedsDisplay()
        }
    }
    
}

The primary difference is that, in our loop that draws the tick-mark lines, instead of calculating the "gap" values, we get the "thumb center" for each step:

    for i in 0...stepCount {
        // get center of Thumb at each "step"
        let aThumbR: CGRect = self.thumbRect(forBounds: bounds, trackRect: trackR, value: Float(i) / Float(stepCount))
        pt.x = aThumbR.midX
        pth.move(to: pt)
        pth.addLine(to: CGPoint(x: pt.x, y: pt.y + tickHeight))
    }

Here's an image using a custom thumb image, at steps 0, 1 & 2:

enter image description here

and the @2x (62x62) and @3x (93x93) images I used for the thumb:

enter image description here

enter image description here

like image 152
DonMag Avatar answered Sep 16 '25 23:09

DonMag