I'm still pretty new to coding and Swift. So bear with me.
Problem Statement : I've got a stopwatch style app that has two concurrent timers start at the same time and display in a mm:ss.SS format, but one is designed to reset to 0 at specific intervals automatically while the other keeps going and tracks total time.
Similar to a "lap" function but it does it automatically. The problem I've encountered is that occasionally the timers aren't perfectly synced up when the user pauses the timers. Since the reset happens at an exact second, both timers should have identical hundredths of a second, while the seconds and minutes will obviously be different. But sometimes the hundredths will be off by .01 or more.
Now, I know Timer isn't designed to be perfectly accurate, and in practice on my app this isn't even a huge deal. My timer doesn't even need to be accurate to the hundredth of a second, and while running it's not noticeably off at all, only while paused. I could display fewer decimal places or none at all, but I prefer the style of showing the hundredths since it fits in well with the stock timer app style.
So if there's a way to make this work, I'd like to keep it.
Screenshot : screenshot
What I tried :
#IBAction func playPauseTapped(_ sender: Any) {
if timerState == .new {
//start new timer
startCurrentTimer()
startTotalTimer()
currentStartTime = Date.timeIntervalSinceReferenceDate
totalStartTime = Date.timeIntervalSinceReferenceDate
timerState = .running
//some ui updates
} else if timerState == .running {
//pause timer
totalTimer.invalidate()
currentTimer.invalidate()
timerState = .paused
pausedTime = Date()
//other ui updates
} else if timerState == .paused {
//resume paused timer
let pausedInterval = Date().timeIntervalSince(pausedTime!)
pausedIntervals.append(pausedInterval)
pausedIntervalsCurrent.append(pausedInterval)
pausedTime = nil
startCurrentTimer()
startTotalTimer()
timerState = .running
//other ui updates
}
}
func startTotalTimer() {
totalTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(runTotalTimer), userInfo: nil, repeats: true)
}
func startCurrentTimer() {
currentTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(runCurrentTimer), userInfo: nil, repeats: true)
}
func resetCurrentTimer() {
currentTimer.invalidate()
currentStartTime = Date.timeIntervalSinceReferenceDate
pausedIntervalsCurrent.removeAll()
startCurrentTimer()
}
#objc func runCurrentTimer() {
let currentTime = Date.timeIntervalSinceReferenceDate
//calculate total paused time
var pausedSeconds = pausedIntervalsCurrent.reduce(0) { $0 + $1 }
if let pausedTime = pausedTime {
pausedSeconds += Date().timeIntervalSince(pausedTime)
}
let currentElapsedTime: TimeInterval = currentTime - currentStartTime - pausedSeconds
currentStepTimeLabel.text = format(time: currentElapsedTime)
if currentElapsedTime >= recipeInterval {
if recipeIndex < recipeTime.count - 1 {
recipeIndex += 1
//ui updates
//reset timer to 0
resetCurrentTimer()
} else {
//last step
currentTimer.invalidate()
}
}
}
#objc func runTotalTimer() {
let currentTime = Date.timeIntervalSinceReferenceDate
//calculate total paused time
var pausedSeconds = pausedIntervals.reduce(0) { $0 + $1 }
if let pausedTime = pausedTime {
pausedSeconds += Date().timeIntervalSince(pausedTime)
}
let totalElapsedTime: TimeInterval = currentTime - totalStartTime - pausedSeconds
totalTimeLabel.text = format(time: totalElapsedTime)
if totalElapsedTime >= recipeTotalTime {
totalTimer.invalidate()
currentTimer.invalidate()
//ui updates
}
}
func format(time: TimeInterval) -> String {
//formats TimeInterval into mm:ss.SS
let formater = DateFormatter()
formater.dateFormat = "mm:ss.SS"
let date = Date(timeIntervalSinceReferenceDate: time)
return formater.string(from: date)
}
You should use a single timer. And when you need a reset to zero, save the current time to a variable.
When presenting the time in the UI, calculate the difference between the running total timer, and the time you saved previously.
Related
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.
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.
I try to implement simple player with UISlider to indicate at what time is current audio file.
In code I have added two observers:
slider.rx.value.subscribe(onNext: { value in
let totalTime = Float(CMTimeGetSeconds(self.player.currentItem!.duration))
let seconds = value * totalTime
let time = CMTime(seconds: Double(seconds), preferredTimescale: CMTimeScale(NSEC_PER_SEC))
self.player.seek(to: time)
}).disposed(by: bag)
let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
player.addPeriodicTimeObserver(forInterval: interval, queue: nil) { [weak self] time in
self?.updateSlider(with: time)
}
with one private function:
private func updateSlider(with time: CMTime) {
let currentTime = CMTimeGetSeconds(time)
var totalTime = CMTimeGetSeconds(player.currentItem!.duration)
if totalTime.isNaN {
totalTime = 0
}
startLabel.text = Int(currentTime).descriptiveDuration
endLabel.text = Int(totalTime).descriptiveDuration
slider.value = Float(currentTime / totalTime)
}
When audio plays, everything is fine and slider is pretty much updated. The problem occurs when I try to move slider manually while audio is playing, then it jumps. Why?
UPDATE:
I know why actually. Because I update it twice: manually and from player observer, but how to prevent from this behaviour? I have no idea;) please, help.
One simple way to go about this would be to prevent addPeriodicTimeObserver from calling self?.updateSlider(with: time) when the slider is being touched.
This can be determined via the UISliders isTracking property:
isTracking
A Boolean value indicating whether the control is currently tracking
touch events.
While tracking of a touch event is in progress, the control sets the
value of this property to true. When tracking ends or is cancelled for
any reason, it sets this property to false.
Ref: https://developer.apple.com/documentation/uikit/uicontrol/1618210-istracking
This is present in all UIControl elements which you can use in this way:
player.addPeriodicTimeObserver(forInterval: interval, queue: nil) { [weak self] time in
//check if slider is being touched/tracked
guard self?.slider.isTracking == false else { return }
//if slider is not being touched, then update the slider from here
self?.updateSlider(with: time)
}
Generic Example:
#IBOutlet var slider: UISlider!
//...
func startSlider() {
slider.value = 0
slider.maximumValue = 10
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] (timer) in
print("Slider at: \(self?.slider.value)")
guard self?.slider.isTracking == false else { return }
self?.updateSlider(to: self!.slider.value + 0.1)
}
}
private func updateSlider(to value: Float) {
slider.value = value
}
I'm sure there are other (better) ways out there but I haven't done much in RxSwift (yet).
I hope this is good enough for now.
I have created one timer object and set #selector method, In #selector method my label update every time that display timer count down value, but when I push or pop another view controller and come back to timer view controller my label not updating timer count down value
override func viewDidLoad() {
super.viewDidLoad()
if timer == nil {
self.startTimer()
}
}
func startTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(timeInterval:1, target: self, selector: #selector(self.update), userInfo: nil, repeats: true);
}
func update() {
count += 1
if(count > 0){
let ti = NSInteger(count)
let strSeconds = ti % 60
let strMinutes = (ti / 60) % 60
let strHours = (ti / 3600)
print("\(strHours):\(strMinutes):\(strSeconds)")
self.lblTimer.text = String(format: "%0.2d:%0.2d:%0.2d",strHours,strMinutes,strSeconds)
}
}
When you use the selector try self.update() to actually call the function. May also put a print statement to check that your variable is indeed incrementing.
When I start a timer, however, it seems to repeatedly run, but it won't decrease the counter. I'm trying to pass the count variable through the counter into the selector, but it seems that the counter resets each time, instead of continuely decreasing. I'm new to programming, so while I'm hoping it's something silly, I might have everything wrong organizationally... my code is:
func timerDidEnd(timer: NSTimer) {
var timeCount = timer.userInfo as! Double
timeCount -= timeInterval
if timeCount <= 0 { //test for target time reached.
print("Timer = 0")
timer.invalidate()
} else { //update the time on the clock if not reached
print("Timer Count: \(timeCount)")
}
extension ViewController: TimerTableViewCellDelegate {
func startTimer(indexPath: NSIndexPath!) {
print("timer \(indexPath.row) button started")
var currentTimer = baseArray[indexPath.row]
currentTimer.timeCount = Double((currentTimer.duration[0] * 60) + (currentTimer.duration[1]) + currentTimer.duration[2])
currentTimer.timer = NSTimer.scheduledTimerWithTimeInterval(timeInterval, target: self, selector: "timerDidEnd:", userInfo: currentTimer.timeCount, repeats: true)
}
func stopTimer(indexPath: NSIndexPath!) {
let currentTimer = baseArray[indexPath.row]
print("Stop Timer")
currentTimer.timer.invalidate()
}
I figured it out- I updated my timer struct to include the proper time count, and this worked while passing in the indexPath as userInfo
I have come across a lot of issues with how to handle NSTimer in background here on stack or somewhere else. I've tried one of all the options that actually made sense .. to stop the timer when the application goes to background with
NSNotificationCenter.defaultCenter().addObserver(self, selector: "appDidEnterBackground", name: UIApplicationDidEnterBackgroundNotification, object: nil)
and
NSNotificationCenter.defaultCenter().addObserver(self, selector: "appDidBecomeActive", name: UIApplicationWillEnterForegroundNotification, object: nil)
At first I thought that my problem is solved, I just saved the time when the app did enter background and calculated the difference when the app entered foreground .. but later I noticed that the time is actually postponed by 3, 4 , 5 seconds .. that it actually is not the same .. I've compared it to the stopwatch on another device.
Is there REALLY any SOLID solution to running an NSTimer in background?
You shouldn't be messing with any adjustments based upon when it enters background or resumes, but rather just save the time that you are counting from or to (depending upon whether you are counting up or down). Then when the app starts up again, you just use that from/to time when reconstructing the timer.
Likewise, make sure your timer handler is not dependent upon the exact timing that the handling selector is called (e.g. do not do anything like seconds++ or anything like that because it may not be called precisely when you hope it will), but always go back to that from/to time.
Here is an example of a count-down timer, which illustrates that we don't "count" anything. Nor do we care about the time elapsed between appDidEnterBackground and appDidBecomeActive. Just save the stop time and then the timer handler just compares the target stopTime and the current time, and shows the elapsed time however you'd like.
For example:
import UIKit
import UserNotifications
private let stopTimeKey = "stopTimeKey"
class ViewController: UIViewController {
#IBOutlet weak var datePicker: UIDatePicker!
#IBOutlet weak var timerLabel: UILabel!
private weak var timer: Timer?
private var stopTime: Date?
let dateComponentsFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .positional
formatter.zeroFormattingBehavior = .pad
return formatter
}()
override func viewDidLoad() {
super.viewDidLoad()
registerForLocalNotifications()
stopTime = UserDefaults.standard.object(forKey: stopTimeKey) as? Date
if let time = stopTime {
if time > Date() {
startTimer(time, includeNotification: false)
} else {
notifyTimerCompleted()
}
}
}
#IBAction func didTapStartButton(_ sender: Any) {
let time = datePicker.date
if time > Date() {
startTimer(time)
} else {
timerLabel.text = "timer date must be in future"
}
}
}
// MARK: Timer stuff
private extension ViewController {
func registerForLocalNotifications() {
if #available(iOS 10, *) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
guard granted, error == nil else {
// display error
print(error ?? "Unknown error")
return
}
}
} else {
let types: UIUserNotificationType = [.alert, .sound, .badge]
let settings = UIUserNotificationSettings(types: types, categories: nil)
UIApplication.shared.registerUserNotificationSettings(settings)
}
}
func startTimer(_ stopTime: Date, includeNotification: Bool = true) {
// save `stopTime` in case app is terminated
UserDefaults.standard.set(stopTime, forKey: stopTimeKey)
self.stopTime = stopTime
// start Timer
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(handleTimer(_:)), userInfo: nil, repeats: true)
guard includeNotification else { return }
// start local notification (so we're notified if timer expires while app is not running)
if #available(iOS 10, *) {
let content = UNMutableNotificationContent()
content.title = "Timer expired"
content.body = "Whoo, hoo!"
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: stopTime.timeIntervalSinceNow, repeats: false)
let notification = UNNotificationRequest(identifier: "timer", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(notification)
} else {
let notification = UILocalNotification()
notification.fireDate = stopTime
notification.alertBody = "Timer finished!"
UIApplication.shared.scheduleLocalNotification(notification)
}
}
func stopTimer() {
timer?.invalidate()
}
// I'm going to use `DateComponentsFormatter` to update the
// label. Update it any way you want, but the key is that
// we're just using the scheduled stop time and the current
// time, but we're not counting anything. If you don't want to
// use `DateComponentsFormatter`, I'd suggest considering
// `Calendar` method `dateComponents(_:from:to:)` to
// get the number of hours, minutes, seconds, etc. between two
// dates.
#objc func handleTimer(_ timer: Timer) {
let now = Date()
if stopTime! > now {
timerLabel.text = dateComponentsFormatter.string(from: now, to: stopTime!)
} else {
stopTimer()
notifyTimerCompleted()
}
}
func notifyTimerCompleted() {
timerLabel.text = "Timer done!"
}
}
By the way, the above also illustrates the use of a local notification (in case the timer expires while the app isn't currently running).
For Swift 2 rendition, see previous revision of this answer.
Unfortunately, there is no reliable way to periodically run some actions while in background. You can make use of background fetches, however the OS doesn't guarantee you that those will be periodically executed.
While in background your application is suspended, and thus no code is executed, excepting the above mentioned background fetches.