Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
161 views
in Technique[技术] by (71.8m points)

ios - How can I get multiple Timers to appropriately animate a Shape in SwiftUI?

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:

  1. First timer running, animating correctly.
image
  1. Second timer running, animation now gone.
image
  1. 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.
image

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!

question from:https://stackoverflow.com/questions/65874566/how-can-i-get-multiple-timers-to-appropriately-animate-a-shape-in-swiftui

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Reply

0 votes
by (71.8m points)

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.


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
OGeek|极客中国-欢迎来到极客的世界,一个免费开放的程序员编程交流平台!开放,进步,分享!让技术改变生活,让极客改变未来! Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...