iosswiftuikituistepper

How to detect when a continuous, autorepeating UIStepper is done updating?


I have a UIStepper setup. Each time the user taps on the stepper I respond to the "value changed" event and update a label with the new value and I perform a relatively expensive database update and iCloud sync with the new value.

Here is a greatly simplified representation of my current stepper code:

class MyViewController: UIViewController {
    var someLabel: UILabel!

    lazy var countStepper: UIStepper = {
        let stepper = UIStepper()
        stepper.minimumValue = 0
        stepper.maximumValue = 99999
        stepper.wraps = false
        stepper.stepValue = 1
        stepper.isContinuous = false
        stepper.autorepeat = false

        stepper.addTarget(self, action: #selector(stepperChanged), for: .valueChanged)

        return stepper
    }()

    @objc func stepperChanged(_ stepper: UIStepper) {
        someLabel.text = "\(Int(stepper.value))"
        let feedback = UISelectionFeedbackGenerator()
        feedback.selectionChanged()

        expensiveAsyncDataUpdate(with: Int(stepper.value))
    }

    func expensiveAsyncDataUpdate(with value: Int) {
        // Perform an async update of the database and perform an iCloud sync
    }
}

All of this is working just fine. But now I want to support the stepper being continuous and autorepeating. As the value changes I want to keep the label updated showing the stepper's current value. But I need to avoid performing the expensive update on every change of value. I want to wait until the user lifts their finger off of the stepper to perform the expensive update.

For example, let's say the stepper currently has a value of 10. The user presses and holds on the + side of the stepper. As the stepper reports changing values, the label is updated. When the user sees the stepper reach 25, the user stops pressing on the stepper. At this point I need to perform the expensive update with the new value of 25. I don't want to perform the expensive update for each change to 11, 12, 13, etc. all the way up to 25.

Another example, if the user simply taps on the + once, I want to update the label and perform the expensive update. If the user taps a few times, I'll still treat each tap as a label update and expensive update. I only want to avoid the expensive update if the user presses and holds at all and then perform one expensive update upon release.

As far as I can tell there is no event for this. The "value changed" event still allows me to update the label with each value. But there's no specific event to tell me when the user is done pressing on the stepper.

How can avoid the expensive update on a sequence of autoupdates?


Solution

  • Another solution is to create a subclass of UIStepper that sends a custom control event when the user releases the stepper.

    First, define a custom control event (UIControl.Event defines a custom event range with .applicationReserved). So make sure the custom event's value is in that range:

    extension UIControl.Event {
        static let complete = UIControl.Event(rawValue: 1 << 24)
    }
    

    The custom stepper only needs to override the endTracking and cancelTracking methods.

    class CompleteStepper: UIStepper {
        // Handles all of the normal cases
        override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
            super.endTracking(touch, with: event)
    
            sendActions(for: .complete)
        }
    
        // Handles a few edge cases requiring the user to perform unlikely dragging while using the stepper
        override func cancelTracking(with event: UIEvent?) {
            super.cancelTracking(with: event)
    
            sendActions(for: .complete)
        }
    }
    

    Now the original code can be updated as follows:

    class MyViewController: UIViewController {
        var someLabel: UILabel!
    
        lazy var countStepper: CompleteStepper = {
            let stepper = CompleteStepper()
            stepper.minimumValue = 0
            stepper.maximumValue = 99999
            stepper.wraps = false
            stepper.stepValue = 1
            stepper.isContinuous = true // changed
            stepper.autorepeat = true // changed
    
            stepper.addTarget(self, action: #selector(stepperChanged), for: .valueChanged)
            stepper.addTarget(self, action: #selector(stepperUpdated), for: .complete)
    
            return stepper
        }()
    
        @objc func stepperChanged(_ stepper: UIStepper) {
            someLabel.text = "\(Int(stepper.value))"
            let feedback = UISelectionFeedbackGenerator()
            feedback.selectionChanged()
        }
    
        @objc func stepperUpdated(_ stepper: UIStepper) {
            expensiveAsyncDataUpdate(with: Int(stepper.value))
        }
    
        func expensiveAsyncDataUpdate(with value: Int) {
            // Perform an async update of the database and perform an iCloud sync
        }
    }
    

    That's it. No need for a timer or handling lots of different events. The custom class makes it much easier to use this type of stepper in other parts of the application.