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 :|
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
}