swiftanimationswiftui

Delay a repeating animation in SwiftUI with between full autoreverse repeat cycles


I’m building an Apple Watch app in SwiftUI that reads the user’s heart rate and displays it next to a heart symbol.

I have an animation that makes the heart symbol beat repeatedly. Since I know the actual user’s heart rate, I’d like to make it beat at the same rate as the user’s heart rate, updating the animation every time the rate changes.

I can determine how long many seconds should be between beats by dividing the heart rate by 60. For example, if the user’s heart rate is 80 BPM, the animation should happen every 0.75 seconds (60/80).

Here is example code of what I have now, where currentBPM is a constant, but normally that will be updated.

struct SimpleBeatingView: View {
    
    // Once I get it working, this will come from a @Published Int that gets updated any time a new reading is avaliable.
    let currentBPM: Int = 80
    
    @State private var isBeating = false
    
    private let maxScale: CGFloat = 0.8
    
    var beatingAnimation: Animation {
        
        // The length of one beat
        let beatLength = 60 / Double(currentBPM)
        
        return Animation
            .easeInOut(duration: beatLength)
            .repeatForever()
    }
    
    var body: some View {
        Image(systemName: "heart.fill")
            .font(.largeTitle)
            .foregroundColor(.red)
            .scaleEffect(isBeating ? 1 : maxScale)
            .animation(beatingAnimation)
            .onAppear {
                self.isBeating = true
            }
    }
}

I'm looking to make this animation behave more like Apple's built-in Heart Rate app. Instead of the heart constantly getting bigger or smaller, I'd like to have it beat (the animation in BOTH directions) then pause for a moment before beating again (the animation in both directions) then pause again, and so on.

When I add a one second delay, for example, with .delay(1) before .repeatForever(), the animation pauses half way through each beat. For example, it gets smaller, pauses, then gets bigger, then pauses, etc.

I understand why this happens, but how can I insert the delay between each autoreversed repeat instead of at both ends of the autoreversed repeat?

I'm confident I can figure out the math for how long the delay should be and the length of each beat to make everything work out correctly, so the delay length can be arbitrary, but what I'm looking for is help on how I can achieve a pause between loops of the animation.

One approach I played with was to flatMap the currentBPM into repeating published Timers every time I get a new heart rate BPM so I can try to drive animations from those, but I wasn't sure how I can actually turn that into an animation in SwiftUI and I'm not sure if manually driving values that way is the right approach when the timing seems like it should be handled by the animation, based on my current understanding of SwiftUI.


Solution

  • A possible solution is to chain single pieces of animation using DispatchQueue.main.asyncAfter. This gives you control when to delay specific parts.

    Here is a demo:

    enter image description here

    struct SimpleBeatingView: View {
        @State private var isBeating = false
        @State private var heartState: HeartState = .normal
    
        @State private var beatLength: TimeInterval = 1
        @State private var beatDelay: TimeInterval = 3
    
        var body: some View {
            VStack {
                Image(systemName: "heart.fill")
                    .imageScale(.large)
                    .font(.largeTitle)
                    .foregroundColor(.red)
                    .scaleEffect(heartState.scale)
                Button("isBeating: \(String(isBeating))") {
                    isBeating.toggle()
                }
                HStack {
                    Text("beatLength")
                    Slider(value: $beatLength, in: 0.25...2)
                }
                HStack {
                    Text("beatDelay")
                    Slider(value: $beatDelay, in: 0...5)
                }
            }
            .onChange(of: isBeating) { isBeating in
                if isBeating {
                    startAnimation()
                } else {
                    stopAnimation()
                }
            }
        }
    }
    
    private extension SimpleBeatingView {
        func startAnimation() {
            isBeating = true
            withAnimation(Animation.linear(duration: beatLength * 0.25)) {
                heartState = .large
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + beatLength * 0.25) {
                withAnimation(Animation.linear(duration: beatLength * 0.5)) {
                    heartState = .small
                }
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + beatLength * 0.75) {
                withAnimation(Animation.linear(duration: beatLength * 0.25)) {
                    heartState = .normal
                }
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + beatLength + beatDelay) {
                withAnimation {
                    if isBeating {
                        startAnimation()
                    }
                }
            }
        }
    
        func stopAnimation() {
            isBeating = false
        }
    }
    
    enum HeartState {
        case small, normal, large
    
        var scale: CGFloat {
            switch self {
            case .small: return 0.5
            case .normal: return 0.75
            case .large: return 1
            }
        }
    }