skip to Main Content

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?

2

Answers


  1. Chosen as BEST ANSWER

    While there isn't a specific event sent by a UIStepper when a set of autorepeating value changes has stopped, you can achieve a similar result by listening for the "touch canceled", "touch up inside", "touch up outside", and "touch drag exited" events.

    The updated code would look like the following:

    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 = true // changed
            stepper.autorepeat = true // changed
    
            stepper.addTarget(self, action: #selector(stepperChanged), for: .valueChanged)
            stepper.addTarget(self, action: #selector(stepperUpdated), for: [ .touchCancel, .touchUpInside, .touchUpOutside, .touchDragExit ])
    
            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
        }
    }
    

    Now the stepperChanged method is called for each continuous, autorepeating value change as well as simple discrete value changes.

    In most cases, when the user lifts their finger, the "touch up inside" event will be sent. The other touch events being used above handle cases of the user dragging their finger off of the stepper while pressing down.

    However, there are some edge cases where a user can press down on a stepper and then start dragging around the screen in such a way that none of those four touch events get sent even though the stepper isn't being updated any more. In those edge cases the label will have been updated properly but nothing is triggered to indicate that the user is done and the expensive update isn't called.

    The workaround for these edge cases is to setup a timer. The timer will get scheduled on each change, after first invalidating the previous timer, if any. The timer will also be invalidated when one of the four registered touch events is received. If the timer does eventually trigger, it is used to call the expensive update.

    Here's the final updated code with all of this in place:

    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 = true
            stepper.autorepeat = true
    
            stepper.addTarget(self, action: #selector(stepperChanged), for: .valueChanged)
            stepper.addTarget(self, action: #selector(stepperUpdated), for: [ .touchCancel, .touchUpInside, .touchUpOutside, .touchDragExit ])
    
            return stepper
        }()
    
        var stepperTimer: Timer?
    
        func resetStepperTimer() {
            stepperTimer?.invalidate()
            stepperTimer = nil
        }
    
        @objc func stepperChanged(_ stepper: UIStepper) {
            resetStepperTimer()
    
            someLabel.text = "(Int(stepper.value))"
            let feedback = UISelectionFeedbackGenerator()
            feedback.selectionChanged()
    
            let timer = Timer.scheduledTimer(withTimeInterval: 0.7, repeats: false, block: { [weak self] timer in
                self?.stepperTimer = nil
                self?.stepperUpdated(stepper)
            })
            RunLoop.current.add(timer, forMode: .common)
            stepperTimer = timer
        }
    
        @objc func stepperUpdated(_ stepper: UIStepper) {
            resetStepperTimer()
    
            expensiveAsyncDataUpdate(with: Int(stepper.value))
        }
    
        func expensiveAsyncDataUpdate(with value: Int) {
            // Perform an async update of the database and perform an iCloud sync
        }
    }
    

    The choice of the timer interval of 0.7 seconds is based on the slowest autorepeating update and not leaving the user waiting too long. The chosen value needs to be longer than the slowest delay between autorepeating values. The slowest delay is when the user first starts pressing on the stepper. A value of 1.0 seconds might be a better choice. But don't use too large of a value. You don't want to allow the user to start another press on the stepper before the timer triggers.


  2. 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search