kalafun kalafun - 3 months ago 18
iOS Question

swift NSTimer in Background

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?

Rob Rob
Answer

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.

private let stopTimeKey = "stopTimeKey"

class ViewController: UIViewController {

    @IBOutlet weak var datePicker: UIDatePicker!
    @IBOutlet weak var timerLabel: UILabel!

    var stopTime: NSDate?

    override func viewDidLoad() {
        super.viewDidLoad()

        registerForLocalNotifications()

        stopTime = NSUserDefaults.standardUserDefaults().objectForKey(stopTimeKey) as? NSDate
        if let time = stopTime {
            if time.compare(NSDate()) == .OrderedDescending {
                startTimer(time)
            } else {
                notifyTimerCompleted()
            }
        }
    }

    func registerForLocalNotifications() {
        let types: UIUserNotificationType = [.Badge, .Sound, .Alert]

        let settings = UIUserNotificationSettings(forTypes: types, categories: nil)

        UIApplication.sharedApplication().registerUserNotificationSettings(settings)
    }

    @IBAction func didTapStartButton(sender: AnyObject) {
        let time = datePicker.date
        if time.compare(NSDate()) == .OrderedDescending {
            startTimer(time)
        } else {
            timerLabel.text = "timer date must be in future"
        }
    }

    // MARK: Timer stuff

    var timer: NSTimer?

    func startTimer(stopTime: NSDate) {
        // save `stopTime` in case app is terminated

        NSUserDefaults.standardUserDefaults().setObject(stopTime, forKey: stopTimeKey)
        self.stopTime = stopTime

        // start NSTimer

        timer = NSTimer.scheduledTimerWithTimeInterval(0.1, target: self, selector: "handleTimer:", userInfo: nil, repeats: true)

        // start local notification (so we're notified if timer expires while app is not running)

        let notification = UILocalNotification()
        notification.fireDate = stopTime
        notification.alertBody = "Timer finished!"
        UIApplication.sharedApplication().scheduleLocalNotification(notification)
    }

    func stopTimer() {
        timer?.invalidate()
        timer = nil
    }

    let dateComponentsFormatter: NSDateComponentsFormatter = {
        let _formatter = NSDateComponentsFormatter()
        _formatter.allowedUnits = [.Hour, .Minute, .Second]
        _formatter.unitsStyle = .Positional
        _formatter.zeroFormattingBehavior = .Pad
        return _formatter
    }()

    // I'm going to use `NSDateComponentsFormatter` 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 `NSDateComponentsFormatter`, I'd suggest considering
    // `NSCalendar` method `components:fromDate:toDate:options:` to 
    // get the number of hours, minutes, seconds, etc. between two 
    // dates.

    func handleTimer(timer: NSTimer) {
        let now = NSDate()

        if stopTime!.compare(now) == .OrderedDescending {
            timerLabel.text = dateComponentsFormatter.stringFromDate(now, toDate: 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).

Comments