How to make a Heart Rate Animation in SwiftUI
This is the third tutorial of our series on the basics of animation in SwiftUI. If you missed our last article, you can find it here. This article will focus on making a heart rate measuring animation similar to the one seen on watchOS.
This is what our end result is going to look like. Let's get started!
Creating the heart shape
To follow along, you are required to draw a custom shape through a SwiftUI Path. Since drawing paths by hand is a difficult task, you can use a design tool likeĀ Kite Compositor or this online tool to generate Swift code. To learn more about how to create complex shape outlines for SwiftUI, check thisĀ video.
To start, create a blank SwiftUI project and create your shape by copying the code below.
struct HeartIcon: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.size.width
let height = rect.size.height
path.move(to: CGPoint(x: 0.49616*width, y: 0.89542*height))
path.addCurve(to: CGPoint(x: 0.48666*width, y: 0.8901*height), control1: CGPoint(x: 0.49477*width, y: 0.89542*height), control2: CGPoint(x: 0.4916*width, y: 0.89365*height))
path.addCurve(to: CGPoint(x: 0.33791*width, y: 0.769*height), control1: CGPoint(x: 0.43473*width, y: 0.85308*height), control2: CGPoint(x: 0.38515*width, y: 0.81271*height))
path.addCurve(to: CGPoint(x: 0.21169*width, y: 0.63198*height), control1: CGPoint(x: 0.29067*width, y: 0.72529*height), control2: CGPoint(x: 0.2486*width, y: 0.67961*height))
path.addCurve(to: CGPoint(x: 0.12417*width, y: 0.48678*height), control1: CGPoint(x: 0.17478*width, y: 0.58434*height), control2: CGPoint(x: 0.14561*width, y: 0.53594*height))
path.addCurve(to: CGPoint(x: 0.09202*width, y: 0.3411*height), control1: CGPoint(x: 0.10274*width, y: 0.43762*height), control2: CGPoint(x: 0.09202*width, y: 0.38906*height))
path.addCurve(to: CGPoint(x: 0.11764*width, y: 0.22099*height), control1: CGPoint(x: 0.09202*width, y: 0.29531*height), control2: CGPoint(x: 0.10056*width, y: 0.25527*height))
path.addCurve(to: CGPoint(x: 0.18729*width, y: 0.14084*height), control1: CGPoint(x: 0.13472*width, y: 0.18672*height), control2: CGPoint(x: 0.15793*width, y: 0.16*height))
path.addCurve(to: CGPoint(x: 0.28757*width, y: 0.1121*height), control1: CGPoint(x: 0.21665*width, y: 0.12168*height), control2: CGPoint(x: 0.25008*width, y: 0.1121*height))
path.addCurve(to: CGPoint(x: 0.36607*width, y: 0.12915*height), control1: CGPoint(x: 0.31787*width, y: 0.1121*height), control2: CGPoint(x: 0.34403*width, y: 0.11778*height))
path.addCurve(to: CGPoint(x: 0.42233*width, y: 0.17195*height), control1: CGPoint(x: 0.38811*width, y: 0.14052*height), control2: CGPoint(x: 0.40686*width, y: 0.15478*height))
path.addCurve(to: CGPoint(x: 0.46149*width, y: 0.22335*height), control1: CGPoint(x: 0.43779*width, y: 0.18912*height), control2: CGPoint(x: 0.45085*width, y: 0.20626*height))
path.addCurve(to: CGPoint(x: 0.47917*width, y: 0.24549*height), control1: CGPoint(x: 0.46816*width, y: 0.2342*height), control2: CGPoint(x: 0.47405*width, y: 0.24158*height))
path.addCurve(to: CGPoint(x: 0.49616*width, y: 0.25135*height), control1: CGPoint(x: 0.48429*width, y: 0.2494*height), control2: CGPoint(x: 0.48995*width, y: 0.25135*height))
path.addCurve(to: CGPoint(x: 0.5129*width, y: 0.24524*height), control1: CGPoint(x: 0.5024*width, y: 0.25135*height), control2: CGPoint(x: 0.50798*width, y: 0.24931*height))
path.addCurve(to: CGPoint(x: 0.53079*width, y: 0.22335*height), control1: CGPoint(x: 0.51781*width, y: 0.24116*height), control2: CGPoint(x: 0.52377*width, y: 0.23387*height))
path.addCurve(to: CGPoint(x: 0.57099*width, y: 0.1724*height), control1: CGPoint(x: 0.54212*width, y: 0.20656*height), control2: CGPoint(x: 0.55552*width, y: 0.18958*height))
path.addCurve(to: CGPoint(x: 0.62677*width, y: 0.12937*height), control1: CGPoint(x: 0.58645*width, y: 0.15523*height), control2: CGPoint(x: 0.60504*width, y: 0.14089*height))
path.addCurve(to: CGPoint(x: 0.70475*width, y: 0.1121*height), control1: CGPoint(x: 0.6485*width, y: 0.11786*height), control2: CGPoint(x: 0.67449*width, y: 0.1121*height))
path.addCurve(to: CGPoint(x: 0.8053*width, y: 0.14084*height), control1: CGPoint(x: 0.74225*width, y: 0.1121*height), control2: CGPoint(x: 0.77576*width, y: 0.12168*height))
path.addCurve(to: CGPoint(x: 0.87497*width, y: 0.22099*height), control1: CGPoint(x: 0.83483*width, y: 0.16*height), control2: CGPoint(x: 0.85805*width, y: 0.18672*height))
path.addCurve(to: CGPoint(x: 0.90035*width, y: 0.3411*height), control1: CGPoint(x: 0.89189*width, y: 0.25527*height), control2: CGPoint(x: 0.90035*width, y: 0.29531*height))
path.addCurve(to: CGPoint(x: 0.8682*width, y: 0.48678*height), control1: CGPoint(x: 0.90035*width, y: 0.38906*height), control2: CGPoint(x: 0.88964*width, y: 0.43762*height))
path.addCurve(to: CGPoint(x: 0.78066*width, y: 0.63198*height), control1: CGPoint(x: 0.84676*width, y: 0.53594*height), control2: CGPoint(x: 0.81758*width, y: 0.58434*height))
path.addCurve(to: CGPoint(x: 0.65442*width, y: 0.769*height), control1: CGPoint(x: 0.74374*width, y: 0.67961*height), control2: CGPoint(x: 0.70165*width, y: 0.72529*height))
path.addCurve(to: CGPoint(x: 0.50567*width, y: 0.8901*height), control1: CGPoint(x: 0.60718*width, y: 0.81271*height), control2: CGPoint(x: 0.55759*width, y: 0.85308*height))
path.addCurve(to: CGPoint(x: 0.49616*width, y: 0.89542*height), control1: CGPoint(x: 0.50072*width, y: 0.89365*height), control2: CGPoint(x: 0.49755*width, y: 0.89542*height))
path.closeSubpath()
return path
}
}
We need to have a custom heart Shape
because an SF Symbol, such as 'heart'
cannot be styled as such.
Now, let's get to the animation!
The dash phase of a path can be used to move dashed strokes along a path. This is called the marching ant effect. In this chapter, you will discover how to create animated marching ant effects in SwiftUI. This animation is similar to the heart rate measuring animation you see on watchOS. We'll be using the dash phase trick, together with an angular gradient and hue rotation.
First, create a new View titled HeartRateMeasuringAnimation
Define the state variableĀ
@State private var measuring = false
. This will be used to change our properties and create the animation.Define the colors for your gradient as view properties using color literals.
let blue = Color(#colorLiteral(red: 0, green: 0.3725490196, blue: 1, alpha: 1))
let red = Color(#colorLiteral(red: 1, green: 0, blue: 0, alpha: 1))
Color literals are an Xcode feature that allow you to pick colors from the code editor and have them be used directly in code. These are great for small projects, but for larger apps you'll probably be better off storing colors another way, such as in an asset catalog color set.
- Begin your body property with an instance of the shape you created and give it a fixed size.
var body: some View {
HeartIcon()
.frame(width: 64, height: 64)
}
Giving your shape a fixed size is important because it's crucial for the marching ant effect to work correctly.
- Stroke the shape with a dashed line - add this modifier before the frame.
.stroke(style:
StrokeStyle(lineWidth: 5,
lineCap: .round,
lineJoin: .round,
miterLimit: 0,
dash: [150, 15],
dashPhase: measuring ? -83 : 83)
)
Let's review what each line does here:
lineWidth
says that the line should be 5 pixels wide.lineCap
,lineJoin
, andmiterLimit
ensure that the borders and line ends are all rounded.dash
specifies the length of the painted dashes and the length of the blank spaces along the line. this is relative to the side length of the shape.dashPhase
is the amount of blank space that should be painted before the first dash. This is the property that will be animated.
Make sure to get dash
and dashPhase
right according to the size of your shape so the end animation is nice and smooth.
- Animate the
measuring
property on appear.
.onAppear {
withAnimation(.linear(duration: 2.5)
.repeatForever(autoreverses: false)) {
measuring.toggle()
}
}
- Create an angular gradient that goes from blue to red and then blue again, and set it as the foreground style, then animate it with the measuring animation.
.foregroundStyle(
.angularGradient(
colors: [blue, red, blue],
center: .center,
startAngle: .degrees(measuring ? 360 : 0),
endAngle: .degrees(measuring ? 720 : 360)
)
)
- Add a hue rotation modifier to create a rainbow style.
.hueRotation(.degrees(measuring ? 0 : 360))
- Extra: add a label and background, then set a leading alignment.
ZStack {
Color.black
VStack(alignment: .leading) {
Text("Measuring Heart Rate")
.foregroundColor(.white)
.bold()
// Add your shape here
}
}
Let's put it all together:
struct HeartRateMeasuringAnimation: View {
@State var measuring = false
let blue = Color(#colorLiteral(red: 0, green: 0.3725490196, blue: 1, alpha: 1))
let red = Color(#colorLiteral(red: 1, green: 0, blue: 0, alpha: 1))
var body: some View {
ZStack {
Color.black
VStack(alignment: .leading) {
Text("Measuring Heart Rate")
.foregroundColor(.white)
.bold()
HeartIcon()
.stroke(style:
StrokeStyle(lineWidth: 5,
lineCap: .round,
lineJoin: .round,
miterLimit: 0,
dash: [150, 15],
dashPhase: measuring ? -83 : 83)
)
.frame(width: 64, height: 64)
.foregroundStyle(
.angularGradient(
colors: [blue, red, blue],
center: .center,
startAngle: .degrees(measuring ? 360 : 0),
endAngle: .degrees(measuring ? 720 : 360)
)
)
.hueRotation(.degrees(measuring ? 0 : 360))
.onAppear {
withAnimation(.linear(duration: 2.5)
.repeatForever(autoreverses: false)) {
measuring.toggle()
}
}
}
}
}
}
And that's it! Pretty easy, huh?
Make sure to check out the other parts of the SwiftUI Animation series: