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 Timer
s 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.
Instead of using an animation with auto-repeat, it works well to use task(id:priority:_:)
to trigger each phase of the animation.
Here is how the example can use this technique:
struct SimpleBeatingView: View {
let currentBPM: Int
private let maxBeatDuration = 0.3
private let defaultScale: CGFloat = 0.8
@State private var isBeating = false
private var beatLength: Double {
60.0 / Double(currentBPM)
}
private var animationDuration: Double {
min(maxBeatDuration, beatLength) / 2
}
var body: some View {
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundStyle(.red)
.scaleEffect(isBeating ? 1 : defaultScale)
.animation(.easeInOut(duration: animationDuration), value: isBeating)
.task(id: isBeating) {
let delaySecs = isBeating
? animationDuration
: max(beatLength / 2, beatLength - (2 * animationDuration))
try? await Task.sleep(for: .seconds(delaySecs))
isBeating.toggle()
}
}
}
Example use:
struct ContentView: View {
@State private var currentBPM: Double = 60
var body: some View {
VStack(spacing: 20) {
HStack(alignment: .firstTextBaseline) {
Text("Current BPM: ")
Text("\(String(format: "%lld", Int(currentBPM.rounded())))")
.font(.title2)
.fontWeight(.semibold)
}
.frame(minWidth: 180, alignment: .leading)
SimpleBeatingView(currentBPM: Int(currentBPM.rounded()))
Slider(value: $currentBPM, in: 30...240)
.padding(.top, 40)
}
.padding()
}
}