Playing with Paths

Nick Butcher
Android Developers
Published in
7 min readDec 18, 2017

--

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:

Polygon Laps by beesandbombs 🐝💣

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). AVDs 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. AVDs 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. AVDs 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 AVDs… 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. Paths are a fundamental representation of a shape (which AVDs 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.

Cartesian coordinates (left) vs polar coordinates (right)

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 new Path object. I used the dash technique as animating the interval and phase 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:

A re-creation of the original gif as an Android drawable

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:

Comparing translate style (left) with rotate (right)

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:

Demonstrating PathDashPathEffect.Style.MORPH

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 PathEffects using a ComposePathEffect, that’s how the path stamp follows the rounded corners here, by composing a PathDashPathEffect with a CornerPathEffect.

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.

Moving a VectorDrawable along a path

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.

--

--