Creating morphable shapes in Flutter
Shape is a key ingredient in UI design. Flutter offers the circle shape and the rounded rectangle shape out of the box. And it's a good thing that they can be morphed with themselves or between each other. I want to add more shapes to the collection, and it would be nice if those shapes can morph to any other shapes without much input from the programmer.
So the first thing is to find out how to implement shapes in Flutter. I find this package called shape_of_view which implemented multiple common shapes. If we need more shapes, we can just extend the Shape class and write the generatePath() function to describe the shape. The only problem is there is no way to morph between two shapes.
Then another package called path_morph comes to my attention. This package can morph between two Path object by using the PathMetric class. The idea is to divide a path into 100 or so (depends on the precision) equal length line segments. Then you get the same number of control points to approximate the original path. Flutter can interpolate two points (Offset) easily thus you can interpolate the two paths (by interpolating from the starting control points list to the end control points list).
Seems like we can just combine the two packages together and be done with it. There are two catches though. First, the path_morph tends to rotate the shape when morphing, sometimes even make the shape intersect with itself during the morphing process.
Second, when the shapes have only a few edges (like a triangle or rectangle), the morphing feels a little weird to look at (because the circumference and the number of edges of the two shapes are different, some edges of the original shape will bend into two edges). If I were to morph the triangle in the following gif into a rectangle, I would keep its three vertices untouched and morph its longest edge into the bottom and right sides of the rectangle. Also, this operation should only involve four control points, using hundreds of control points is inefficient.
To solve the first problem, I suggest you watch this youtube video. The idea is that you need to align the points in a way that the total offset needed to morph the shape is minimized. Then you rotate either one of the control point lists by this minimumShift.
static int computeMinimumOffsetIndex(
List<Offset> points1, List<Offset> points2) {
int minimumShift = 0;
double minimumOffset = double.infinity;
assert(points1.length == points2.length);
int length = points1.length;
for (int shift = 0; shift < length; shift++) {
double currentOffset = 0.0;
for (int i = 0; i < length; i++) {
currentOffset +=
(points1[(i + shift) % length] - points2[i]).distance;
}
if (currentOffset <= minimumOffset) {
minimumOffset = currentOffset;
minimumShift = shift;
}
}
return minimumShift;
}
Now if we look at the morphing process, there is no rotation.
Now to address the second issue. The key is to find the key control points that govern a simple shape. We can do this by looking at the angle of the tangent line of the path when we walk through the path (by increasing i in the following code snippet from 0 to 1). If the angle of the tangent at this point is the same as the angle at the previous point, they should be on the same line and we don’t need to record the second point.
double angle =
metric.getTangentForOffset(metric.length * i).angle;
Then we get the results as we expected: three control points for the triangle and four control points for the rectangle. The next question is how do we put the one extra control point needed on the triangle. More generally, how do we put the extra control points needed on the path with fewer control points. The natural intuition is to distribute those points based on the length of the edges of the shape. Like what is shown in the following gif.
There is one caveat here. Some edges might be of equal length so we need to find the optimal way to choose which edge to put the point. For example in the following gif, we are morphing a square into a cut-cornered square. The original square has four equal-length sides, but only putting the extra control point at the top or right side is the optimal way to do it. My solution at the moment is to run a Monte Carlo simulation to distribute this extra point and adopt the way that minimizes the total offset needed to morph between the two paths. Since we are dealing with simple shapes here (I choose shapes with less than 12 control points), the time cost is bounded.
There you have it, a library that can generate various common shapes and most importantly, morph between any two shapes in a natural way. The following gifs showcase morphing between more complex shapes. The red dots are the location of the control points used.
We can also morph the thickness and color of the border. To make the following animation, you can just write something like this:
Shape startShape = PolygonShape(numberOfSides: 9);
Shape endShape =
BubbleShape(arrowHeight: 50, arrowWidth: 50, borderRadius: 100);
startBorder = ShapeOfViewBorder(
shape: startShape, borderWidth: 40, borderColor: Colors.redAccent);
endBorder = ShapeOfViewBorder(
shape: endShape, borderColor: Colors.greenAccent, borderWidth: 10);
ShapeOfViewBorderTween shapeBorderTween =
ShapeOfViewBorderTween(begin: startBorder, end: endBorder);
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget child) {
double t = animation.value;
return Center(
child: Material(
animationDuration: Duration.zero,
shape: shapeBorderTween.lerp(t),
clipBehavior: Clip.antiAlias,
child: Container(
color: Colors.amberAccent,
width: 400,
height: 400 - 200 * t,
alignment: Alignment(0, 0 - 0.3 * t),
child: Opacity(
opacity: t,
child: Text(
"Hello world!",
style: TextStyle(fontSize: 20 + t * 20),
),
),
),
),
);
});
Note that if the size of your shape (the size of the rectangle box bounding the shape) does not change during the morph, the computation of the control points will only happen once, otherwise they will be recomputed every time the size changes and may have a performance hit on your app.
Since I have made significant changes to the two packages mentioned at the beginning, I created this repo called morphable_shape and you are more than welcomed to take a look and contribute. You can also check it out on pub.dev.
Because there is a major rewrite of the morphing algorithm, I strongly recommend you read this follow-up article, and try the online demo.