Search code examples
swifttimer

Swift: another timer starting when application enters background


I've been struggling to figure this out for a few days: I need to create a timer that the user can't kill, so once they start it, even if they kill the app or it enters background, it will pick up where it left off, and to achieve this I am saving the date when the app terminated and then calculating the difference, so that part works fine.

The problem I keep running into however is that a second timer seems to start if I minimise the app and bring it back. I'm not sure how timers are managed in the background, but nothing of what I tried (calling timer.invalidate() when applicationWillResignActive gets called, calling it in deinit() ) seems to work. The behaviour I see after this is that the timer will count like this: 80 - 78 - 79 - 76 - 77..

There's also a problem where the timer will sometime run past the time it's supposed to run for after killing the app, but I can't find the exact cause for that because it doesn't always happen.

Any idea what I'm doing wrong?

Thanks a lot.

class Focus: UIViewController {

// MARK: Variables
var timer = Timer()
let timeToFocus = UserDefaults.standard.double(forKey: "UDTimeToFocus")
let currentFocusedStats = UserDefaults.standard.integer(forKey: "UDFocusStats")


// MARK: Outlets
@IBOutlet weak var progress: KDCircularProgress!
@IBOutlet weak var timeLabel: UILabel!
@IBOutlet weak var focusTimeLabel: UILabel!
@IBOutlet weak var stepNameLabel: UILabel!
@IBOutlet weak var focusAgain: UIButton!
@IBOutlet weak var allDone: UIButton!
@IBOutlet weak var help: UIButton!
@IBOutlet weak var dottedCircle: UIImageView!


// MARK: Outlet Functions
@IBAction func helpTU(_ sender: Any) { performSegue(withIdentifier: "ToFocusingHelp", sender: nil) }
@IBAction func helpTD(_ sender: Any) { help.tap(shape: .rectangle) }


@IBAction func allDoneTU(_ sender: Any) {
    UserDefaults.standard.set(false, forKey: "UDFocusIsRunning")
    UserDefaults.standard.set(false, forKey: "UDShouldStartFocus")
    completeSession()
    hero(destination: "List", type: .zoomOut)
}

@IBAction func allDoneTD(_ sender: Any) { allDone.tap(shape: .rectangle) }


@IBAction func focusAgainTU(_ sender: Any) {
    UserDefaults.standard.set(currentFocusedStats + Int(timeToFocus), forKey: "UDFocusStats")
    UserDefaults.standard.set(true, forKey: "UDShouldStartFocus")
    initFocus()
}

@IBAction func focusAgainTD(_ sender: Any) { focusAgain.tap(shape: .rectangle) }


// MARK: Class Functions
@objc func initFocus() {

    var ticker = 0.0
    var angle = 0.0
    var duration = 0.0

     if UserDefaults.standard.bool(forKey: "UDShouldStartFocus") == true {
        UserDefaults.standard.set(Date(), forKey: "UDFocusStartDate")
        UserDefaults.standard.set(false, forKey: "UDShouldStartFocus")
        ticker = timeToFocus
        duration = timeToFocus
        angle = 0.0
        print("starting")
     } else {
        let elapsedTime = difference(between: UserDefaults.standard.object(forKey: "UDFocusStartDate") as! Date, and: Date())
        let timeLeft = timeToFocus - elapsedTime
        ticker = timeLeft
        duration = timeLeft
        angle = elapsedTime / (timeToFocus / 360)
     }

    // Timer
    let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
        if ticker > 0 {
            self.timeLabel.text = "\(Int(ticker))s"
            ticker -= 1
        }
    }
    timer.fire()

    // Progress Circle
    progress.animate(fromAngle: angle, toAngle: 360, duration: duration) { completed in
        if completed { self.completeSession() }
    }


    // UI Changes
    allDone.isHidden = true
    focusAgain.isHidden = true
    help.isHidden = false
}


func completeSession() {
    // The timer gets fired every time, but this will invalidate it if it's complete
    timer.invalidate()
    timeLabel.text = "Done"
    help.isHidden = true
    allDone.isHidden = false
    focusAgain.isHidden = false
}


// MARK: viewDidLoad
override func viewDidLoad() {

    initFocus()

    allDone.isHidden = true
    focusAgain.isHidden = true

    if timeToFocus < 3600 { focusTimeLabel.text = "Focusing for \(Int(timeToFocus/60)) minutes" }
    else if timeToFocus == 3600 { focusTimeLabel.text = "Focusing for \(Int(timeToFocus/60/60)) hour" }
    else { focusTimeLabel.text = "Focusing for \(Int(timeToFocus/60/60)) hours" }

    stepNameLabel.text = UserDefaults.standard.string(forKey: "UDSelectedStep")

    // This resumes the timer when the user sent the app in the background.
    NotificationCenter.default.addObserver(self, selector: #selector(self.initFocus), name: NSNotification.Name(rawValue: "WillEnterForeground"), object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(self.fadeProgress), name: NSNotification.Name(rawValue: "WillEnterForeground"), object: nil)

}

@objc func fadeProgress(){

    // This function is called both when the view will enter foreground (for waking the phone or switching from another app) and on viewWillAppear (for starting the app fresh). It will fade the progress circle and buttons to hide a flicker that occurs.
    timeLabel.alpha = 0
    dottedCircle.alpha = 0
    progress.alpha = 0
    allDone.alpha = 0
    focusAgain.alpha = 0

    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: {
        UIButton.animate(withDuration: 0.5, animations: {
            self.timeLabel.alpha = 1
            self.dottedCircle.alpha = 1
            self.progress.alpha = 1
            self.allDone.alpha = 1
            self.focusAgain.alpha = 1

        })
    })
}

// MARK: viewWillAppear
override func viewWillAppear(_ animated: Bool) { fadeProgress() }
}

Solution

  • It seems the problem is that you create a local timer variable inside initFocus() but you call invalidate inside completeSession for another timer defined there:

    class Focus: UIViewController {
    
    // MARK: Variables
    var timer = Timer()