Creating morphable shapes in Flutter — A complete rewrite
Greetings fellow coders. In my previous article “Creating morphable shapes in Flutter”, I talked about how to use the PathMetric class to morph two arbitrary shapes in Flutter. The basic ideas are the following:
- The PathMetric class can traverse the border of a shape(represented by the Path class in Flutter) and generate a list of points(represented by the Offset class) on the border.
- We take the two lists of border points and try to calculate the intermediate states between them. This is easy, as the intermediate states between two points on a 2D plane are just along the straight line between the two points. So the intermediate state between two lists of points is just a list of intermediate states of each pair of the points.
- I did some tricks to improve the morphing, such as vertex detection to reduce the number of points needed, shift the list to minimize the total offset needed to eliminate the unnatural spinning of the shape during the morphing process. And the final algorithm seems to work very well on both simple and complex shapes.
So what's the problem?
Then I tried to build an online tool for people to see the morphing process and found out the performance on the web is horrible for complex shapes (e.g. 20 cornered star shape with rounded corners). I looked back on my morphing algorithm and found that there are some problems.
- The number of points used is sometimes unnecessarily large. It typically requires a few hundred points to morph between two shapes. As we are essentially approximating curves in a shape by many small line segments, this is inevitable.
- The intermediate shape does not go back to the original shapes at 0% or 100% progress. This is again inevitable as we are approximating a shape by many small line segments. We can improve the situation by increasing the accuracy when sampling points along the border, but that would make the performance even worse.
- Traversing the shape border using PathMetric incurs a performance hit as well as it needs to call native methods to get an arbitrary location on a path.
Adding an extra layer to solve the problem!
Looking at the code to generate a Path object (that describes a Shape):
Since an arc can be approximated by one or multiple cubic Bezier curves pretty well, we can use just a bunch of linear and cubic Bezier curves to define a shape. What if I just animate those curves instead? This would solve the aforementioned three issues, as
- The number of points needed to drive the animation is much smaller. We only need two points for a linear Bezier and four points for a cubic Bezier.
- The intermediate shape will go back to the exact original shapes at 0% or 100% progress, as the original shapes are defined also by linear and cubic Beziers.
- We don’t use the PathMetric class at all so this step is completely skipped.
I just need to store those curves in an intermediate data class (I call it DynamicPath) before generating the actual path for Flutter to render.
The remaining problem is shapes generally do not have the same sequence of Bezier curves. For example, the rectangle shape has 4 linear Bezier curves while a circle has 8 cubic Bezier curves. To morph a rectangle into a circle, we need them to have the same number of Bezier curves. So we need to know how to split a cubic Bezier into multiple parts. We also need to know how to morph a linear Bezier into a cubic Bezier and vice versa. Those two questions kinda have standard answers and I just need to implement them in Dart.
The final question is how many Bezier curves do we need. For example, for a rectangle (which has four linear Bezier curves) to morph into a triangle (which has three linear Bezier curves), we can either split one of the sides of the triangle into two linear Bezier curves, or we can have 12 Bezier curves in total by splitting each side of the rectangle into three and each side of the triangle into four.
The first method has the benefit that it uses fewer curves. However, it raises the question of which side of the triangle do we choose to split? A natural choice is to choose the longest side. But if we need to split multiple times and the shape has multiple sides that are of equal length, we run into trouble. Also, the longest side may not give you the most natural morphing process. The easiest way to solve those issues is to run a Monte Carlo simulation. Sample the split positions and choose the one that needs the smallest total offset for the curves to complete the morph. This Monte Carlo does not guarantee the absolute minimum as we only ran it for finite iterations. But it should work well if the total number of curves we want is small. Because the Monte Carlo sampling is weighted by the length of each curve, I call this method the weighted method.
The second method is easier at the preparation step. Say shape1 has m curves, shape2 has n curves. The total curves we need are just l=lcm(m,n) (the least common multiple). Then every curve on shape1 will be divided into n parts and every curve on shape2 will be divided into m parts. Then we can start our morphing process. However, this brings a performance penalty compared to the first method. The total number of curves may be too large (for example, lcm(15, 31)=465). We have to put a cap on the total number of curves (currently set to 120). Because this method does not care about the length of each curve, I call it the unweighted method.
So you can imagine that both methods has its range of applicability. We let you choose between them by changing the MorphMethod setting when you use the MorphableShapeTween to morph between shapes. MorphMethod.weighted means method 1 and MorphMethod.unweighted means method 2. The default setting is MorphMethod.auto, which let the program choose between the two methods automatically based on the displacement of the center of mass using the two methods.
Now let's see some shape morphing in action!
You can see for all the shapes involved, at least one of the method (weighted or unweighted) gives a very natural looking morphing process. The red dots indicate the location for the control points involved. The app is running on Chrome and all the morphing processes are pretty smooth.
I’ve seen many Flutter animations that involving changing the shape of a Button. However, they are limited to changing the border-radius of a rounded rectangle or morphing it into a circle because that's what Flutter supports out of the box. The morphable_shape package can reproduce those built-in shape animations exactly and lets you animate even more shapes. To learn more about what shapes are supported, see my previous medium article “Create responsive shapes in Flutter”. To play with the morphing, check out the online shape editing tool I just published at fluttershape.com. You can also check out the GitHub repo of the package and build the example app to see shape morphing in action.
That should be all for the moment. After a month of development and three medium articles, I think the morphable_shape package has stabilized and ready to be used. The end result is promising as it is a perfect extension to the Shapes that Flutter currently supports. If you have any suggestions, kindly issue a ticket on the package’s Github repository. Thank you for your time!