Coding
Open Sourcing the BR Radio Play Button
How we leveraged Core Animation to build a joyful interaction for our users
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.
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
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.
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:
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:
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:
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:
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. closeBar:
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.
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:
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).
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. closeBar:
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.
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.
- An array of animations with the target layer they should be applied to
- The resulting
AnimationState
after the animation finished successfully - 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
oranimateToBuffering
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 thecurrentAnimationState
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. ThenextAnimation
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
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
andswift-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!