Search code examples
iosswifttimeruilabel

Labels displaying countdown sometimes out of sync after pausing. Rounding errors?


I have an app that does a countdown with a Timer. The countdown tracks multiple steps (all at the same intervals) as well as the total time left, and updates 2 separate UILabels accordingly. Occasionally, the labels will be out of sync.

I can't say for sure, but I think it might be only happening when I pause the countdown sometimes, and usually on steps later than the first step. It's most apparent on the last step when the two labels should be displaying the same exact thing, but will sometimes be 1 second off.

The other tricky thing is that sometimes pausing and resuming after the time has gone out of sync will get it back in sync.

My guess is I'm getting something weird happening in the pause code and/or the moving between steps, or maybe the calculating and formatting of TimeIntervals. Also I'm using rounded() on the calculated TimeIntervals because I noticed only updating the timer every 1s the labels would freeze and skip seconds a lot. But I'm unsure if that's the best way to solve this problem.

Here's the relevant code. (still need to work on refactoring but hopefully it's easy to follow, I'm still a beginner)

@IBAction func playPauseTapped(_ sender: Any) {
        if timerState == .running {
            //pause timer
            pauseAnimation()
            timer.invalidate()
            timerState = .paused
            pausedTime = Date()
            playPauseButton.setImage(UIImage(systemName: "play.circle"), for: .normal)
        } else if timerState == .paused {
            //resume paused timer
            guard let pause = pausedTime else { return }
            let pausedInterval = Date().timeIntervalSince(pause)
            startTime = startTime?.addingTimeInterval(pausedInterval)
            endTime = endTime?.addingTimeInterval(pausedInterval)
            currentStepEndTime = currentStepEndTime?.addingTimeInterval(pausedInterval)
            pausedTime = nil
            startTimer()
            resumeAnimation()
            timerState = .running
            playPauseButton.setImage(UIImage(systemName: "pause.circle"), for: .normal)
        } else {
            //first run of brand new timer
            startTimer()
            startProgressBar()
            startTime = Date()
            if let totalTime = totalTime {
                endTime = startTime?.addingTimeInterval(totalTime)
            }
            currentStepEndTime = Date().addingTimeInterval(recipeInterval)
            timerState = .running
            playPauseButton.setImage(UIImage(systemName: "pause.circle"), for: .normal)

            currentWater += recipeWater[recipeIndex]
            currentWeightLabel.text = "\(currentWater)g"
        }
    }

func startTimer() {
        timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(runTimer), userInfo: nil, repeats: true)
    }

@objc func runTimer() {
        let currentTime = Date()

        guard let totalTimeLeft = endTime?.timeIntervalSince(currentTime).rounded() else { return }

        guard let currentInterval = currentStepEndTime?.timeIntervalSince(currentTime).rounded() else { return }

        //end of current step
        if currentInterval <= 0 {
            //check if end of recipe
            if recipeIndex < recipeWater.count - 1 {
                //move to next step
                totalTimeLabel.text = totalTimeLeft.stringFromTimeInterval()
                currentStepEndTime = Date().addingTimeInterval(recipeInterval)
                startProgressBar()
                currentStepTimeLabel.text = recipeInterval.stringFromTimeInterval()
                stepsTime += recipeInterval
                recipeIndex += 1
                //update some ui
            } else {
                //last step
                currentStepTimeLabel.text = "00:00"
                totalTimeLabel.text = "00:00"
                timer.invalidate()
                //alert controller saying finished
            }
        } else {
            //update time labels
            currentStepTimeLabel.text = currentInterval.stringFromTimeInterval()
            totalTimeLabel.text = totalTimeLeft.stringFromTimeInterval()
        }

    }

extension TimeInterval {

    func stringFromTimeInterval() -> String {

        let time = NSInteger(self)

        let seconds = time % 60
        let minutes = (time / 60) % 60

        return String(format: "%0.2d:%0.2d",minutes,seconds)

    }
}

EDIT UPDATE: I tried a few different things but still kept having the same issue. I started testing with printing the TimeInterval and the formatted string to compare and see what's off. It's definitely some sort of rounding error.

Total - 173.50678288936615 / 02:54
Step - 39.00026595592499 / 00:39
Total - 172.5073879957199 / 02:53
Step - 38.00087106227875 / 00:38
Total - 171.1903439760208 / 02:51
Step - 36.68382704257965 / 00:37
Total - 170.19031596183777 / 02:50
Step - 35.683799028396606 / 00:36

As you can see, the total time skips from 2:53 to 2:51, but the step timer remains consistent. The reason is the TimeInterval for total goes from 172.5 which gets rounded up, to 171.19 which gets rounded down.

I've also watched the timer count down without touching pause, and it remains in sync reliably. So I've narrowed it down to my pause code.


Solution

  • Fixed my issue and posting here for posterity. I ended up making my totalTimeLeft and currentInterval global properties. Then, on pause and resume, instead of tracking the paused time and adding it to endTime, I just used the totalTimeLeft and currentInterval values that are still stored from the last Timer firing and doing endTime = Date().addingTimeInterval(totalTimeLeft) and the same with the interval time. This got rid of the paused time adding weird amounts that would mess up the rounding.