25 Sep 2019

Animating completed goals in Exist for iOS

One of the small touches that's been in Exist for Android but not our iOS app for ages is an animation whenever you complete a goal that uses a circular progress bar. We use circular progress bars for steps and productive time (sleep also uses one, but it doesn't fill up in the same way).

To celebrate when you've completed one of these goals, after filling up the circular progress bar, Exist for Android shows an animation of the whole circle filling up with the same colour as the progress bar. It's brief, but it makes completing these goals a little more fun.

I finally set aside some time to add something similar to Exist for iOS. For anyone who hasn't gotten their feet wet in iOS animations with Swift, I thought I'd share some of the technical details about how I made this work, and what I learned along the way.

I tried a few different iterations, including confetti and stars exploding from the progress bar. It was fun to learn about using particle systems in iOS with CAEmitterLayer, but I ended up scrapping all of that. My final result consists of the following steps:

  1. The circle fills up from the middle with the same colour as the progress bar
  2. The circle wobbles, getting slightly bigger and smaller a few times
  3. The coloured inside of the circle disappears as a ring of stars bursts from the circle
  4. The inside of the circle fades out to reveal the user's goal and actual steps/productive minutes count as normal

Here's how it looks:

Animation in Exist for iOS


I ran into a few hurdles throughout the five days I spent on this. Here are a couple of problems I had:


This animation consists of a sequence of steps that need to happen without any delay in-between. Initially I tried using UIView.animate(withDuration) to create each step, but doing so created a delay between the steps. I tried nesting animations inside the completion closures of that function, and I tried using PromiseKit wrappers around that function, but I couldn't get each step to flow to the next one smoothly without delays.

I ended up switching to UIView.animateKeyFrames, which let me create a bunch of steps inside a single animation block, and ran through each step more smoothly, without unwanted delays.

Layers and views

I used two views in this animation: a coloured circle that matches the colour of the progress bar, and a white circle to cover the numbers that normally live inside the progress bar. I also used CALayer objects for the star animations.

A problem I ran into with UIView.animateKeyFrames was that I couldn't animate my layers inside this function—it only let me animate views. When I included my layers alongside my view animations inside animateKeyFrames, the delays of my layer animations were ignored, and they just animated immediately.

I couldn't find a way to combine UIView and CALayer animations, so I ended up using layers for my two circles, and using CAAnimation for all of my animations.

The solution

The way I'm creating this animation now is actually pretty simple. It feels complicated because it took me so long to figure out, and because I learned about CAEmitterLayer particle animations and UIView.animateKeyframes before throwing all of that code away and learning CAAnimation instead. I'm glad I did, though, because coding animations feels much less scary now.

I start with a white circle at almost but not quite the full size of the main circle progress bar, and a circle filled with the same colour as the progress bar, at the same size as the white circle, but initially scaled to 0.

I used CAAnimationGroup to combine my animations together, which makes them a little easier to read and reason about. The group has just two animations: one to make the circle expand and wobble, and a second to fade it out when the stars appear. For the wobble, I used CAKeyframeAnimation, which let me pass in a set of values for the scale property, and a duration. Then CAAnimation does the rest of the hard work for me. It looks like this:

let animation = CAKeyframeAnimation(keyPath: "transform.scale")
animation.values = [0, 1.1, 1.0, 1.1, 1.0]
animation.duration = 1.0
animation.beginTime = 0

The beginTime property is relevant to the CAAnimationGroup, and in this case it means to start immediately when the group starts. I used this to offset the second step, which fades out the circle.

In my values array I've set the different values for the circle's scale, which are based on its actual size (which matches the circular progress bar). So it starts at zero, expands to just a little bigger than its full size, then back down to full size, a little bigger again, and ends up at its full size. This gives it the wobble effect I was going for, which is a bit like someone blowing up a balloon.

(Side note: Now that I've learned how this class works, I think CAKeyframeAnimation would have been a great fit for my wiggle animation.)

After this wobble effect finishes, I quickly fade out the circle. I just used CABasicAnimation for the fade:

let alpha = CABasicAnimation(keyPath: "opacity")
alpha.fromValue = 1
alpha.toValue = 0
alpha.duration = 0.1
alpha.beginTime = 1.9

CABasicAnimation takes a fromValue and a toValue, as well as a duration. The beginTime is again relative to the group's time, so in this case it means 1.9 seconds after the group begins,

Putting the animations in a group is simple:

let group = CAAnimationGroup()
group.animations = [animation, alpha]
group.duration = 1.6
group.beginTime = CACurrentMediaTime() + 1.0
group.fillMode = kCAFillModeForwards
group.isRemovedOnCompletion = false

CACurrentMediaTime essentially means "now", so we use that to add a delay to the group's beginTime. This is to make sure the progress bar has time to complete its own animation first. In the future I'd like to refactor the progress bar to have some kind of callback to tell me when it's done animating so I didn't need to hard-code a delay here.

And after making the group, the group itself gets added to the layer:

self.circle.layer.add(group, forKey: "burst")

Quick detour about fillMode and isRemovedOnCompletion, both of which I'm setting on my CAAnimationGroup instance:

The fillMode and isRemovedOnCompletion properties are a bit contentious. The reason they're relevant is that when using CAAnimation to animate a layer, your animation gets set on the model layer but it plays out on the presentation layer. When the animation is over, the presentation layer disappears, and your model layer remains. The problem is, if your model layer didn't change at all, when the presentation layer disappears it'll look like it animated, then returned to its starting state.

So Apple recommends adjusting the model layer to match the final state of the presentation layer. For example, if you're using an animation to set the opacity of a layer to zero, after creating the animation you'd also specifically set the layer's opacity to zero, so that when the presentation layer is removed, the layer shows the same state as the end of the animation.

The fillMode and isRemovedOnCompletion are a sort of terrible hack to avoid having to change your model layer. However, they're generally recommended against, because they make your model and presentation layers stay out of sync by keeping around the presentation layer to make it look like the layer itself is in the state you expect after the animation. But if you then change something else about the model layer, you might not get the result you expect, since what you're looking at is the presentation layer, not the layer you're actually changing.

All that is to say that the one time it seems to be okay to use this hack is when you're going to remove the layer after the animation anyway. In my case, after the entire animation sequence is done, the view and its layers are all removed, so this seems to be okay.

So that's the group set up. The star animation is a whole function of its own, so I just call that inside an asyncAfter closure with a delay to time it with the end of the group animation:

DispatchQueue.main.asyncAfter(deadline: .now() + 1.7) {
  self.playStarAnimation(with: self.colour)

I got most of the code for the star animation from the example code in this WWDC 2017 talk.

The last step is to fade out the white circle, which is used to cover up the progress bar's numbers while the animation takes place:

let whiteFade = CABasicAnimation(keyPath: "opacity")
whiteFade.fromValue = 1
whiteFade.toValue = 0
whiteFade.duration = 0.5
whiteFade.beginTime = CACurrentMediaTime() + 3.0
whiteFade.fillMode = kCAFillModeForwards
whiteFade.isRemovedOnCompletion = false
self.whiteCircle.layer.add(whiteFade, forKey: "white fade")

I just used another CABasicAnimation here, but in this case I'm not using a group. CAAnimationGroup lets you set several animations on the same layer, which was perfect for the wobble and fade of the coloured circle. But now that I want to set an animation on a different layer I can't include it in the same group. So this time I set the animation itself on the layer directly.

Finally, I just remove the view containing all these layers after a delay:

DispatchQueue.main.asyncAfter(deadline: .now() + 5) {

Overall, it's a pretty simple animation process. The star animation is the most complicated part, as there's a fair bit of maths in there to figure out the angle and position of each star. But besides the maths it just uses a CABasicAnimation and a transform that combines rotating, scaling, and moving each star layer in a single step.

CAAnimation is the kind of thing that used to be too low-level and complicated for me to even attempt to understand. I was scared off by how different the APIs seemed compared to higher-level options like UIView.animate(withDuration). But it's really not so bad once you try it out, and it does offer more control which is sometimes exactly what you need. Apart from the resources already linked, I found this objc.io article helpful in understanding CABasicAnimation and CAAnimationGroup.