Making a timer persistant

People here in the forums have been so helpful, I thought I would ask another question. I am designing an App where I need a timer to be persistent. I wanted to show the user seconds, days, weeks, months, etc, since the start date.
I have the timer running off of the view. Trying to figure out how to make the time persistent. Right now... the timer runs well. Every time the App is started, it starts at 0 sec, and starts counting away. I was wanting the App to remember what time it was, and add to it. So that if it was 1 minute ago when the App was closed. When the App is re-opened it basically adds 1 minute to the counter and keeps going, I have used AppData and UserDefaults for other things in this App, but I could not quite see how to do this here. I was thinking I would have to save to defaults a value inside of the "Timer" service, but there might be a better way. Thanks for taking a look...

This is my separate file that is the "Timer" Service

import Foundation
import SwiftUI
import UIKit




class TimerService: NSObject, ObservableObject {
    
    @Published var timeremaining: String = ""
    
    
    private var timer: Timer!
    
    let formatPicker: [String] = ["Seconds","Minutes","Hours","Days", "Weeks", "Months"]
    let calendar = Calendar.current
    let setdate: Date = Calendar.current.date(byAdding: .second, value:0, to: Date()) ?? Date ()
    private func updateTimeRemaining() {
        let remaining = Calendar.current.dateComponents([.year, .month, .weekOfYear, .hour, .minute, .second], from: setdate, to: Date())
        let year = remaining.year ?? 0
        let month = remaining.month ?? 0
        let week = remaining.weekOfYear ?? 0
        let day = remaining.day ?? 0
        let hour = remaining.hour ?? 0
        let minute = remaining.minute ?? 0
        let second = remaining.second ?? 0
        timeremaining = "Whisky has been aging for \(year) Years  \(month) Months \(week) Weeks \(day) Days \(hour) Hours \(minute) Minutes \(second) Seconds"
    }
    override init() {
        super.init()
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.updateTimeRemaining()
        }
        
    }
}

This is my main view where the timer will be located:

import SwiftUI
import UIKit
import Foundation



struct ContentView: View {
    @EnvironmentObject var bluetoothService: BluetoothService
    @EnvironmentObject var timerService: TimerService
    @Binding var whiskyname: String
    @Binding var barrelabv: String
    @Binding var imageSelected: UIImage
    @Binding var weightlownum: Float32
    @Binding var weighthinum: Float32
    @Binding var screenDisable: Bool
    @AppStorage("pourabv") var pourabv: String = "ABV"
    @State var TempValue: String = ""
    @State var link = UIImage()
    @State var timeinterval = Date()
    @State var percentFilled: Float = 0
    @State var timeFormat: String = "Days"
    @State var timeremaining: String = ""
 
  
    
    
    var body: some View {
        NavigationStack{
            NavigationLink {
                SetupView()
            } label: {
                
            }
  
            VStack{
                Text(self.whiskyname)
                    .frame(width: 300, height:1)
                    .font(.system(size: 24))
            }
            VStack{
                Image(uiImage: imageSelected)
                    .resizable()
                    .font(.system(size: 40))
                    .aspectRatio(contentMode: .fill)
                    .frame(width:245, height:245)
                    .font(.system(size: 24))
                    .background(Color.blue.opacity(0.4))
                    .cornerRadius(15)
                    .padding(.vertical, 10)
            }
            VStack(alignment: .leading){
                HStack{
                    if bluetoothService.Connected == true{
                        let link = Image(systemName: "link.circle.fill").foregroundColor(.blue)
                        link
                        Text("Bluetooth Connected")
                            .font(.footnote)
                    } else {
                        let link = Image(systemName: "link.circle.fill").foregroundColor(.red)
                        link
                        Text("Bluetooth Disconnected")
                            .font(.footnote)
                    }
                }
                HStack{
                    TextField("", text: $pourabv)
                        .padding(20)
                        .font(.system(size: 30))
                        .frame(width: 110, height:35)
                        .font(.system(size: 24))
                        .background(Color.blue.opacity(0.4))
                        .cornerRadius(15)
                    
                    Text("% Pour ABV")
                        .font(.system(size: 30))
                        .frame(width: 200, height:35)
                        .font(.system(size: 24))
                        .cornerRadius(15)
                }
                HStack{
                    Text("\(bluetoothService.TempValue, specifier: "%.1f")°")
                        .font(.system(size: 30))
                        .frame(width: 110, height:35)
                        .font(.system(size: 24))
                        .background(Color.blue.opacity(0.4))
                        .cornerRadius(15)
                    Text("Temperature F")
                        .font(.system(size: 30))
                        .frame(width: 200, height:35)
                        .font(.system(size: 24))
                        .cornerRadius(15)
                }
                HStack{
                    Text("\(bluetoothService.HumidValue, specifier: "%.1f") %")
                        .font(.system(size: 30))
                        .frame(width: 110, height:35)
                        .font(.system(size: 24))
                        .background(Color.blue.opacity(0.4))
                        .cornerRadius(15)
                    Text("Humidity")
                        .font(.system(size: 30))
                        .frame(width: 200, height:35)
                        .font(.system(size: 24))
                        .cornerRadius(15)
                }
                HStack{
                 
                    let percent = (100 / (weighthinum - weightlownum)) * (bluetoothService.WeightValue - weightlownum)
              
                   
                    
                    Text("\(percent, specifier: "%.1f") %")
                        .font(.system(size: 30))
                        .frame(width: 110, height:35)
                        .font(.system(size: 24))
                        .background(Color.blue.opacity(0.4))
                        .cornerRadius(15)
                    Text("Barrel Filled")
                        .font(.system(size: 30))
                        .frame(width: 200, height:35)
                        .font(.system(size: 24))
                        .cornerRadius(15)
                }
                HStack{
                    let myString = (barrelabv)
                    let floatbarrelabv = (myString as NSString).floatValue
                    let myString2 = (pourabv)
                    let floatpourabv = (myString2 as NSString).floatValue
                    
                    let glasses = (((((1.8 * floatbarrelabv * (100 / (weighthinum - weightlownum)) * (bluetoothService.WeightValue - weightlownum))) / floatpourabv) * 3785.41) / 40) / 100
                    Image("whisky glass")
                        .resizable()
                        .frame(width: 50, height:50)
                    Text("\(glasses, specifier: "%.0f")")
                        .font(.system(size: 30))
                        .padding(20)
                        .frame(width: 105, height:35)
                        .font(.system(size: 24))
                        .background(Color.blue.opacity(0.4))
                        .cornerRadius(15)
                    Text("Glasses remaining")
                        .font(.system(size: 25))
                        .frame(width: 200, height:35)
                        .font(.system(size: 24))
                        .cornerRadius(15)
                }
                HStack{
                    let myString = (barrelabv)
                    let floatbarrelabv = (myString as NSString).floatValue
                    let myString2 = (pourabv)
                    let floatpourabv = (myString2 as NSString).floatValue
                    
                    let bottles = (((((1.8 * floatbarrelabv * (100 / (weighthinum - weightlownum)) * (bluetoothService.WeightValue - weightlownum))) / floatpourabv) * 3785.41) / 750) / 100
                    Image("bottle")
                        .resizable()
                        .frame(width: 50, height:50)
                    
                    Text("\(bottles, specifier: "%.1f")")
                        .font(.system(size: 30))
                        .padding(20)
                        .frame(width: 105, height:35)
                        .font(.system(size: 24))
                        .background(Color.blue.opacity(0.4))
                        .cornerRadius(15)
                    Text("Bottles remaining")
                        .font(.system(size: 25))
                        .frame(width: 200, height:40)
                        .font(.system(size: 24))
                        .cornerRadius(15)
                }
                
                
                
                Text(timerService.timeremaining)
                
                
            } //Main VStack ends here
            
            Spacer()
            
        } //Navigation Stack
        
        
    } // body view
    
    
} // Content View


struct ContentView_Previews: PreviewProvider {
    @State static var whiskyname: String = ""
    @State static var barrelabv: String = ""
    @State static var weighthinum: Float32 = 0
    @State static var weightlownum: Float32 = 0
    @State static var imageSelected = UIImage()
    @State static var screenDisable: Bool = false
    @State var link = UIImage()
    @State static var bottles: Float32 = 0
    static var previews: some View{
        
        ContentView(whiskyname: $whiskyname, barrelabv: $barrelabv, imageSelected: $imageSelected, weightlownum: $weightlownum, weighthinum: $weighthinum, screenDisable: $screenDisable)
            .environmentObject(BluetoothService())
            .environmentObject(TimerService())
    }
    
}

Currently I am just displaying the time with the following line in the code above.

  Text(timerService.timeremaining)

You can persist the startDate when you start the timer for the first time, for example:

var startDate: Date? {
    get {
        UserDefaults.standard.object(forKey: "startDate") as? Date
    }
    set {
        UserDefaults.standard.setValue(newValue, forKey: "startDate")
    }
}

or a modern "@UserDefault(...)" equivalent.

2 Likes

Pardon my newbie status.... I understand what you are saying to do, just cannot understand the mechanics for doing so. Could you show me what you mean? Thanks...

Something like this:

import SwiftUI

class Model: ObservableObject {
    @Published var elapsedTime: TimeInterval = 0
    private var timer: Timer?
    
    init() {
        if startDate != nil {
            restartTimer()
        }
    }
    private var startDate: Date? {
        get { UserDefaults.standard.object(forKey: "startDate") as? Date }
        set {
            UserDefaults.standard.setValue(newValue, forKey: "startDate")
        }
    }
    var isTimerRunning: Bool {
        startDate != nil
    }
    private func timerProc() {
        elapsedTime = Date().timeIntervalSince(startDate!)
        // or your calendar based calculation
    }
    private func restartTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.timerProc()
        }
        timerProc()
    }
    func startTimer() {
        precondition(!isTimerRunning)
        startDate = Date()
        elapsedTime = 0
        restartTimer()
    }
    func stopTimer() {
        timer?.invalidate()
        timer = nil
        precondition(isTimerRunning)
        startDate = nil
        elapsedTime = 0
    }
}

struct ContentView: View {
    @StateObject private var model = Model()
    
    var body: some View {
        VStack {
            Text("elapsed time: \(model.elapsedTime)")
            Button("start timer") {
                model.startTimer()
            }.disabled(model.isTimerRunning)
            Button("stop timer") {
                model.stopTimer()
            }.disabled(!model.isTimerRunning)
        }
    }
}

@main struct TestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

in this app even after restarting the app the timer "remembers" if it was running or not, and restarts itself.

1 Like

Thanks again Tara,
That worked really well. I was not aware of some of those commands, so I will have to do some research how to use them effectively. I was able to combine that new counter, within my main APP, and all is well with one issue. When I attempt to stop the counter, the app crashes. With the use of the simulator, I was able to track it back to having to do with if there is a null field when the data is read out of the memory location. I am sure it is a simple fix. Probably some combination of all of those !, ??, ? and other symbols that I am attempting to get a better grasp of.

The updated timer:

import Foundation
import SwiftUI

class Model: ObservableObject {
    @Published var elapsedTime: TimeInterval = 0
    @Published var timeremaining: String = ""
    private var timer: Timer?
    
    init() {
        if startDate != nil {
            restartTimer()
        }
    }
    private var startDate: Date? {
        get { UserDefaults.standard.object(forKey: "startDate") as? Date }
        set {
            UserDefaults.standard.setValue(newValue, forKey: "startDate")
        }
    }
    var isTimerRunning: Bool {
        startDate != nil
    }
    private func timerProc() {
      
            **let remaining = Calendar.current.dateComponents([.year, .month, .weekOfYear, .hour, .minute, .second], from: startDate!, to: Date())**
            let year = remaining.year ?? 0
            let month = remaining.month ?? 0
            let week = remaining.weekOfYear ?? 0
            let day = remaining.day ?? 0
            let hour = remaining.hour ?? 0
            let minute = remaining.minute ?? 0
            let second = remaining.second ?? 0
            timeremaining = "Whisky has been aging for \(year) Years  \(month) Months \(week) Weeks \(day) Days \(hour) Hours \(minute) Minutes \(second) Seconds"
        
        
        
        
        
        // elapsedTime = Date().timeIntervalSince(startDate!)
        // or your calendar based calculation
    }
    private func restartTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.timerProc()
        }
        timerProc()
    }
    func startTimer() {
        precondition(!isTimerRunning)
        startDate = Date()
        elapsedTime = 0
        restartTimer()
    }
    func stopTimer() {
        timer?.invalidate()
        timer = nil
        precondition(isTimerRunning)
        startDate = nil
        elapsedTime = 0
    }
}

I attempted to bold above where the issue is. I think it just shows up as ** **

Below is where I call the Stop function...

  Button(action: {
                    isAlertShown.toggle()
                }) {
                    Text("Delete Barrel")
                }
                .alert("You are about to delete all barrel data. All history will be lost.", isPresented: $isAlertShown) {
                    Button("Delete Barrel", role: .destructive) {
                        whiskyname = ""
                        barrelabv = ""
                        weightlownum = 0
                        weighthinum = 0
                        UserDefaults.standard.set(0, forKey: "weighthi")
                        UserDefaults.standard.set(0, forKey: "weightlow")
                        imageSelected =  UIImage(systemName: "photo.artframe") ?? UIImage()
                        screenDisable = false
                        **model.stopTimer()**
**                    }.disabled(!model.isTimerRunning)**

This one crash is pretty much the last piece I need to finish up my APP. The only thing remaining is to make a UIImage persistent. Of course that will not be nearly as easy as user defaults, but I will fight with that for a while and see if I can get that to work. Thanks for taking a look at this one... You have been really generous with your time. Is there a kudos thing here on the forum sort of a thing I can send your way. :slight_smile:

Dan

You are not using "elapsedTime" variable so just get rid of it, once you do the crash should go.

The crash is cause by unwrapping startDate! when it is nil. I don't see how that event could happen (looking at the source) as whenever you set startDate to nil the timer should be already invalidated and won't fire anymore. Would be a good excessive investigating how exactly that happens. As an immediate remedy you could

guard let startDate else { return }

as the first thing in the timerProc, but I'd still investigate how/why control get's there with nil in startDate while it seems it should not be doing so.

Yes.... the last line fixed it perfectly. No more crashes. I was able to input all of the fields, close and re-open the program and time was kept. Pressed the delete barrel button and everything went back to defaults. Now to fight to get a single UIImage to be persistent, I have seen examples with file manger etc. And then my App should be mostly complete. Thanks again for your time and knowledge. It is very much appreciated.... I hope in the future when I do ask questions they are better ones than today.. :slight_smile:

I see this question (more or less) asked fairly often, and I think it's worth taking just a bit more space to clarify the basis of @tera's answer.

People fairly naturally start by thinking that, in order for a timer to persist, it needs to be kept running continuously. This is especially troublesome on platforms like iOS, where keeping your app's process running at all is uncertain, and keeping timers running in the background is not necessarily possible.

When faced with this problem, there are 2 key things to keep in mind:

  1. It takes no effort for your code to know how long "a timer" — a conceptually persistent timer — has been running, so long as you preserve the date/time when it started, which you only have to do once, when starting it.

    The timer has been running for Date.now() - myStartTime seconds — easy!

  2. You only need to be concerning about showing that your timer is running when your app is in the foreground, since that's the only scenario when the user can actually see your UI.

    You can achieve this easily too, by initializing your view state (in, say, .onAppear) to look like the timer has been running and counting up and down all the time, even though it hasn't. You just start the view with the state the UI should show, which you can calculate as in #1 above.

The truly hard thing (on platforms like iOS) is to trigger some action every n seconds (so: every second, or every half second, or every 10 seconds, etc, depending on what you're trying to achieve) whether or not your app is running. Such OSes deliberately lack API to allow you to do that, for multiple reasons. Even an innocent, performant periodic computation can result in a drained battery much faster than you'd expect.

In this scenario, a persistent timer based design is usually an infeasible choice. In the end, the hard question is not how to do it (you probably can't!), but what to do instead. That's a whole other discussion.

4 Likes

Do you want to persist the image (data) itself or just the image name? E.g. if you download arbitrary images from the internet that's one thing and if you have all images in your app resources that's another deal.