Amos Gyamfi
Amos GyamfiAugust 24, 2022

How to make a Heart Rate Animation in SwiftUI

Learn how to create a looping Heart Rate Animation in SwiftUI which replicates the pulsating heartbeat in watchOS.
Making 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.

Making a heart rate animation in SwiftUI

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.

HeartIcon.swift
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

  1. Define the state variableĀ @State private var measuring = false. This will be used to change our properties and create the animation.

  2. 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.


  1. 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.


  1. 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, and miterLimit 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.


  1. Animate the measuring property on appear.
.onAppear {
    withAnimation(.linear(duration: 2.5)
        .repeatForever(autoreverses: false)) {
        measuring.toggle()
    }
}
  1. 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)
    )
)
  1. Add a hue rotation modifier to create a rainbow style.
.hueRotation(.degrees(measuring ? 0 : 360))
  1. 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:

HeartRateMeasuringAnimation.swift
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: