1

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

  • Hi, I can't test my answers without supportive code that is missing like `TimerModel` and the `secondsPicker`. Do you have a more minimal recreation of the problem? and/or those missing items? – carlynorama Oct 05 '22 at 19:42
  • I think I updated the code with all I got. Please take a look. – Robert Basamac Oct 05 '22 at 21:23
  • So this still doesn't compile. You're still missing the row view and some other functions. Really the best thing is to provide that minimal reproducible example. https://stackoverflow.com/help/minimal-reproducible-example – carlynorama Oct 05 '22 at 22:09
  • You are right.. Please feel free to check here https://github.com/robertbasamac/TicTac/tree/master/TicTac – Robert Basamac Oct 05 '22 at 22:12
  • Glad you found a solution that worked for you! When you have time you might also want to check out this question - https://stackoverflow.com/questions/65095562/observableobject-is-updating-all-views-and-causing-menus-to-close-in-swiftui – carlynorama Oct 06 '22 at 23:07

2 Answers2

2

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
}
wildcard
  • 896
  • 6
  • 16
  • Thanks for your answer! This solution seems to work, but there is a delay in updating the timers with the new values when closing the AddTimerView and this is visible to the user. I'll think about accepting this answer, but I would like to find a better solution if possible. Maybe there is something wrong with the architecture of the app.. – Robert Basamac Oct 06 '22 at 06:27
  • I managed to fix the mentioned delay by using this - https://stackoverflow.com/questions/59745663/is-there-a-swiftui-equivalent-for-viewwilldisappear-or-detect-when-a-view-is – Robert Basamac Oct 06 '22 at 09:48
  • Glad I could help and that you have found a solution for the delay issue. – wildcard Oct 06 '22 at 13:12
1

Every time your timer fires it needs to update the view - ie re-run the body code, so it's rechecking the popup conditional.

There are a couple of things you could try.

One is to switch your modal presentation from a sheet to a

.navigationDestination(isPresented: $helloNewItem, destination: { HelloThereView() })

Also that AddTimerView shouldn't be inside its own NavigationStack. The one in MainView is the Stack that should manage the subviews.

Having the NavigationStack be in charge might make your MainView less disruptive to your AddTimerView. It's more officially in the background that way than a view behind a sheet in my experience.

The other thing, perhaps quicker to test, is to add a .id("staticName") to the AddTimerView presentation that MIGHT keep it from updating every time?

.navigationDestination(isPresented: $helloNewItem, destination: { HelloThereView().id("TheOneTimerView") })

More on that: https://swiftui-lab.com/swiftui-id/

carlynorama
  • 166
  • 7
  • Thanks for the answer. I understood that the picker resets because the UI is rendered over and over again when timer fires. I also found this post https://developer.apple.com/forums/thread/127218 but I am still not able to use that information. It is the same idea as you mentioned but explained in more details. – Robert Basamac Oct 05 '22 at 21:29
  • Thanks for updating your code! I'll take a look. – carlynorama Oct 05 '22 at 21:31