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)
}
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:
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:
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:
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:
and the @2x
(62x62) and @3x
(93x93) images I used for the thumb:
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