Attempting to add scrubbing to UISlider

I made a custom slider by subclassing UISlider, and I'm trying to add scrubbing functionality to it, but for some reason the scrubbing is barely even noticeable at 0.1? In my code, I tried multiplying change in x distance by the scrubbing value, but it doesn't seem to work. Also, when I manually set the scrubbing speed to a lower value such as 0.01, it does go slower but it looks really laggy and weird?? What am I doing wrong?

Any help or advice would be greatly appreciated!

Subclass of UISlider:

class SizeSliderView: UISlider {
    private var previousLocation: CGPoint?
    private var currentLocation: CGPoint?
    private var translation: CGFloat = 0
    private var scrubbingSpeed: CGFloat = 1

    private var defaultDiameter: Float
    
    init(startValue: Float = 0, defaultDiameter: Float = 500) {
        self.defaultDiameter = defaultDiameter
        super.init(frame: .zero)

        value = clamp(value: startValue, min: minimumValue, max: maximumValue)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        clear()
        createThumbImageView()
        addTarget(self, action: #selector(valueChanged(_:)), for: .valueChanged)
    }
    
    // Clear elements
    private func clear() {
        tintColor = .clear
        maximumTrackTintColor = .clear
        backgroundColor = .clear
        thumbTintColor = .clear
    }
    
    // Call when value is changed
    @objc private func valueChanged(_ sender: SizeSliderView) {
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        CATransaction.commit()
        createThumbImageView()
    }
    
    // Create thumb image with thumb diameter dependent on thumb value
    private func createThumbImageView() {
        let thumbDiameter = CGFloat(defaultDiameter * value)
        let thumbImage = UIColor.red.circle(CGSize(width: thumbDiameter, height: thumbDiameter))
        setThumbImage(thumbImage, for: .normal)
        setThumbImage(thumbImage, for: .highlighted)
        setThumbImage(thumbImage, for: .application)
        setThumbImage(thumbImage, for: .disabled)
        setThumbImage(thumbImage, for: .focused)
        setThumbImage(thumbImage, for: .reserved)
        setThumbImage(thumbImage, for: .selected)
    }
    
    // Return true so touches are tracked
    override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        let location = touch.location(in: self)
        // Ensure that start location is on thumb
        let thumbDiameter = CGFloat(defaultDiameter * value)
        if location.x < bounds.width / 2 - thumbDiameter / 2 || location.x > bounds.width / 2 + thumbDiameter / 2 || location.y < 0 || location.y > thumbDiameter {
            return false
        }
        previousLocation = location
        super.beginTracking(touch, with: event)
        return true
    }
    
    // Track based on moving slider
    override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
        guard isTracking else { return false }
        guard let previousLocation = previousLocation else { return false }
        // Reference
        // location: location of touch relative to device
        // delta location: change in touch location WITH scrubbing
        // adjusted location: location of touch to slider bounds (WITH scrubbing)
        // translation: location of slider relative to device
        let location = touch.location(in: self)
        currentLocation = location
        scrubbingSpeed = getScrubbingSpeed(for: location.y - 50)
        let deltaLocation = (location.x - previousLocation.x) * scrubbingSpeed
        var adjustedLocation = deltaLocation + previousLocation.x - translation
        if adjustedLocation < 0 {
            translation += adjustedLocation
            adjustedLocation = deltaLocation + previousLocation.x - translation
        } else if adjustedLocation > bounds.width {
            translation += adjustedLocation - bounds.width
            adjustedLocation = deltaLocation + previousLocation.x - translation
        }
        self.previousLocation = CGPoint(x: deltaLocation + previousLocation.x, y: location.y)
        let newValue = Float(adjustedLocation / bounds.width) * (maximumValue - minimumValue) + minimumValue
        setValue(newValue, animated: false)
        sendActions(for: .valueChanged)
        return true
    }
    
    // Reset start and current location
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.currentLocation = nil
        self.translation = 0
        super.touchesEnded(touches, with: event)
    }
    
    // Thumb location follows current location and resets in middle
    override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
        let thumbDiameter = CGFloat(defaultDiameter * value)
        let origin = CGPoint(x: (currentLocation?.x ?? bounds.width / 2) - thumbDiameter / 2, y: (currentLocation?.y ?? thumbDiameter / 2) - thumbDiameter / 2)
        return CGRect(origin: origin, size: CGSize(width: thumbDiameter, height: thumbDiameter))
    }
    
    private func getScrubbingSpeed(for value: CGFloat) -> CGFloat {
        switch value {
            case 0:
                return 1
            case 0...50:
                return 0.5
            case 50...100:
                return 0.25
            case 100...:
                return 0.1
            default:
                return 1
        }
    }
    
    private func clamp(value: Float, min: Float, max: Float) -> Float {
        if value < min {
            return min
        } else if value > max {
            return max
        } else {
            return value
        }
    }
}

UIView representative:

struct SizeSlider: UIViewRepresentable {
    private var startValue: Float
    private var defaultDiameter: Float
    
    init(startValue: Float, defaultDiameter: Float) {
        self.startValue = startValue
        self.defaultDiameter = defaultDiameter
    }
    
    func makeUIView(context: Context) -> SizeSliderView {
        let view = SizeSliderView(startValue: startValue, defaultDiameter: defaultDiameter)
        view.minimumValue = 0.1
        view.maximumValue = 1
        return view
    }

    func updateUIView(_ uiView: SizeSliderView, context: Context) {}
}

Content view:

struct ContentView: View {
    var body: some View {
        SizeSlider(startValue: 0.20, defaultDiameter: 100)
            .frame(width: 400)
    }
}

Could you explain what you mean and expect exactly by 'scrubbing' ?

This line does not compile (UIColor has no circle attribute)

        let thumbImage = UIColor.red.circle(CGSize(width: thumbDiameter, height: thumbDiameter))

Is there a line missing ? Or did you define an extension (if so, please provide it).

I change the thumbImage to some systemImage.

It compiles, but screen remains empty. So I added a background to SizeSlider and I get it on screen. But just a coloured rect, not a slider.

Could you show what you get ?

I completed code as follows, and now thumb (and slider) increase size. But thumb can get out of slider, is not saved at the end of school… Looks like there are missing parts in your code.

extension UIImage {
    static func from(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) -> UIImage {
        let format = UIGraphicsImageRendererFormat()
        format.scale = 1
        return UIGraphicsImageRenderer(size: size, format: format).image { context in
            color.setFill()
            context.fill(CGRect(origin: .zero, size: size))
        }
    }
}

class SizeSliderView: UISlider { private var previousLocation: CGPoint? private var currentLocation: CGPoint? private var translation: CGFloat = 0 private var scrubbingSpeed: CGFloat = 1

private var defaultDiameter: Float

init(startValue: Float = 0, defaultDiameter: Float = 500) {
    self.defaultDiameter = defaultDiameter
    super.init(frame: .zero)

    value = clamp(value: startValue, min: minimumValue, max: maximumValue)
}

required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

override func draw(_ rect: CGRect) {
    super.draw(rect)
    clear()
    createThumbImageView()
    addTarget(self, action: #selector(valueChanged(_:)), for: .valueChanged)
}

// Clear elements
private func clear() {
    tintColor = .clear
    maximumTrackTintColor = .clear
    backgroundColor = .clear
    thumbTintColor = .clear
}

// Call when value is changed
@objc private func valueChanged(_ sender: SizeSliderView) {
    CATransaction.begin()
    CATransaction.setDisableActions(true)
    CATransaction.commit()
    createThumbImageView()
}

// Create thumb image with thumb diameter dependent on thumb value
private func createThumbImageView() {
    let thumbDiameter = CGFloat(defaultDiameter * value)

// let thumbImage = UIColor.red.circle(CGSize(width: thumbDiameter, height: thumbDiameter))

    let thumbImage = UIImage.from(color: .blue, size: CGSize(width: thumbDiameter, height: thumbDiameter))
    setThumbImage(thumbImage, for: .normal)
    setThumbImage(thumbImage, for: .highlighted)
    setThumbImage(thumbImage, for: .application)
    setThumbImage(thumbImage, for: .disabled)
    setThumbImage(thumbImage, for: .focused)
    setThumbImage(thumbImage, for: .reserved)
    setThumbImage(thumbImage, for: .selected)
}

// Return true so touches are tracked
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
    let location = touch.location(in: self)
    // Ensure that start location is on thumb
    let thumbDiameter = CGFloat(defaultDiameter * value)
    print(location, bounds, thumbDiameter)
    if location.x < bounds.width / 2 - thumbDiameter / 2 || location.x > bounds.width / 2 + thumbDiameter / 2 || location.y < 0 || location.y > thumbDiameter {
        return false
    }
    previousLocation = location
    super.beginTracking(touch, with: event)
    return true
}

// Track based on moving slider
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
    guard isTracking else { return false }
    guard let previousLocation = previousLocation else { return false }
    // Reference
    // location: location of touch relative to device
    // delta location: change in touch location WITH scrubbing
    // adjusted location: location of touch to slider bounds (WITH scrubbing)
    // translation: location of slider relative to device
    let location = touch.location(in: self)
    currentLocation = location
    scrubbingSpeed = getScrubbingSpeed(for: location.y - 50)
    let deltaLocation = (location.x - previousLocation.x) * scrubbingSpeed
    var adjustedLocation = deltaLocation + previousLocation.x - translation
    if adjustedLocation < 0 {
        translation += adjustedLocation
        adjustedLocation = deltaLocation + previousLocation.x - translation
    } else if adjustedLocation > bounds.width {
        translation += adjustedLocation - bounds.width
        adjustedLocation = deltaLocation + previousLocation.x - translation
    }
    self.previousLocation = CGPoint(x: deltaLocation + previousLocation.x, y: location.y)
    let newValue = Float(adjustedLocation / bounds.width) * (maximumValue - minimumValue) + minimumValue
    setValue(newValue, animated: false)
    sendActions(for: .valueChanged)
    return true
}

// Reset start and current location
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    self.currentLocation = nil
    self.translation = 0
    super.touchesEnded(touches, with: event)
}

// Thumb location follows current location and resets in middle
override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
    let thumbDiameter = CGFloat(defaultDiameter * value)
    let origin = CGPoint(x: (currentLocation?.x ?? bounds.width / 2) - thumbDiameter / 2, y: (currentLocation?.y ?? thumbDiameter / 2) - thumbDiameter / 2)
    return CGRect(origin: origin, size: CGSize(width: thumbDiameter, height: thumbDiameter))
}

private func getScrubbingSpeed(for value: CGFloat) -> CGFloat {
    switch value {
        case 0:
            return 1
        case 0...50:
            return 0.5
        case 50...100:
            return 0.25
        case 100...:
            return 0.1
        default:
            return 1
    }
}

private func clamp(value: Float, min: Float, max: Float) -> Float {
    if value < min {
        return min
    } else if value > max {
        return max
    } else {
        return value
    }
}

}

//UIView representative: struct SizeSlider: UIViewRepresentable { private var startValue: Float private var defaultDiameter: Float

init(startValue: Float, defaultDiameter: Float) {
    self.startValue = startValue
    self.defaultDiameter = defaultDiameter
}

func makeUIView(context: Context) -> SizeSliderView {
    let view = SizeSliderView(startValue: startValue, defaultDiameter: defaultDiameter)
    view.minimumValue = 0.1
    view.maximumValue = 1
    return view
}

func updateUIView(_ uiView: SizeSliderView, context: Context) { }

}

struct ContentView: View { var body: some View { SizeSlider(startValue: 0.20, defaultDiameter: 100) .frame(width: 400) .background(.red). // Just to see something .opacity(0.1) } }

Edit:

I forgot to mention that I made an extension for UIColor:

extension UIColor {
    func circle(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage {
        return UIGraphicsImageRenderer(size: size).image { rendererContext in
            self.setFill()
            UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
            // rendererContext.fill(CGRect(origin: .zero, size: size))
        }
    }
}

And the thumb is supposed to follow your touch location and grow and shrink depending on the value. Additionally, if the thumb goes past the end of the slider, translation is supposed to essentially "move" the slider so it's as if the entire slider was dragged to where you moved the thumb.

OK, it's better.

What is the problem, precisely ? What do you get ? What did you expect ? Please show screenshots.

Here are my screenshots showing it is working, but thumb can move out of slider, which is probably not expected:

You can force the thumb inside slider by making the following change:

    override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
        let thumbDiameter = CGFloat(defaultDiameter * value)
        let origin = CGPoint(x: (currentLocation?.x ?? bounds.width / 2) - thumbDiameter / 2, y: 0 /*(currentLocation?.y ?? thumbDiameter / 2) - thumbDiameter / 2*/)  // <<-- CHANGED
        return CGRect(origin: origin, size: CGSize(width: thumbDiameter, height: thumbDiameter))
    }

@Claude31 I'm trying to go for something like this, where if your finger is above the slider, the values change slower. The thumb thing probably made it more confusing, so you can ignore that.

Here's a video of kind of what I'm going for: https://www.dropbox.com/scl/fi/vy2deau3tdgx0nd4yogzo/ScreenRecording_08-15-2024-09-52-44_1.MP4?rlkey=xo3jlc7g6n5ervhw0ft7p01x2&st=v35wnpsk&dl=0

In the video, when I drag the thumb above the slider and move it, the values change more slowly so the thumb size also changes slowly.

OK, so getting thumb out of slider is expected.

But I don't understand the logic.

At the end, the location during tracking is

        let location = touch.location(in: self)

During update, thumb moves slower, but immediately after the mouse position prevails, which annihilate the effect.

You should change the touch position as well depending on speed.

Or change the cursor move speed programmatically, to fit with the speed you selected. An implementation here: https://stackoverflow.com/questions/47254745/change-mac-cursor-speed-programmatically

Ohhh, I see what you mean! Do you know how I might go about adjusting the position with the scrub speed? I'm not really sure how to make it move slowly "around" a specific value. Maybe I could store the previous value and use that??

I fear it may be a bit tricky.

I see a few options:

    let origin = CGPoint(x: (currentLocation?.x ?? bounds.width / 2) - thumbDiameter / 2, y: (currentLocation?.y ?? thumbDiameter / 2) - thumbDiameter / 2)

so that x is not currentLocation (mouse position), but the adjusted value. Problem is that you will not be able to move the thumb on the full range…

Attempting to add scrubbing to UISlider
 
 
Q