问题
Apologies in advance for any tacky code (I'm still learning Swift and SwiftUI).
I have a project where I'd like to have multiple timers in an Array count down to zero, one at a time. When the user clicks start, the first timer in the Array counts down and finishes, and a completion handler is called which shifts the array to the left with removeFirst()
and starts the next timer (now the first timer in the list) and does this until all timers are done.
I also have a custom Shape called DissolvingCircle, which like Apple's native iOS countdown timer, erases itself as the timer counts down, and stops dissolving when the user clicks pause.
The problem I'm running into is that the animation only works for the first timer in the list. After it dissolves, it does not come back when the second timer starts. Well, not exactly at least: if I click pause while the second timer is running, the appropriate shape is drawn. Then when I click start again, the second timer animation shows up appropriately until that timer ends.
I'm suspecting the issue has to do with the state check I'm making. The animation only starts if timer.status == .running
. In that moment when the first timer ends, its status gets set to .stopped
, then it falls off during the shift, and then the new timer starts and is set to .running
, so my ContentView doesn't appear to see any change in state, even though there is a new timer running. I tried researching some basic principles of Shape animations in SwiftUI and tried re-thinking how my timer's status is being set to get the desired behavior, but I can't come up with a working solution.
How can I best restart the animation for the next timer in my list?
Here is my problematic code below:
MyTimer Class - each individual timer. I set the timer status here, as well as call the completion handler passed as a closure when the timer is finished.
//MyTimer.swift
import SwiftUI
class MyTimer: ObservableObject {
var timeLimit: Int
var timeRemaining: Int
var timer = Timer()
var onTick: (Int) -> ()
var completionHandler: () -> ()
enum TimerStatus {
case stopped
case paused
case running
}
@Published var status: TimerStatus = .stopped
init(duration timeLimit: Int, onTick: @escaping (Int) -> (), completionHandler: @escaping () -> () ) {
self.timeLimit = timeLimit
self.timeRemaining = timeLimit
self.onTick = onTick //will call each time the timer fires
self.completionHandler = completionHandler
}
func start() {
status = .running
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
if (self.timeRemaining > 0) {
self.timeRemaining -= 1
print("Timer status: \(self.status) : \(self.timeRemaining)" )
self.onTick(self.timeRemaining)
if (self.timeRemaining == 0) { //time's up!
self.stop()
}
}
}
}
func stop() {
timer.invalidate()
status = .stopped
completionHandler()
print("Timer status: \(self.status)")
}
func pause() {
timer.invalidate()
status = .paused
print("Timer status: \(self.status)")
}
}
The list of timers is managed by a class I created called MyTimerManager, here:
//MyTimerManager.swift
import SwiftUI
class MyTimerManager: ObservableObject {
var timerList = [MyTimer]()
@Published var timeRemaining: Int = 0
var timeLimit: Int = 0
init() {
//for testing purposes, let's create 3 timers, each with different durations.
timerList.append(MyTimer(duration: 10, onTick: self.updateTime, completionHandler: self.myTimerDidFinish))
timerList.append(MyTimer(duration: 7, onTick: self.updateTime, completionHandler: self.myTimerDidFinish))
timerList.append(MyTimer(duration: 11, onTick: self.updateTime, completionHandler: self.myTimerDidFinish))
self.timeLimit = timerList[0].timeLimit
self.timeRemaining = timerList[0].timeRemaining
}
func updateTime(_ timeRemaining: Int) {
self.timeRemaining = timeRemaining
}
//the completion handler - where the timer gets shifted off and the new timer starts
func myTimerDidFinish() {
timerList.removeFirst()
if timerList.isEmpty {
print("All timers finished")
} else {
self.timeLimit = timerList[0].timeLimit
self.timeRemaining = timerList[0].timeRemaining
timerList[0].start()
}
print("myTimerDidFinish() complete")
}
}
Finally, the ContentView:
import SwiftUI
struct ContentView: View {
@ObservedObject var timerManager: MyTimerManager
//The following var and function take the time remaining, express it as a fraction for the first
//state of the animation, and the second state of the animation will be set to zero.
@State private var animatedTimeRemaining: Double = 0
private func startTimerAnimation() {
let timer = timerManager.timerList.isEmpty ? nil : timerManager.timerList[0]
animatedTimeRemaining = Double(timer!.timeRemaining) / Double(timer!.timeLimit)
withAnimation(.linear(duration: Double(timer!.timeRemaining))) {
animatedTimeRemaining = 0
}
}
var body: some View {
VStack {
let timer = timerManager.timerList.isEmpty ? nil : timerManager.timerList[0]
let displayText = String(timerManager.timeRemaining)
ZStack {
Text(displayText)
.font(.largeTitle)
.foregroundColor(.black)
//This is where the problem is occurring. When the first timer starts, it gets set to
//.running, and so the animation runs approp, however, after the first timer ends, and
//the second timer begins, there appears to be no state change detected and nothing happens.
if timer?.status == .running {
DissolvingCircle(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(animatedTimeRemaining*360-90))
.onAppear {
self.startTimerAnimation()
}
//this code is mostly working approp when I click pause.
} else if timer?.status == .paused || timer?.status == .stopped {
DissolvingCircle(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(Double(timer!.timeRemaining) / Double(timer!.timeLimit)*360-90))
}
}
HStack {
Button(action: {
print("Cancel button clicked")
timerManager.objectWillChange.send()
timerManager.stop()
}) {
Text("Cancel")
}
switch (timer?.status) {
case .stopped, .paused:
Button(action: {
print("Start button clicked")
timerManager.objectWillChange.send()
timer?.start()
}) {
Text("Start")
}
case .running:
Button(action: {
print("Pause button clicked")
timerManager.objectWillChange.send()
timer?.pause()
}){
Text("Pause")
}
case .none:
EmptyView()
}
}
}
}
}
Screenshots:
- First timer running, animating correctly.
- Second timer running, animation now gone.
- I clicked pause on the third timer, ContentView noticed state change. If I click start from here, the animation will work again until the end of the timer.
Please let me know if I can provide any additional code or discussion. I'm glad to also receive recommendations to make other parts of my code more elegant.
Thank you in advance for any suggestions or assistance!
回答1:
I may have found one appropriate answer, similar to one of the answers in How can I get data from ObservedObject with onReceive in SwiftUI? describing the use of .onReceive(_:perform:)
with an ObservableObject.
Instead of presenting the timer's status in a conditional to the ContentView, e.g. if timer?.status == .running
and then executing the timer animation function during .onAppear
, instead I passed the timer's status to .onReceive
like this:
if timer?.status == .paused || timer?.status == .stopped {
DissolvingCircle(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(Double(timer!.timeRemaining) / Double(timer!.timeLimit)*360-90))
} else {
if let t = timer {
DissolvingCircle(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(animatedTimeRemaining*360-90))
.onReceive(t.$status) { _ in
self.startTimerAnimation()
}
}
The view receives the new timer's status, and plays the animation when the new timer starts.
来源:https://stackoverflow.com/questions/65874566/how-can-i-get-multiple-timers-to-appropriately-animate-a-shape-in-swiftui