Home > Software design >  SwiftUI Timer firings interferes with Picker within a sheet and resets its selection
SwiftUI Timer firings interferes with Picker within a sheet and resets its selection

Time:10-06

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:.0f:.0f",
                   (self / 3600).truncatingRemainder(dividingBy: 3600),
                   (self / 60).truncatingRemainder(dividingBy: 60).rounded(.down),
                   truncatingRemainder(dividingBy: 60).rounded(.down))
        } else {
            return String(format: ".0f:.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

CodePudding user response:

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/

CodePudding user response:

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
}
  • Related