swift NSTimer in Background - ios

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.

Related

How to setup long running timers on iOS

I want to create a timer that should fire every hour. But from my research, an app gets suspended after 10 mins in the background. It also seems like the app gets suspended after the screen is locked.
I want to trigger this timer every 1 hour. I will be invalidating the timer when app goes in the background and restart it when the app foregrounds. So I have a few questions:
When the user backgrounds the app and comes back to it, will the timer fire immediately if it has been 1+ hour?
What happens if the user returns to the app after multiple (2+) hours, will the timer fire multiple times?
Are there any recommended ways to setup such longer running timers so they fire more consistently and not just once when they were setup?
You can do something like this without using background timer. It is only idea how you can achieve your requirement, add one or more hours condition as per your requirement.
var totalTime = Double()
override func viewDidLoad() {
super.viewDidLoad()
// MARK: - To Reset timer's sec if app is in background and foreground
NotificationCenter.default.addObserver(self, selector: #selector(self.background(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(self.foreground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
}
#objc func background(_ notification: Notification) {
if self.totalTime > 0{
user_default.setValue(self.totalTime, forKey: "TotalSecInBackground")
user_default.setValue(Date().timeIntervalSince1970, forKey: "OldTimeStamp")
LogInfo("total seconds left in background: \(self.totalTime)")
}
}
#objc func foreground(_ notification: Notification) {
let timerValue: TimeInterval = user_default.value(forKey: "TotalSecInBackground") as? TimeInterval ?? 0
let otpTimeStamp = user_default.value(forKey: "OldTimeStamp") as? TimeInterval ?? 0
let timeDiff = Date().timeIntervalSince1970 - otpTimeStamp
if timerValue > timeDiff{
LogInfo("total second & timeDiff:, \(Int(timerValue)),\(Int(timeDiff))")
let timeLeft = timerValue - timeDiff
self.totalTime = Int(timeLeft)
LogInfo("timeLeft: \(Int(timeLeft))") // <- This is what you need
}}

How to keep two timers in sync

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.

Execute a func when the device time change

I'm trying to execute a func when the device clock change.
This func will return the next course of a student, all the courses are in a array.
if I understand correctly we can not execute a func when the clock of the device change.
I read some topics where people say to do a timer of 60s or other but if the user launch the app a 08:05:07 the func will execute with 7s of late.
I thought to use a do while but I think it will use the CPU a lot and so the battery too. no ?
Does anyone have an idea ?
If you’re just saying that you want to fire a timer at some specific future Date, you should just calculate the amount of time between now and then (using timeIntervalSince), and then use that.
For example, it’s currently "2019-01-20 17:11:59 +0000”, but if I want it to fire at 17:15, you can do:
weak var timer: Timer?
func startTimer() {
let futureDate = ISO8601DateFormatter().date(from: "2019-01-20T17:15:00Z")!
let elapsed = futureDate.timeIntervalSince(Date()) // will be roughly 180.56 in this example at this moment of time
timer?.invalidate() // invalidate prior timer, if any
timer = Timer.scheduledTimer(withTimeInterval: elapsed, repeats: false) { [weak self] _ in
// whatever you want to do at 17:15
}
}
Clearly, however you come up with futureDate will be different in your case, but it illustrates the idea. Just calculate the elapsed time between the future target date and now, and use that for the timer.
Now, if you’re really worried about changes to clock, in iOS you might observe significantTimeChangeNotification, e.g.,
NotificationCenter.default.addObserver(forName: UIApplication.significantTimeChangeNotification, object: nil, queue: .main) { [weak self] _ in
// do something, maybe `invalidate` existing timer and create new one
self?.startTimer()
}
I thought to use a do while but I think it will use the CPU a lot and so the battery too. no ?
Yes, spinning in a loop, waiting for some time to elapse, is always a bad idea. Generally you’d just set a timer.
This func will return the next course of a student, all the courses are in a array.
This begs the question whether the app will be running in the foreground or not. If you want to notify the user at some future time, whether they’re running the app right now or not, consider "user notifications”. E.g. request permission for notification:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
if !granted {
// warn the user that they won't be notified after the user leaves the app unless they grant permission for notifications
DispatchQueue.main.async {
let alert = UIAlertController(title: nil, message: "We need permission to notify you of your class", preferredStyle: .alert)
if let url = URL(string: UIApplication.openSettingsURLString) {
alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in
UIApplication.shared.open(url)
})
}
alert.addAction(UIAlertAction(title: "Cancel", style: .default))
self.present(alert, animated: true)
}
}
}
And then, assuming that permissions have been granted, then schedule a notification:
let content = UNMutableNotificationContent()
content.title = "ClassTime"
content.body = "Time to go to math class"
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: futureDate)
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: "Math 101", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
I did this
private var secondesActuelles : Int = 0
private var timer = Timer()
private func récuperLesSecondes(date : Date) -> Int {
let date = date
let calendar = Calendar.current
let second = calendar.component(.second, from: date)
return second
}
override func viewDidLoad() {
super.viewDidLoad()
secondesActuelles = récuperLesSecondes(date: Date())
timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(60 - secondesActuelles), repeats: false, block: { (timer) in
print("l'heure a changé")
self.secondesActuelles = self.récuperLesSecondes(date: Date())
print("secondesActuelles : \(self.secondesActuelles)")
})
}
It work once. but after the timeInterval don't change.

Swift 3 - How to make timer work in background

i am trying to do an application which can make a timer run in background.
here's my code:
let taskManager = Timer.scheduledTimer(timeInterval: 10, target: self, selector: #selector(self.scheduleNotification), userInfo: nil, repeats: true)
RunLoop.main.add(taskManager, forMode: RunLoopMode.commonModes)
above code will perform a function that will invoke a local notification.
this works when the app is in foreground, how can i make it work in the background?
i tried to put several print lines and i saw that when i minimize (pressed the home button) the app, the timer stops, when i go back to the app, it resumes.
i wanted the timer to still run in the background. is there a way to do it?
here's what i want to happen:
run app -> wait 10 secs -> notification received -> wait 10 secs -> notification received -> and back to wait and received again
that happens when in foreground. but not in background. pls help.
you can go to Capabilities and turn on background mode and active Audio. AirPlay, and picture and picture.
It really works . you don't need to set DispatchQueue .
you can use of Timer.
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (t) in
print("time")
}
Swift 4, Swift 5
I prefer to not run timer on background task, just compare a Date seconds between applicationDidEnterBackground and applicationWillEnterForeground.
func setup() {
NotificationCenter.default.addObserver(self, selector: #selector(applicationDidEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(applicationWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
}
#objc func applicationDidEnterBackground(_ notification: NotificationCenter) {
appDidEnterBackgroundDate = Date()
}
#objc func applicationWillEnterForeground(_ notification: NotificationCenter) {
guard let previousDate = appDidEnterBackgroundDate else { return }
let calendar = Calendar.current
let difference = calendar.dateComponents([.second], from: previousDate, to: Date())
let seconds = difference.second!
countTimer -= seconds
}
This works. It uses while loop inside async task, as suggested in another answer, but it is also enclosed within a background task
func executeAfterDelay(delay: TimeInterval, completion: #escaping(()->Void)){
backgroundTaskId = UIApplication.shared.beginBackgroundTask(
withName: "BackgroundSound",
expirationHandler: {[weak self] in
if let taskId = self?.backgroundTaskId{
UIApplication.shared.endBackgroundTask(taskId)
}
})
let startTime = Date()
DispatchQueue.global(qos: .background).async {
while Date().timeIntervalSince(startTime) < delay{
Thread.sleep(forTimeInterval: 0.01)
}
DispatchQueue.main.async {[weak self] in
completion()
if let taskId = self?.backgroundTaskId{
UIApplication.shared.endBackgroundTask(taskId)
}
}
}
}
A timer can run in the background only if both the following are true:
Your app for some other reason runs in the background. (Most apps don't; most apps are suspended when they go into the background.) And:
The timer was running already when the app went into the background.
Timer won't work in background. For background task you can check this link below...
https://www.raywenderlich.com/143128/background-modes-tutorial-getting-started
============== For Objective c ================
create Global uibackground task identifier.
UIBackgroundTaskIdentifier bgRideTimerTask;
now create your timer and add BGTaskIdentifier With it, Dont forget to remove old BGTaskIdentifier while creating new Timer Object.
[timerForRideTime invalidate];
timerForRideTime = nil;
bgRideTimerTask = UIBackgroundTaskInvalid;
UIApplication *sharedApp = [UIApplication sharedApplication];
bgRideTimerTask = [sharedApp beginBackgroundTaskWithExpirationHandler:^{
}];
timerForRideTime = [NSTimer scheduledTimerWithTimeInterval:1.0
target:self
selector:#selector(timerTicked:)
userInfo:nil
repeats:YES];
[[NSRunLoop currentRunLoop]addTimer:timerForRideTime forMode: UITrackingRunLoopMode];
Here this will work for me even when app goes in background.ask me if you found new problems.
You can achieve this by getting the time-lapse between background and foreground state of the app, here is the code snippet.
import Foundation
import UIKit
class CustomTimer {
let timeInterval: TimeInterval
var backgroundTime : Date?
var background_forground_timelaps : Int?
init(timeInterval: TimeInterval) {
self.timeInterval = timeInterval
}
private lazy var timer: DispatchSourceTimer = {
let t = DispatchSource.makeTimerSource()
t.schedule(deadline: .now() + self.timeInterval, repeating: self.timeInterval)
t.setEventHandler(handler: { [weak self] in
self?.eventHandler?()
})
return t
}()
var eventHandler: (() -> Void)?
private enum State {
case suspended
case resumed
}
private var state: State = .suspended
deinit {
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
timer.setEventHandler {}
timer.cancel()
resume()
eventHandler = nil
}
func resume() {
NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackgroundNotification), name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForegroundNotification), name: UIApplication.willEnterForegroundNotification, object: nil)
if state == .resumed {
return
}
state = .resumed
timer.resume()
}
func suspend() {
if state == .suspended {
return
}
state = .suspended
timer.suspend()
}
#objc fileprivate func didEnterBackgroundNotification() {
self.background_forground_timelaps = nil
self.backgroundTime = Date()
}
#objc fileprivate func willEnterForegroundNotification() {
// refresh the label here
self.background_forground_timelaps = Date().interval(ofComponent: .second, fromDate: self.backgroundTime ?? Date())
self.backgroundTime = nil
}
}
Use this class like;
self.timer = CustomTimer(timeInterval: 1)
self.timer?.eventHandler = {
DispatchQueue.main.sync {
var break_seconds = self.data.total_break_sec ?? 0
break_seconds += 1
if self.timer?.background_forground_timelaps != nil && self.timer?.backgroundTime == nil{
break_seconds += (self.timer?.background_forground_timelaps)!
self.timer?.background_forground_timelaps = nil
}
self.data.total_break_sec = String(break_seconds)
self.lblBreakTime.text = PRNHelper.shared.getPlainTimeString(time: TimeInterval(break_seconds))
}
}
self.timer?.resume()
This way I am able to get the timer right when resumed the app from background.
If 1 or 2 seconds threshold is acceptable this hack could be helpful.
UIApplication.didEnterBackgroundNotification
UIApplication.willEnterForegroundNotification
Stop Timer and Backup Date() on didEnterBackground.
Add Date() to the Backup date on willEnterForegraound to achieve total time.
Start Timer and Add total date to the Timer.
Notice: If user changed the date time of system it will be broken!
You dont really need to keep up with a NSTImer object. Every location update comes with its own timestamp.
Therefore you can just keep up with the last time vs current time and every so often do a task once that threshold has been reached:
if let location = locations.last {
let time = location.timestamp
guard let beginningTime = startTime else {
startTime = time // Saving time of first location time, so we could use it to compare later with subsequent location times.
return //nothing to update
}
let elapsed = time.timeIntervalSince(beginningTime) // Calculating time interval between first and second (previously saved) location timestamps.
if elapsed >= 5.0 { //If time interval is more than 5 seconds
//do something here, make an API call, whatever.
startTime = time
}
}
As others pointed out, Timer cannot make a method run in Background. What you can do instead is use while loop inside async task
DispatchQueue.global(qos: .background).async {
while (shouldCallMethod) {
self.callMethod()
sleep(1)
}
}

How to pass a Timer from Second View Controller to first?

So I have a timer that saves that saves the ending time using NSUserDefaults but I want to push that timer to the previous ViewController as well. The timer should be started on the second View Controller, and if you go back, or exit the app and reopen it, the timer should display. I have an idea of how do it with a Singleton DataService, but not quite sure how to put it all together. Here is my code as of now.
import UIKit
import UserNotifications
let stopTimeKey = "stopTimeKey"
class QOTDVC: UIViewController {
// TIMER VARIABLES
let timeInterval: Double = 89893
let defaults = UserDefaults.standard
var expirationDate = NSDate()
#IBOutlet weak var timerLabel: UILabel!
#IBAction func DoneWithQuestion(_ sender: AnyObject) {
self.dismiss(animated: true, completion: nil)
}
#IBOutlet weak var timerCounter: UILabel!
var timer: Timer?
var stopTime: Date?
override func viewDidLoad() {
super.viewDidLoad()
saveStopTime()
NotificationCenter.default.addObserver(self, selector: #selector(saveStopTime), name: NSNotification.Name(rawValue: "Date") , object: nil)
}
func alert(message: String, title: String = "") {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let OKAction = UIAlertAction(title: "OK", style: .default) {
UIAlertAction in
self.registerForLocalNotifications()
StartTimerInitiated()
}
alertController.addAction(OKAction)
self.present(alertController, animated: true, completion: nil)
}
func registerForLocalNotifications() {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { (granted, error) in
if granted {
let content = UNMutableNotificationContent()
content.title = "Ready for the QOTD"
content.body = "You have 30 seconds to answer the question"
content.sound = UNNotificationSound.default()
let trigger = UNTimeIntervalNotificationTrigger.init(timeInterval: self.timeInterval , repeats: false)
let request = UNNotificationRequest(identifier: "myTrigger", content: content, trigger: trigger)
center.add(request)
}
}
}
func StartTimerInitiated() {
let time = Date(timeIntervalSinceNow: timeInterval)
if time.compare(Date()) == .orderedDescending {
startTimer(stopTime: time)
} else {
timerLabel.text = "timer date must be in future"
}
}
// MARK: Timer stuff
func startTimer(stopTime: Date) {
// save `stopTime` in case app is terminated
UserDefaults.standard.set(stopTime, forKey: stopTimeKey)
self.stopTime = stopTime
// start NSTimer
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(QOTDVC.handleTimer), userInfo: nil, repeats: true)
// start local notification (so we're notified if timer expires while app is not running)
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
let dateComponentsFormatter: DateComponentsFormatter = {
let _formatter = DateComponentsFormatter()
_formatter.allowedUnits = [.hour, .minute, .second]
_formatter.unitsStyle = .positional
_formatter.zeroFormattingBehavior = .pad
return _formatter
}()
func handleTimer(timer: Timer) {
let now = Date()
if stopTime!.compare(now) == .orderedDescending {
timerLabel.text = dateComponentsFormatter.string(from: now, to: stopTime!)
} else {
stopTimer()
notifyTimerCompleted()
}
}
func notifyTimerCompleted() {
timerLabel.text = "Timer done!"
}
func saveStopTime() {
stopTime = UserDefaults.standard.object(forKey: stopTimeKey) as? Date
if let time = stopTime {
if time.compare(Date()) == .orderedDescending {
startTimer(stopTime: time)
} else {
notifyTimerCompleted()
}
}
stopTime = UserDefaults.standard.object(forKey: stopTimeKey) as? Date
}
Any help would be much appreciated. If you need any clarification, please let me know.
You are juggling several issues: Passing an object between view controllers, triggering code at some future time, and having a timer persist in the background.
As far as a timer that runs while your program is in the background, you can simply calculate the number of seconds between now and your target time and set a non-repeating timer for that number of seconds in the future. There's no reason to fire a repeating timer every second and do math to see if your time has passed yet. The way you're doing it will run the CPU hotter and drain your battery faster, so better to set up a single timer in the future.
Next, dealing with timers while in the background. The short answer is that you can't. Apps don't actually run in the background for very long. They quickly get suspended, which is a state where they are not getting any CPU time at all. You can ask for background time, but the system limits you to 3 minutes. You can play tricks to get more than 3 minutes of background time, but those tricks will cause Apple to reject your app, and would drain down the user's battery quite quickly if you did manage to sneak it by Apple. (When an app is running in the background the phone isn't able to go to sleep. THE CPU stays fully powered up, drawing a LOT more current than it does in the sleep state.
Finally, passing your timer from one view controller to the next. Yes, you can certainly use a singleton to make the timer a shared resource. You could also set up your two view controllers so that in the code that invokes the second from the first, you give the second view controller a delegate pointer back to the first, and set up a protocol that would let you pass the timer from the second view controller back to the first.
However, a timer calls a single target, so while you'll have access to the timer from either view controller using either the singleton pattern or the delegate pattern, the timer will still call the original target that you used when you set it up.
You could make your singleton the target of the timer, give the singleton a delegate, and have the singleton send a message to it's delegate when the timer fires. Then when you switch view controllers you could change the singleton's delegate to point to the new view controller.
Alternately you could record the "fire date" of your timer in your viewWillDisappear method, invalidate the timer, and create a new timer with that same fire date (actually you'd have to do some math to convert a fire date to a number of seconds, but it would be a single call.)
You could also use a local notification with a future fire date and set it up to play a sound. However, that won't invoke your program from the background unless the user responds to the notification.

Resources