Coding

Open Sourcing the BR Radio Play Button

How we leveraged Core Animation to build a joyful interaction for our users

Alex Türk
BR Next

--

All too often we concern ourselves with adding more and more features to apps that we forget it is the user experience, interactions and the general look and feel of an app that make it stand out from the crowd. In this post, we focus on a small component that all users of audio apps take for granted: the play button. Join us on our journey of transforming a mere touch target into a fun button that sparks joy in our users!

TL;DR Check out the code

Since the very early stages of development we knew that our play button would be the key user interaction in our app and we would spare no efforts (some might argue we spent too much time 😅) for the animations to be

  • fun and playful
  • interactive
  • interruptible
  • lightweight

In our case lightweight meant that even though the animations were important to us we didn’t want to include an entire animation engine like Spring, Pop, Motion or Lottie.

To implement interruptibility we thought about using UIViewPropertyAnimator but unfortunately we can’t animate paths. So we ended up using Core Animation directly.

Slightly slowed down looping animation of play button between play, buffering, stop and pause

Once upon a time

To understand what made the play button the component it is today it makes sense to illustrate all iterations and design changes it went through.

For the first release of our radio application we haven’t had a rewind feature yet so the play button actually just had two states: playing was represented by an equalizer keyframe animation

Early stage with equalizer animation (tweet)

and not playing by the typical triangle. We used five shape layers. One was for the triangle path morphing into the horizontal bar and the remaining four were revealed when the horizontal bar shrinked to a simple circle on the right.

Early sketches for the initial equalizer animation

The problem we already had to solve back then was to animate a triangle with rounded corners into the horizontal bar which is what you can see here:

Looping animation of triangle with rounded corners to bar with rounded endings

This problem was actually way harder to solve than we initially thought so we’ll give you and in-detail look into the process we went through.

Triangle to horizontal bar (naive way)

If you think this looks super easy let me welcome you to Underestimation Valley — a place where hopes and dreams get crushed and where you’ll inevitably spent way more time than you anticipated. A naive (aka our first) approach would be something like the following:

Well, this is what you get:

Mismatching morph animation from triangle to horizontal bar

Beautiful, isn’t it? Of course that’s not what we wanted. So what’s going on here? The documentation of for CAShapeLayer’s path property states

Paths will interpolate as a linear blend of the “on-line” points; “off-line” points may be interpolated non-linearly (e.g. to preserve continuity of the curve’s derivative). If the two paths have a different number of control points or segments the results are undefined […]

It’s important to note that this does not refer to the explicit instructions we use to construct the CGPath but rather the elements of the resulting “graphics path” that can be inspected using the apply(info:function:) iterator. Using a little helper function

we can see that printing the trianglePath returns something like this (values are rounded):

1. moveTo (300, 200)
2. lineTo (300, 268)
3. curveTo (320, 288), control points [(300, 279), (309, 288)]
4. curveTo (329, 286), control points [(323, 288), (326, 287)]
5. lineTo (464, 218)
6. curveTo (473, 191), control points [(474, 213), (478, 201)]
7. curveTo (464, 182), control points [(471, 187), (468, 184)]
8. lineTo (329, 114)
9. curveTo (302, 123), control points [(319, 109), (307, 113)]
10. curveTo (300, 132), control points [(300, 126), (300, 129)]
11. close

This loosely resembles our instructions for CGMutablePath but there is definitely no one-to-one mapping.

Further, the CGPathElementType only has five different operations and everything that isn’t a straight line gets converted to a quadratic or cubic Bézier curve.

In our case an arc was converted to two cubic curves and an addLineToPoint instruction (which was implicitly coming from our next arc). Compared to the horizontal bar the triangle has one more on-line point which explains the undefined behavior during the animation.

Triangle to horizontal bar (intermediate)

Why are we going into so much detail about this? We cannot directly manipulate the path element types so we used the debug description to try different ways of composing our triangle and bar paths in order for the interpolation to work correctly.

After a lot of trial and error (and hot beverages containing caffeine ☕️) we figured out that we had to divide the triangle’s right tip (arc) in halves to come up with equal numbers of path elements. The following sketch illustrates the different Core Graphic instructions of the the shapes:

A visualization of the CGPath instructions to create the triangle and horizontal bar shape with numbers for their order.
The different implicit and explicit CGPath instructions to create the triangle and horizontal bar shape.

For the triangle we simply use CGMutablePath’s addArc(center:radius:startAngle:endAngle:clockwise:) function to draw three arcs while the second one is divided in halve. This implicitly creates the straight lines in between (orange).

We implemented a generic function that takes the corner points of a strictly convex polygon and a corner radius as input and produces a path of a rounded polygon that lies within the polygon (see gist).

For the horizontal bar the instructions seem a little weird but it will make sense soon. It’s basically also just a series of arcs with implicit lines that connect them.

Notice that at point 2 and 6 we “add a line” to the same point that we’re already at. Again, it will make sense soon, but first let’s look at the result or checkout the playground yourself:

Looping animation from rounded triangle to horizontal bar
Better solution for animating triangle to horizontal bar

This already looks way better than our first attempt but if you squint closely you might see some inconsistencies around the end of the top left arc and the (not so straight) line on the left. Let’s use our debug description again to find out the concrete graphics instructions that result from our path descriptions:

Triangle:
1. moveTo (300, 132)
2. curveTo (320, 112), control points [(300, 121), (309, 112)]
3. curveTo (329, 114), control points [(323, 112), (326, 113)]
4. lineTo (464, 181)
5. curveTo (475, 199), control points [(471, 184), (475, 191)]
6. lineTo (475, 199)
7. curveTo (464, 217), control points [(475, 207), (471, 214)]
8. lineTo (329, 285)
9. curveTo (302, 276), control points [(319, 290), (307, 286)]
10. curveTo (300, 267), control points [(301, 273), (300, 270)]
11. close
Bar:
1. moveTo (300, 200)
2. curveTo (325, 175), control points [(300, 186), (311, 175)]
3. lineTo (325, 175)
4. lineTo (475, 175)
5. curveTo (500, 200), control points [(489, 175), (500, 186)]
6. lineTo (500, 200)
7. curveTo (475, 225), control points [(500, 214), (489, 225)]
8. lineTo (325, 225)
9. curveTo (300, 200), control points [(311, 225), (300, 214)]
10. lineTo (300, 200)
11. close

We meet the most important requirement for the path animation which is an equal number of on-line points (this excludes the control points of the cubic curves).

If we examine a visualization of the points we can not only understand where the imperfections are coming from but also why we added helper points at certain segments.

All the on-line and off-line (control) points for triangle and horizontal bar with different point colors to indicate duplicate points. This is the graphic for the intermediate solution.
On-line points and control points of triangle and horizontal bar paths

Our triangle’s first arc is approximated by two cubic Bézier curves since it takes one curve per 90° and our arc is more than 90° so we add a helper point after the bar’s first arc (which is exactly 90° so only adds one curve) to balance out the number of on-line points.

This artificial over-specification of a path is a general strategy to help guide morph animations.

The triangle’s tip has two points since we split the arc in halve and the addArc function of the second halve adds a line between the current point (tip) and the beginning of the arc (again the tip) even though it’s the same point:

If the specified path already contains a subpath, Core Graphics implicitly adds a line connecting the subpath’s current point to the beginning of the arc

So we have to follow suit and add a helper point in the horizontal bar path as well. One last helper point is added at the green circle which represents three points since it’s also the starting point.

Issues: The first imperfection on the top left results from interpolating the two points of the smaller curve into a single point. It would be better if the curve would be interpolated to a straight line.

The second deviation from an optimal interpolation is basically caused by the same thing with some additional hickups because the paths end there.

Triangle to horizontal bar (manual bezier)

From the last image it seems quite obvious how to fix the issues. We “just” need the points to look like this:

All the on-line and off-line (control) points for triangle and horizontal bar with different point colors to indicate duplicate points. This is the graphic for the manual bezier solution.
On-line points and control points of triangle and horizontal bar paths (optimized version)

The basic idea is to add the extra point after the first and before the last quarter arc of the horizontal bar with a certain distance (d) on the horizontal line.

Just to pick a value we used the same horizontal distance that the second and third on-line points of the triangle have to each other (d). If our button shapes had fixed width, corner radii, bar height etc. we could just note the values to construct the paths and be done with it.

But we want to be able to parameterize some of those values later to play with different designs (no pun intended).

To create a cubic Bézier curve for a CGPath we can use the addCurve function on its mutable counterpart. The input for this function can be calculated on the basis of what we previously did to implement createRoundedShapeWithControlPoints in combination with some help from the internet to approximate arcs using cubic Béziers.

Again, the resulting function would be too long to post but you can just checkout the gist here. It will render the animation we were looking for without any glitches and everyone comparing it with the previous one will agree that the effort was toootally worth it (just a pinch of sarcasm here).

Looping animation from rounded triangle to horizontal bar
Optimized solution for animating triangle to horizontal bar

One thing to note is that we didn’t use transforms to offset and rotate the calculated points because that led to different results from the cubic Bézier points that Core Graphics had.

This way, when we look at the final path instructions we can see some familiar points (they are rounded but the floating point values are exactly the same):

Triangle:
1. moveTo (300, 132)
2. curveTo (320, 112), control points [(300, 121), (309, 112)]
3. curveTo (329, 114), control points [(323, 112), (326, 113)]
4. lineTo (464, 181)
5. curveTo (475, 199), control points [(471, 184), (475, 191)]
6. lineTo (475, 199)
7. curveTo (464, 217), control points [(475, 207), (471, 214)]
8. lineTo (329, 285)
9. curveTo (320, 287), control points [(326, 286), (323, 287)]
10. curveTo (300, 267), control points [(309, 287), (300, 278)]
11. close
Bar:
1. moveTo (300, 200)
2. curveTo (325, 175), control points [(300, 186), (311, 175)]
3. lineTo (334, 175)
4. lineTo (475, 175)
5. curveTo (500, 200), control points [(489, 175), (500, 186)]
6. lineTo (500, 200)
7. curveTo (475, 225), control points [(500, 214), (489, 225)]
8. lineTo (334, 225)
9. lineTo (325, 225)
10. curveTo (300, 200), control points [(311, 225), (300, 214)]
11. close

More than just play and pause

As the feature set of BR Radio advanced so did the complexity of our play button. For a short period of time we actually had two buttons:

a PlayStopButton that could only render a triangle or a rounded rect which we used for the overview page and a PlayButton with play, pause and buffering states used in the sticky and fullscreen player.

During that time our player could already seek back up to five hours in the livestream.

After a lot of feedback from our users we couldn’t wave the “It’s not a bug it’s a feature”-flag anymore so we decided to change how our player behaves when it’s at the live edge.

Instead of the pause button we now display the stop button in the sticky and fullscreen player so subsequent play actions would play from the live position instead of the previously paused position (the old behavior can still be restored in settings).

When the player is not live (either because the user manually seeked back or playback was initiated for a past program) we still display the pause button to indicate that playback will be resumed where the user left off.

Instead of adding 2 buttons on top of each other and syncing their visibility/states with the player we decided to rewrite the two into a single play button component.

This meant we had to merge all of our states and animations and extend them to incorporate previously unnecessary transitions from states like stop to buffering.

Keeping track of state

To reach our goal of interruptibility we have to keep track of the currently executed animation step or the idle state so that we can infer how to animate back when an animation is in-flight. We used a bunch of enums to model those transient and idle states:

The *Idle cases are sort of final states meaning that the button is not animating when in those states. Other cases describe the transition from one to another state.

It’s no coincidence that many of the enums carry the *ToBar or BarTo* in their names. All paths use the horizontal bar as an intermediate state before animating to the target paths.

In the following diagram you can see all possible state transitions. If an arrow emits from an inner state it means that it can only come from that state. If an arrow emits from a state cluster it means the transition can come from any state within that cluster.

A diagram that shows all animation states and arrows connecting possible transitions.
All possible transitions of animation states.

Grouping animations

Another abstraction is to group animations that run simultaneously while transitioning from one animation state to another. Basically, there are always two shape layers animated to the same path (triangle, square, horizontal bar) except when the vertical bar is split to show the pause state.

Also, changing the background color runs alongside the path animations.

  1. An array of animations with the target layer they should be applied to
  2. The resulting AnimationState after the animation finished successfully
  3. We add the animation to the corresponding layer (the buffering animation is a repeating keyframe animation).

In the addBasicAnimationSyncingModelWithPresentationLayer function we check whether there is an ongoing animation for the keypath and if so we set the given animation’s fromValue to the presentation layer’s current value. Then, we set the model layer to the final value (toValue) and add the animation to the layer.

Bringing it all together

Now everything that’s left is assembling the parts we’ve talked about so far. Whenever you set the play button’s mode with the animated parameter set to true the following sequence of events occur:

  • we call the corresponding animateToPause, animatedToStop, animatedToPlay or animateToBuffering functions
  • within those functions we check whether we are either already in the requested state or are currently animating “in the right direction” (i.e. if we want to animate to the play mode we don’t have to do anything if we’re already animating from the horizontal bar to the triangle)
  • if the previous cases don’t apply, we have to kick off an animation group. Depending on the current animation state we call the nextAnimation(baseOn:) function with the state from the opposite direction to get the animation group to run next and assign its result state to the currentAnimationState property. For example: We want to animate to the .pause mode but are currently animating from pause to the vertical bar (→ currentAnimationState = .pauseToBar(.barsMerge)). So we get the next animation group based on the .barToPause(.barRotateVertical) state because this is the idle state that precedes the splitting state. The nextAnimation function will return a group that splits the currently stacked shape layers by animating one to the left and one to the right. The resulting state is .barToPause(.barSplit)
  • whenever an animation (state) finishes successfully, we get the next animation group based on the current state and run it. This continues until we’re in an idle state (or the buffering state)

So every animation is split into individual steps and run in sequence. Admittedly, this leads to big switch statements but the code is easier to reason about than if we combined seemingly redundant states (trust us, we tried 😅).

Remaining path animations

The remaining animations (buffering/stop) all build on the same principles explained before. We over-specify the points of the squircle and the circle that the horizontal bar animates to and from during buffering. The points look like this

Control points of the remaining shapes.

For the squircle we use the same d we calculated for the triangle.

Finishing touches

In the end, we have spent some time on fixing edge case bugs and polishing the look and feel including

  • adding a rounded circle background + correctly centering the triangle
  • restarting the buffering animation when returning to the application
  • styling for highlighted and disabled state
  • pointer interactions, hover gestures
  • tint and background color animation for different modes
  • 3D interpolation effect for tvOS
  • adding documentation using swift-docc and swift-docc-render

Try it out, it’s open source

If you have any questions open a discussion on GitHub, file bugs as an issue or reach out to us directly. We’d love to hear your feedback!

--

--

Alex Türk
BR Next
Writer for

iOS Developer ● Swift enthusiast ● Coffee lover ☕️ ● @fruitcoder