Search code examples
iosswiftswiftuibackground-fetch

Custom timer in swiftui stops to count in background fetch when user hide app or turn off device


I'm trying to use this cool timer from kavsoft

But when i hide app or turn off screen device (not close app) this timer after a couple of seconds or minutes in the background mode stops counting and stops. If I open the application, it continues to count down again.

I tried on the original code and on my modified one. In mine, I made the format of hours minutes seconds. In any of them, counting in background mode stops working. Is any way to fix it? it may take up to 2 hours for my needs in app to work in background mode. Here is my modifed code in swift

import SwiftUI
import UserNotifications

    struct TimerDiscovrView : View {
        
        @State var start = false
        @State var to : CGFloat = 0
        @State var MaxCount = 0
        @State var count = 0
        var testTimer: String
        var testName: String
        @State var time = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
        
        var body: some View{
            ZStack{
                //заливка экрана
                Color.black.opacity(0.06).edgesIgnoringSafeArea(.all)
                VStack{
                    
                    ZStack{
                        Circle()
                        .trim(from: 0, to: 1)
                            .stroke(Color.black.opacity(0.09), style: StrokeStyle(lineWidth: 35, lineCap: .round))
                        .frame(width: 280, height: 280)
                        
                        Circle()
                            .trim(from: 0, to: self.to)
                            .stroke(Color.red, style: StrokeStyle(lineWidth: 35, lineCap: .round))
                        .frame(width: 280, height: 280)
                        .rotationEffect(.init(degrees: -90))
                        
                        
                        VStack{
                            
                            Text("\(valueFormat(mCount: count/3600)):\(valueFormat(mCount: count/60%60)):\(valueFormat(mCount: count%60))")
                                .font(.system(size: 45))
                                .fontWeight(.bold)
                                .padding(.top)
                                .padding(.bottom)
                            
                            Text(LocalizedStringKey("Total time")).lineLimit(2)
                            Text("\(valueFormat(mCount: MaxCount/3600)):\(valueFormat(mCount: MaxCount/60%60)):\(valueFormat(mCount: MaxCount%60))")
                                .font(.title)
                        }
                    }
                    
                    VStack {
                        HStack(spacing: 20){
                            
                            Button(action: {
                                
                                if self.count == MaxCount {
                                    
                                    self.count = 0
                                    withAnimation(.default){
                                        
                                        self.to = 0
                                    }
                                }
                                self.start.toggle()
                                
                            }) {
                                
                                HStack(spacing: 15){
                                    
                                    Image(systemName: self.start ? "pause.fill" : "play.fill")
                                        .foregroundColor(.white)
                                    //текст на кнопке
        //                            Text(self.start ? "Pause" : "Play")
        //                                .foregroundColor(.white)
                                }
                                .padding(.vertical)
                                .frame(width: (UIScreen.main.bounds.width / 2) - 55)
                                .background(Color.red)
                                .clipShape(Capsule())
                                .shadow(radius: 6)
                            }
                            
                            Button(action: {
                                
                                self.count = 0
                                
                                withAnimation(.default){
                                    
                                    self.to = 0
                                }
                                
                            }) {
                                
                                HStack(spacing: 15){
                                    
                                    Image(systemName: "arrow.clockwise")
                                        .foregroundColor(.red)
                                    //текст на кнопке
        //                            Text("Restart")
        //                                .foregroundColor(.red)
                                    
                                }
                                .padding(.vertical)
                                .frame(width: (UIScreen.main.bounds.width / 2) - 55)
                                .background(
                                
                                    Capsule()
                                        .stroke(Color.red, lineWidth: 2)
                                )
                                .shadow(radius: 6)
                            }
                            
    
    
                        }
                        Text(LocalizedStringKey("Set timer for")).font(.subheadline).lineLimit(1)
                        Text("\(testName)").font(.title).lineLimit(2)
                        VStack {
                        Text(LocalizedStringKey("Attention")).font(.footnote).foregroundColor(.gray).fixedSize(horizontal: false, vertical: true)
                        }.padding(.horizontal)
                    }
                    .padding(.top)
                    .padding(.bottom, 30)
    
                }
                
            }.navigationBarTitle(LocalizedStringKey("Set timer"), displayMode: .inline)
            .onAppear(perform: {
                if self.MaxCount == 0 {
                    let arrayTimer = testTimer.split(separator: " ")
                    if arrayTimer.count > 1 {
                      
                        let counts = Int(arrayTimer[0]) ?? 0
                        
                        /// Преобразование в секунды
                        switch arrayTimer[1] {
                        case "min":
                            self.MaxCount = counts*60
                        case "hour":
                            self.MaxCount = counts*3600
                        case "hours":
                            self.MaxCount = counts*3600
                        default:
                            self.MaxCount = counts
                        }
      
                    }
                    
                }
                
                UNUserNotificationCenter.current().requestAuthorization(options: [.badge,.sound,.alert]) { (_, _) in
                }
            })
            .onReceive(self.time) { (_) in
                
                if self.start{
                    
                    if self.count != MaxCount {
                        
                        self.count += 1
                        print("hello")
                        
                        withAnimation(.default){
                            
                            self.to = CGFloat(self.count) / CGFloat(MaxCount)
                        }
                    }
                    else {
                    
                        self.start.toggle()
                        self.Notify()
                    }
    
                }
                
            }
        }
        
        func Notify(){
            
            let content = UNMutableNotificationContent()
            
            /// key - ключ_локализованной_фразы, comment не обязательно заполнять
            content.title = NSLocalizedString("Perhaps your test is ready", comment: "") 
            
            /// с аргументами (key заменяете на нужное)
            // Вид локализованной строки в файлах локализации "key %@"="It,s time to check your %@";
            content.body = String.localizedStringWithFormat(NSLocalizedString("It's time to check your %@", comment: ""), self.testName)
            
            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
            
            let req = UNNotificationRequest(identifier: "MSG", content: content, trigger: trigger)
            
            UNUserNotificationCenter.current().add(req, withCompletionHandler: nil)
        }
        
        func valueFormat(mCount: Int) -> String {
            String(format: "%02d", arguments: [mCount])
        }
        
        
    }

Solution

  • I had this problem before, and I was struggling to fix it, however I didn't get any useful answer. I tried to activate Background Modes without success.

    Here it is how did I overcome this problem:

    Let's imagine that your counter has started counting, the counter now is at 5 seconds, then suddenly the user enter the background what do we need to do?

    1. Let's go to SceneDelegate and declare those variables

      var appIsDeadAt: Double?
      var appIsBackAliveAt: Double?
      
    2. We need to save the time when the user enter the background in sceneDidEnterBackground

      appIsDeadAt = Date().timeIntervalSince1970
      
    3. When user enter the application again, we need to calculate the time that the application stayed in background. Go to sceneWillEnterForeground and get the time when the application is back active

      appIsBackAliveAt = Date().timeIntervalSince1970
      
    4. Now we need to calculate the total time that the application stayed in the background

      let finalTime = (appIsBackAliveAt! - appIsDeadAt!)
      
    5. Finally, let's say that the application stayed in background for 10 seconds and the previous time is 5 by that 5 + finalTime (Which is 10 seconds) the total time would be 15 seconds, and then update your counter time to continue counting from 15 seconds.

    Note: use UserDefaults to pass the values to your counter, to make it easy for you.

    One real time example is from my own application that is released on applestore: Chatiw

    Basically of my timer, is when the user watch an Ads video I'll reward him with a 300 seconds without ads.

    After implementing all the steps above, when I have the final time I'll store it on UserDefaults:

    userDefaults.setValue(finalTime.rounded(), forKey: "timeInBg")
    

    Then on my main code that contain the counter I'll update the counter to the new time:

    adsRemovalCounter = adsRemovalCounter - Int(self.userDefaults.double(forKey: "timeInBg"))
    

    After that I'll delete the UserDefaults key for the finalTime so it won't interfere with the next calculation when the user enter the background again.

    self.userDefaults.removeObject(forKey: "timeInBg")
    

    Here it is an example in SwiftUI that I just made:

    ContentView File:

    import SwiftUI
    
    struct ContentView: View {
    
    @ObservedObject var counterService = CounterService()
    
    var body: some View {
        
        
        VStack {
            Text("\(self.counterService.counterTime)")
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(Color.white)
                .frame(width: 100, height: 80)
                .background(Color.red)
                .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
                .shadow(color: Color.red.opacity(0.5), radius: 10, x: 5, y: 2)
                .padding()
                .padding(.bottom, 100)
            
            
            Button(action: {
                self.counterService.startCounting()
            }) {
                Text("Start Counter")
                    .font(.title)
                    .fontWeight(.bold)
                    .foregroundColor(Color.white)
                    .frame(width: 200, height: 80)
                    .background(Color.gray)
                    .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
                    .shadow(color: Color.black.opacity(0.5), radius: 10, x: 5, y: 2)
            }
        }
    
    }
        
    }
    

    CounterService File:

    import SwiftUI
    
    class CounterService: ObservableObject {
    
    @Published var counterTime: Int = 0
    
    
    func startCounting(){
        
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
            
            
            if UserDefaults.standard.string(forKey: "timeInBg") != nil {
                self.counterTime = Int(UserDefaults.standard.double(forKey: "timeInBg")) + self.counterTime
                UserDefaults.standard.removeObject(forKey: "timeInBg")
            }
            self.counterTime += 1
            
        }
        
    }
    
    }
    

    SceneDelegate File:

    import UIKit
    import SwiftUI
    
    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    var window: UIWindow?
    
    let userDefaults = UserDefaults.standard
    var appIsDeadAt: Double?
    var appIsBackAliveAt: Double?
    
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    
        let contentView = ContentView().environment(\.managedObjectContext, context)
    
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }
    
    func sceneDidDisconnect(_ scene: UIScene) {
    
    }
    
    func sceneDidBecomeActive(_ scene: UIScene) {
    
    }
    
    func sceneWillResignActive(_ scene: UIScene) {
    
    }
    
    func sceneWillEnterForeground(_ scene: UIScene) {
        appIsBackAliveAt = Date().timeIntervalSince1970
        
        if appIsDeadAt != nil && appIsBackAliveAt != nil {
            let finalTime = (appIsBackAliveAt! - appIsDeadAt!)
            userDefaults.setValue(finalTime.rounded(), forKey: "timeInBg")
        }
        
        
    }
    
    func sceneDidEnterBackground(_ scene: UIScene) {
        appIsDeadAt = Date().timeIntervalSince1970
        (UIApplication.shared.delegate as? AppDelegate)?.saveContext()
    }
    
    
    }
    

    Voila! here you go the result:

    Result

    I hope this answer your question.