Playing with Paths
I recently helped out with a hero animation in an app–unfortunately I can’t share this animation just yet… but I wanted to share what I learned making it. In this post I’ll walk through recreating this mesmerizing animation by Dave ‘beesandbombs’ Whyte which demonstrates many of the same techniques:
My first thought when looking at this (which might not be much of a surprise to anyone who knows my work) was to reach for an AnimatedVectorDrawable
(AVD
hereafter). AVD
s are great, but they’re not suitable for every situation — specifically we had the following requirements:
- I knew we’d need to draw a polygon, but we hadn’t settled on the exact shape.
AVD
s are “pre-baked” animations, as such varying the shape would require re-making the animation. - Part of the ‘progress tracking’ aspect, we’d want to only draw a portion of the polygon.
AVD
s are ‘fire-and-forget’ i.e. you can’t scrub through them. - We wanted to move another object around the polygon. This is definitely achievable with
AVD
s… but again would require a lot of upfront work to pre-calculate the composition. - We wanted to control the progress of the object moving around the polygon separately from the portion of the polygon being shown.
Instead I opted to implement this as a custom Drawable
, made up of Path
objects. Path
s are a fundamental representation of a shape (which AVD
s use under the hood!) and Android’s Canvas
APIs offer pretty rich support for creating interesting effects with them. Before going through some of these, I want to give a shout out to this excellent post by Romain Guy which demonstrates many of techniques which I build upon in this post:
Polar coordination
Usually when defining 2d shapes, we work in (x, y) coordinates technically known as cartesian coordinates. They define shapes by specifying points by their distance from the origin along the x and y axes. An alternative is the polar coordinate system which instead defines points by an angle (θ) and a radius (r) from the origin.
We can convert between polar and cartesian coords with this formula:
val x = radius * Math.cos(angle);
val y = radius * Math.sin(angle);
I highly recommend this post to learn more about polar coordinates:
To generate regular polygons (i.e. where each interior angle is the same), polar coordinates are extremely useful. You can calculate the angle necessary to produce the desired number of sides (as the interior angles total 360º) and then use multiples of this angle with the same radius to describe each point. You can then convert these points into cartesian coordinates which graphics APIs work in. Here’s a function to create a Path
describing a polygon with a given number of sides and radius:
fun createPath(sides: Int, radius: Float): Path {
val path = Path()
val angle = 2.0 * Math.PI / sides
path.moveTo(
cx + (radius * Math.cos(0.0)).toFloat(),
cy + (radius * Math.sin(0.0)).toFloat())
for (i in 1 until sides) {
path.lineTo(
cx + (radius * Math.cos(angle * i)).toFloat(),
cy + (radius * Math.sin(angle * i)).toFloat())
}
path.close()
return path
}
So to recreate our target composition, we can create a list of polygons with different numbers of sides, radius and colors. Polygon
is a simple class which holds this info and calculates the Path
:
private val polygons = listOf(
Polygon(sides = 3, radius = 45f, color = 0xffe84c65.toInt()),
Polygon(sides = 4, radius = 53f, color = 0xffe79442.toInt()),
Polygon(sides = 5, radius = 64f, color = 0xffefefbb.toInt()),
...
)
Effective path painting
Drawing a Path is simple using Canvas.drawPath(path, paint)
but the Paint
parameter supports a PathEffect
which we can use to alter how the path will be drawn. For example we can use a CornerPathEffect
to round off the corners of our polygon or a DashPathEffect
to only draw a portion of the Path
(see the ‘Path tracing’ section of the aforementioned post for more details on this technique):
An alternative technique for drawing a subsection of a path is to use
PathMeasure#getSegment
which copies a portion into a newPath
object. I used the dash technique as animating theinterval
andphase
parameters enabled interesting possibilities.
By exposing the parameters controlling these effects as properties of our drawable, we can easily animate them:
object PROGRESS : FloatProperty<PolygonLapsDrawable>("progress") {
override fun setValue(pld: PolygonLapsDrawable, progress: Float) {
pld.progress = progress
}
override fun get(pld: PolygonLapsDrawable) = pld.progress
}...ObjectAnimator.ofFloat(polygonLaps, PROGRESS, 0f, 1f).apply {
duration = 4000L
interpolator = LinearInterpolator()
repeatCount = INFINITE
repeatMode = RESTART
}.start()
For example, here are different ways of animating the progress of the concentric polygon paths:
Stick to the path
To draw objects along the path, we can use a PathDashPathEffect
. This ‘stamps’ another Path
along a path, so for example stamping blue circles along a polygon might look like this:
PathDashPathEffect
accepts advance
and phase
parameters — that is the gap between stamps and how far to move along the path before the first stamp. By setting the advance to the length of the entire path (obtained via PathMeasure#getLength
), we can draw a single stamp. By animating the phase (here controlled by a dotProgress
parameter [0, 1]) we can make this single stamp move along the path.
val phase = dotProgress * polygon.length
dotPaint.pathEffect = PathDashPathEffect(pathDot, polygon.length,
phase, TRANSLATE)
canvas.drawPath(polygon.path, dotPaint)
We now have all of the ingredients to create our composition. By adding another parameter to each polygon of the number of ‘laps’ each dot should complete per animation loop, we produce this:
You can find the source for this drawable here:
https://gist.github.com/nickbutcher/b41da75b8b1fc115171af86c63796c5b#file-polygonlapsdrawable-kt
Show some style
The eagle eyed amongst you might have noticed the final parameter to PathDashPathEffect
: Style
. This enum controls how to transform the stamp at each position it is drawn. To illustrate how this parameter works the example below uses a triangular stamp instead of a circle and shows both the translate
and rotate
styles:
Notice that when using translate
the triangle stamp is always in the same orientation (pointing left) whilst with the rotate
style, the triangles rotate to remain tangential to the path.
There’s a final style
called morph
which actually transforms the stamp. To illustrate this behaviour, I’ve changed the stamp to a line below. Notice how the lines bend when traversing the corners:
This is an interesting effect but seems to struggle in some circumstances like the start of the path or tight corners.
Note that you can combine
PathEffect
s using aComposePathEffect
, that’s how the path stamp follows the rounded corners here, by composing aPathDashPathEffect
with aCornerPathEffect
.
Going on a tangent
While the above was all we need to recreate the polygon laps composition, my initial challenge actually called for a bit more work. A drawback with using PathDashPathEffect
is that the stamps can only be a single shape and color. The composition I was working on called for a more sophisticated marker so I had to move beyond the path stamping technique. Instead I use a Drawable
and calculate where along the Path
it needs to be drawn for a given progress.
To achieve this, I again used the PathMeasure
class which offers a getPosTan
method to obtain position coordinates and tangent at a given distance along a Path
. With this information (and a little math), we can translate and rotate the canvas to draw our marker
drawable at the correct position and orientation:
pathMeasure.setPath(polygon.path, false)
pathMeasure.getPosTan(markerProgress * polygon.length, pos, tan)
canvas.translate(pos[0], pos[1])
val angle = Math.atan2(tan[1].toDouble(), tan[0].toDouble())
canvas.rotate(Math.toDegrees(angle).toFloat())
marker.draw(canvas)
Find your path
Hopefully this post has demonstrated how a custom drawable using path creation and manipulation can be useful for building interesting graphical effects. Creating a custom drawable gives you the ultimate control to alter and animate different parts of the composition independently. This approach also lets you dynamically supply values rather than having to prepare pre-canned animations. I was very impressed with what you can achieve with Android’s Path
APIs and the built in effects, all of which have been available since API 1.