Apple WatchKit Breathe Animation Tutorial

by Lou Franco

In the last article, we drew the initial state of the the Breathe app:

Breathe App Screenshot

With this code:

extension UIColor {
    func with(alpha: CGFloat) -> UIColor {
        var r: CGFloat = 0
        var g: CGFloat = 0
        var b: CGFloat = 0
        self.getRed(&r, green: &g, blue: &b, alpha: nil)
        return UIColor(red: r, green: g, blue: b, alpha: alpha)
    }
}

func circlePositionOf(angle: CGFloat, center: CGPoint, radius: CGFloat, spread: CGFloat) -> CGPoint {
    return CGPoint(
        x: center.x + cos(angle) * radius / 2 * spread,
        y: center.y + sin(angle) * radius / 2 * spread
    )
}

struct ContentView: View {
  // Set this to a value from 0.0 to 1.0
  let spread: CGFloat = 1.0

  // The count of circles
  let count = 11

  var body: some View {
    GeometryReader { geometry in
      ZStack {
        ForEach(0..<count) { i in
          Circle()
            .foregroundColor(Color(UIColor.cyan.with(alpha: 0.4)))
            // The size of each circle is half of the size we are allotted
            .frame(
              width: geometry.size.width / 2.0,
              height: geometry.size.height / 2.0)
            // The position is the center of the circle, with (0, 0) at the top-left
            .position(
              circlePositionOf(
                angle: CGFloat(i) / CGFloat(count) * 2.0 * CGFloat.pi,
                center: CGPoint(x: geometry.size.width / 2,
                        y: geometry.size.height / 2),
                radius: geometry.size.width / 2,
                spread: spread))
        }
      }
    }
    // Make the width and height equal
    .aspectRatio(1.0, contentMode: .fit)
  }
}

Base the animation on a @State variable

I mentioned that spread would be the basis of the animation. So, now let's add in that animation.

The first step is to make spread into a State variable that can change. Since Views are structs, we can't just change the let to a var. Instead we use the @State property wrapper.

Change the state declaration to:

@State var spread: CGFloat = 1.0

Trigger the animation

To keep it simple, we're going to just start the animation when the view appears. We do that using an onAppear block. Add this after aspectRatio

  .onAppear() {
    withAnimation(Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
      self.spread = 0
    }
  }

If you do this, the animation will look like this:

Breathe animation of just spread

But, if you look at the real Breathe app, you will notice that two other things are animating at the same time.

  1. The size of the circles is also changing
  2. The entire view is rotating

Before you read on, see if you can get this effect. To start with, you need to fill in the calls to this (added after onAppear)

.rotationEffect(Angle.radians(/* pass in something based on spread */))
.scaleEffect(CGSize(/* pass in something based on spread */))

Play with this before you move on. Try just using spread directly and then try getting what you want by using expressions based on spread

Using Effects

scaleEffect and rotationEffect are applied to views after they are rendered. If we use spread in the call, then they will animate too.

Since spread starts at 1.0 and goes to 0.0, then we have to think of how we want the effect to look to know how to use it.

For the rotation, if we want the animation to do a half rotation, then we multiply spread by .pi like this:

.rotationEffect(Angle.radians(Double(spread * .pi)))

We would get a full rotation by using 2 * .pi instead.

For the scale, we could use spread directly with:

.scaleEffect(CGSize(width: spread, height: spread))

But then the view completely disappears at the end (try it!).

Maybe we'd like the scale to go from 1.0 to 0.1 so that there's always a small dot visible. There are lots of ways to do that, but a simple way is

.scaleEffect(CGSize(width: max(0.1, spread), height: max(0.1, spread)))

If you do this, you end up with an animation that looks like:

Breathe animation of animation and scale

An exercise to try

The scaleEffect is applied after the view is rendered, and does not change the sizes of individual elements directly.

But, this animation might look nicer if we didn't scale the view at the end, but instead based the individual circle sizes on spread somehow. That way, we'd decrease the size over the animation duration and that would cause it to shrink. The effect would be that it would not become a solid dot so quickly.

To do that, we remove the scaleEffect and instead alter the .frame call so that the width and height are based on spread. It's a subtle difference, but now the rendering is based on the size and the individual circles. Here's a before and after of what it looks like when spread is 0.5 (halfway through the animation)

Breathe app before and after scale change

To try this out:

  1. Comment out the scaleEffect line
  2. Alter the .frame call so that the arguments are based on spread.

Contact me if you need help.

Subscribe to be notified of the next WatchKit article

In the next article we'll start to look at some of the views that make up a typical workout app.

Make sure to sign up to my newsletter to get notified when a new article is published.

Contact me if you need help with this tutorial.

Next Article: How to use NavigationLink in a SwiftUI Watch App

Never miss an article

Get more articles like this in your inbox.