Creating and animating rounded shapes with AndroidX, Part II
In the previous article, I showed how to use the new AndroidX :graphics:graphics-shapes: library that Sergio Sancho and I recently released to create and draw rounded polygonal shapes. Making it easier to create those kind of shapes was part of what we wanted to accomplish… but it wasn’t our main goal. What we really wanted to do was to make it easier to automatically animate (aka, “morph”) between those shapes.
Spoiler alert: Now you can.
Welcome to Part II of this series, in which I’ll show how to use this new library to do just that: create and draw morph animations on rounded shapes. Easily.
When I first started looking into the problem, it was clear that not only was morphing between shapes a difficult problem, but it’s actually (as far as I can tell) an unsolved research problem.
That is: it is possible to create great animations between arbitrary shapes. But the more random those shapes get, the more this becomes a “design-time” problem, where the developer/designer needs to get involved to figure out exactly how they want the animation to look. The problem is that animating between two very dissimilar, and potentially very complex, shapes can result in some very weird animations. So it takes some intervention on the part of the user/designer to decide how to structure the animation (and possibly the underlying geometry of the shapes) to prevent things from looking too odd.
To help picture why this is the case, let’s say you want to animate from some triangular object to rounded rectangle shape, like this:
The overall approach to such an operation is to make the underlying structures (a series of vector operations) similar, such that you can then just animate between the points making up those structures. You could imagine, for situations like the one above, an approach where you added placeholder curves at appropriate places on the simple shape on the left that expand into the curves needed to achieve the shape on the right. This may not be trivial code, but it is possible to automate this approach.
Now imagine that your design team asked you to animate the triangle to this end shape instead:
Given the combination of the discontinuities and multiple sub-shapes of the object on the right, in addition to the sheer complexity of the end shape compared to the start shape, it’s not clear how one would create such an animation. At the very least, it seems tricky, or even impossible, to automatically calculate such an animation at runtime that looked reasonable. This, then, becomes a design-time problem where someone needs to figure out how the simpler should expand to the more complicated one: where new sub-shapes would animate out from, how to animate the appears of disconnected shapes, and so on.
There are tools for such problems. For example, design tools such as After Effects enable creating and fine-tweaking such animations. And in the Android world, Alex Lockwood created a tool called ShapeShifter which also allows editing complex morphs like this.
But in our case, we wanted all animations to happen automatically. That is, given any start or end shape, we wanted to be able to calculate a morph between the two and run it on the fly, without it looking weird.
So: how to do this? I showed above why this isn’t a tractable problem. In the general case, you simply can’t handle arbitrary objects in a way that enables all morphs to be reasonable. And yet we still wanted to animate between our curved shapes.
It was time to constrain the problem a tad.
The problem above, for general shapes, is that pesky word: general. Sure, if someone throws an arbitrary complex and discontiguous shape at us, we cannot handle it elegantly. On the other hand, for our purposes, we didn’t need to. That is: we wanted to be able to animate between the curved shapes that this library enables; we didn’t need to solve morphing for all shapes. So we constrained the problem to do just that.
In particular, the rounded polygonal shapes that our library produces are (a) contiguous (there are no separate sub-objects like we see in the map shape above) and (b) non self-intersecting* (the vertices proceed in an ordered fashion around the outline). Moreover, we create all of these objects with the same exact structure internally (a series of Bézier cubic curves from start to finish); the only structural difference between any two shapes is just the position and quantity of those curves.
* The non-self-intersection constraint is not strictly true. One of the constructors for RoundedPolygon takes an arbitrary list of vertices, as shown in a the final triangle-ish example of the previous article. So you can actually create your own hopelessly complex self-intersecting arbitrary shape using that API… and you can then run into some of the problems described above. So you will need to, er, constrain yourself (and your shapes) to follow similar rules as we do with the other polygonal constructors in order to enable reasonable morph results.
Constraining the morphing problem in this way allowed us to reduce the infinite research problem of general-case morphing into a problem of mapping these two lists of curves together. And voilà, we had automatic morphing that worked.
The basic approach to morphing is to take two potentially dissimilar shapes and produce some mapping of one shape to the other, such that the animation is as simple as interpolating between the point values of the mapping. In our case, it means mapping one ordered list of cubic curves (for the starting shape) to another list of cubic curves (for the ending shape). The mapping needs to account for proximity (determining which curves are closest to each other between the shapes) as well as quantity (if the shapes do not have the same number of curves, then new curves will be created to ensure that the mapping is 1:1 — each curve on one shape maps to a specific curve on the other).
One important thing to note in our approach is our use of features in the shapes, which is our name for the edges and corners of shapes. Specifically, we extract information about where the features occur on each shape, along with the convexity of the corner features. Aligning the structures (detailed in the steps below) uses information about these features so that features morph to similar features whenever possible.
The most straightforward way to calculate a morph is to simply pair each curve to the one closest to it on the other shape (in terms of the outline progress around both of the curves), adding curves whenever necessary. However, this tends to result in animation artifacts, where curves around corners might split up to become unrelated curves and edges of the other shape. We experimented (a lot!) and ended up taking a different approach that we call “feature mapping,” where we use information about these features, thus preserving some of the fundamental characteristics of each shape through the morph and, along the way, reducing some of the angular artifacts that we had encountered. The end result is much better in general than many of our experiments along the way.
For example, in this screen shot of the demo app, we can see that an animation from the triangle-ish shape to the star maps all four existing corner features of the triangle (the three convex corners and the concave one along the bottom) to similar features in the star. Meanwhile, the other two convex corner features of the star grow out of the edges of the triangle.
In our implementation, the morph setup process is broken up into three stages:
Measurement**: Measurement determines where each curve is located along the overall outline of its shape. This is recorded in an outlineProgress parameter (a value from 0 to 1), indicating where each curve point is between the start (0) and end (1) of the entire shape outline. This step also extracts the features of each shape, recording information about which curves lie along edges, convex corners, or concave corners. This information is important in the later Mapping phase to ensure smooth animations.
Mapping: Given the list of features for each shape, we can now create a mapping between those features (e.g., a convex corner on the start shape should map to the nearest convex corner on the end shape). Once that is done, we can similarly map the rest of the curves on the objects between these shapes, based on the relative proximity of these curves to each other between the mapped features.
Matching: Once the structures are mapped (for their current features/curves), we can then create the morph structure which matches and maps the curves of both shapes, effectively creating a list of values that can be easily and quickly interpolated between the shapes during the animation. An important part of this process is what we call cutting.*** As we walk around both shape outlines, matching the curves on one to the curves on the other, we need to insert curves on potentially both objects to create an overall structure that easily translates from one to the other. For example, in the above example of animating the triangle-ish shape to the star, all four features on the triangle map to similar features on the star, but two additional features for the stars other points need to be inserted in the morph structure so that they can animate into being as we morph from the triangle to the star. This is done by cutting the edge curves on the triangle shape to create the curves that will morph into the convex corners on the star shape.
** It’s worth noting that the current implementation of the library uses a very simple (and fast!) algorithm for calculating distances, using the angle measurement for each point relative to the polygon’s center. This approach is sufficient (and fast!) for most cases, but can be suboptimal in some situations, such as where the vertices are not all equidistant from the center. A better, more general approach is to use the actual curve length (or at least an approximation to it); look for that change to the library eventually, either by default measurer or at least as an option.
*** Start/end shapes usually have unequal numbers of curves. Consider, for example, that an unrounded triangle has just three “curves” to represent its three straight edges, whereas a rounded and smoothed triangle may have as many as twelve curves (three edges plus three for each rounded corner — two flanking curves plus the center circular rounding curve). So there is almost always cutting work to be done to result in structures for both shapes that have the same numbers of curves.
There are many (many) details about how the above steps are executed internally. Although I find them very interesting… I also find them to be a bit beyond the scope of an article showing how to use the API. So I invite you to check out the code for the actual details. Or you can just assume that the code does all of the stuff above, and I’ll get down to the details of how to use the library to actually morph some shapes.
Once you have two shapes (created with the library APIs described in the previous article), morphing between those shapes requires just two steps:
Create the Morph object
Animate the morph’s progress property
… and that’s it. All of the difficult parts of morphing are taken care of for you in the creation of the shapes themselves (with their helpful constraints as outlined above) and in the construction of the morph object.
For example, to animate a pointy triangle to a rounded star shape, you might do something like this:
val pointyTriangle = RoundedPolygon(3) val roundedStar = Star(5, innerRadius = .5f, CornerRounding(.1f)) val morph = Morph(pointyTriangle, roundedStar) // ... one easy way to run the morph animation... ObjectAnimator.ofFloat(morph, "progress", 0f, 1f).start()
The actual animation approach can differ widely from the code in the snippet above. For example, a Compose app would not use an ObjectAnimator here. And you may need to do more to make the update visible in the app (such as invalidating the view which draws the Morph object). But this shows the basic approach: change the morph’s progress to update its shape between the start (0) and end (1) shapes.
For this and the general use of the APIs, check out the ShapesDemo sample code to see how it works in practice, in both the Compose and View UI worlds.
As I said earlier, the complicated parts of morphing are inside the library (and are drastically simplified by some of the constraints discussed above). The actual use of the API is limited to what you see above: create two shapes, create a morph with them, and animate the morph’s progress.
There is, of course, much that we could add to this library to make it even more powerful. Many of the things we have in mind involve flexibility. For example, I mentioned in a footnote above that we currently use angular measurement to track the outlineProgress for the shapes’ curves, and that we will be changing soon to use curve lengths instead. But why not both? We would like to have an API to allow different measurement algorithms to be used, whether they are provided by the library, or by developers creating completely custom measurement approaches.
Similarly, it might be nice to allow developers to plug in different matching algorithms. We’re quite happy with the feature mapping result discussed above, but maybe you want a slightly different feel to your animations (example: allow concave features to map to convex ones in some cases). You cannot do that now… would you like to? It would require some work (and clever APIs), but it seems doable… eventually, if it would be useful.
Another feature area we’d like to explore involves adding the ability to morph arbitrary Path objects. With the introduction to the platform and to AndroidX of path querying APIs (finally!), we should be able to construct a morph between two arbitrary paths, instead of requiring use of the RoundedPolygon API for morphing. The caveat here (and it’s a big one) is what I mentioned above; a morph between two very dissimilar Path objects may produce very undesirable results. This API might be much more restrictive (with possibly less automatically pleasing results), but it would be good to provide some way, even with limitations, to automatically morph existing Path objects.
The library is available in alpha form from AndroidX:
The animation at the start of the article was taken from a sample app now hosted on GitHub:
The sample has both Compose- and View-based apps, showing how to use the library to create and morph shapes for both toolkits. The Compose version has an additional editor view that helps visualize the various shape parameters, along with a debug view that let’s you peek behind the curtain to see the cubic curves making up these shapes.