Search code examples
swiftswiftuitimeruipickerview

SwiftUI Timer firings interferes with Picker within a sheet and resets its selection


I am trying to build a multi timer app and here is the important part of the code for now:

struct TimerModel: Identifiable {
    var id: String = UUID().uuidString
    var title: String
    var startTime: Date? {
        didSet {
            alarmTime = Date(timeInterval: duration, since: startTime ?? Date())
        }
    }
    var pauseTime: Date? = nil
    var alarmTime: Date? = nil
    var duration: Double
    var timeElapsed: Double = 0 {
        didSet {
            displayedTime = (duration - timeElapsed).asHoursMinutesSeconds
        }
    }
    var timeElapsedOnPause: Double = 0
    var remainingPercentage: Double = 1
    var isRunning: Bool = false
    var isPaused: Bool = false
    var displayedTime: String = ""
    
    init(title: String, duration: Double) {
        self.duration = duration
        self.title = title
        self.displayedTime = self.duration.asHoursMinutesSeconds
    }
}
class TimerManager: ObservableObject {
    
    @Published var timers: [TimerModel] = [] // will hold all the timers

    @Published private var clock: AnyCancellable?
    
    private func startClock() {
        clock?.cancel()
        
        clock = Timer
            .publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else { return }
                
                for index in self.timers.indices {
                    self.updateTimer(forIndex: index)
                }
            }
    }
    
    private func stopClock() {
        let shouldStopClock: Bool = true
        
        for timer in timers {
            if timer.isRunning && !timer.isPaused {
                return
            }
        }
        
        if shouldStopClock {
            clock?.cancel()
        }
    }
    
    private func updateTimer(forIndex index: Int) {
        if self.timers[index].isRunning && !self.timers[index].isPaused {
            self.timers[index].timeElapsed = Date().timeIntervalSince(self.timers[index].startTime ?? Date())
            self.timers[index].remainingPercentage = 1 - self.timers[index].timeElapsed / self.timers[index].duration
            
            if self.timers[index].timeElapsed < self.timers[index].duration {
                let remainingTime = self.timers[index].duration - self.timers[index].timeElapsed
                self.timers[index].displayedTime = remainingTime.asHoursMinutesSeconds
            } else {
                self.stopTimer(self.timers[index])
            }
        }
    }
    
    func createTimer(title: String, duration: Double) {
        let timer = TimerModel(title: title, duration: duration)
        timers.append(timer)
        startTimer(timer)
    }
    
    func startTimer(_ timer: TimerModel) {
        startClock()
        
        if let index = timers.firstIndex(where: { $0.id == timer.id }) {
            timers[index].startTime = Date()
            timers[index].isRunning = true
        }
    }
    
   func pauseTimer(_ timer: TimerModel) {
        if let index = timers.firstIndex(where: { $0.id == timer.id }) {
            timers[index].pauseTime = Date()
            timers[index].isPaused = true
        }
        
        stopClock()
    }
    
    func resumeTimer(_ timer: TimerModel) {
        startClock()
        
        if let index = timers.firstIndex(where: { $0.id == timer.id }) {
            timers[index].timeElapsedOnPause = Date().timeIntervalSince(self.timers[index].pauseTime ?? Date())
            timers[index].startTime = Date(timeInterval: timers[index].timeElapsedOnPause, since: timers[index].startTime ?? Date())
            timers[index].isPaused = false
        }
    }
    
    func stopTimer(_ timer: TimerModel) {
        if let index = timers.firstIndex(where: { $0.id == timer.id }) {
            timers[index].startTime = nil
            timers[index].alarmTime = nil
            timers[index].isRunning = false
            timers[index].isPaused = false
            timers[index].timeElapsed = 0
            timers[index].timeElapsedOnPause = 0
            timers[index].remainingPercentage = 1
            timers[index].displayedTime = timers[index].duration.asHoursMinutesSeconds
        }
        
        stopClock()
    }
    
    func deleteTimer(_ timer: TimerModel) {
        timers.removeAll(where: { $0.id == timer.id })
        
        stopClock()
    }
}
struct MainView: View {
    @EnvironmentObject private var tm: TimerManager
    
    @State private var showAddTimer: Bool = false
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(tm.timers) { timer in
                    TimerRowView(timer: timer)
                        .listRowInsets(.init(top: 4, leading: 20, bottom: 4, trailing: 4))
                }
            }
            .listStyle(.plain)
            .navigationTitle("Timers")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        showAddTimer.toggle()
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showAddTimer) {
                AddTimerView()
            }
        }
    }
}
struct AddTimerView: View {
    
    @EnvironmentObject private var tm: TimerManager
    
    @Environment(\.dismiss) private var dismiss
    
    @State private var secondsSelection: Int = 0
    
    private var seconds: [Int] = [Int](0..<60)

    
    var body: some View {
        NavigationStack {
            VStack {
                secondsPicker
                Spacer()
            }
            .navigationTitle("Add Timer")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button {
                        dismiss()
                    } label: {
                        Text("Cancel")
                    }
                }
                
                ToolbarItem(placement: .confirmationAction) {
                    Button {
                        tm.createTimer(title: "Timer added from View", duration: getPickerDurationAsSeconds())
                        dismiss()
                    } label: {
                        Text("Save")
                    }
                    .disabled(getPickerDurationAsSeconds() == 0)
                }
            }
        }
    }
}
extension AddTimerView {
    private var secondsPicker: some View {
        Picker(selection: $secondsSelection) {
            ForEach(seconds, id: \.self) { index in
                Text("\(index)").tag(index)
                    .font(.title3)
            }
        } label: {
            Text("Seconds")
        }
        .pickerStyle(.wheel)
        .labelsHidden()
    }

    private func getPickerDurationAsSeconds() -> Double {
        var duration: Double = 0
        
        duration += Double(hoursSelection) * 60 * 60
        duration += Double(minutesSelection) * 60
        duration += Double(secondsSelection)
        
        return duration
    }
}
extension TimeInterval {
    
    var asHoursMinutesSeconds: String {
        if self > 3600 {
            return String(format: "%0.0f:%02.0f:%02.0f",
                   (self / 3600).truncatingRemainder(dividingBy: 3600),
                   (self / 60).truncatingRemainder(dividingBy: 60).rounded(.down),
                   truncatingRemainder(dividingBy: 60).rounded(.down))
        } else {
            return String(format: "%02.0f:%02.0f",
                   (self / 60).truncatingRemainder(dividingBy: 60).rounded(.down),
                   truncatingRemainder(dividingBy: 60).rounded(.down))
        }
        
    }
}

extension Date {
    
    var asHoursAndMinutes: String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .none
        dateFormatter.timeStyle = .short
        
        return dateFormatter.string(from: self)
    }
}

The issue is that if I have the clock running and the .sheet with AddTimerView displayed, the Picker is reseting its selection when the clock is firing (check recording). My intention is to make the timer runs at 10ms or 1ms, not 1s... When I change the timer to 10ms, I actually cannot interact with the Picker because the Timer is firing so fast that the selection resets instantly.

Does anyone know how to get rid of this issue? Is the timer implementation wrong or not at least not good for a multi timer app?

PS1: I noticed that when the clock runs at 10ms/1ms, the CPU usage is ~30%/70%. Moreover, when the sheet is presented, the CPU usage is ~70%/100%. Is this expected?

PS2: I also noticed that when testing on a physical device, the "+" button from the main toolbar is not working every time. I have to scroll the Timers list in order for the button to work again. This is strange :|

enter image description here


Solution

  • There is another solution. Since your timer calculation is based on the difference between Dates, you can 'pause' the updateTimer function while your AddTimerView Sheet is open.

    Add this to your TimerManager:

    @Published var isActive: Bool = true
    

    Perform updates only if isActive is true:

    private func updateTimer(forIndex index: Int) {
        if isActive { // <--- HERE
                
            if self.timers[index].isRunning && !self.timers[index].isPaused {
                self.timers[index].timeElapsed = Date().timeIntervalSince(self.timers[index].startTime ?? Date())
                self.timers[index].remainingPercentage = 1 - self.timers[index].timeElapsed / self.timers[index].duration
                    
                if self.timers[index].timeElapsed < self.timers[index].duration {
                    let remainingTime = self.timers[index].duration - self.timers[index].timeElapsed
                    self.timers[index].displayedTime = remainingTime.asHoursMinutesSeconds
                } else {
                    self.stopTimer(self.timers[index])
                }
            }
        }
    }
    

    Set isActive when AddTimerView appears or disappears.

    NavigationStack {
        .
        .
        .
    }
    .onAppear{
        tm.isActive = false
    }
    .onDisappear{
        tm.isActive = true
    }