Fish-Lottie: How to Implement a High-performance Animation Framework with Dart?
By Cenyu at Xianyu Technology
Lottie is an open-source animation solution developed by Airbnb, which is compatible with multiple terminals, including Android, iOS, and the web. By using JSON, Lottie reduces the development cost of complex animations for developers.
From its early days, the Xianyu team used the Flutter solution on the client, and many interfaces in Xianyu’s current projects are still implemented by Flutter. However, there were no official Lottie-Flutter solutions. Instead, several third-party developers provided similar solutions, which basically work in either one of the following two ways:
(1) Parse and render data at the native end. Then, transmit the rendered data to the Flutter end by bridging for final display.
(2) Directly parse data in Flutter and use Flutter’s drawing capability to render and display the resulting data.
Nonetheless, both of the preceding solutions have their shortcomings. Specifically, the first method is defective in performance and display; for example, it may display a flashing white screen. The second method has functional defects in supporting certain features; for example, it does not support text animation. Therefore, these problems have always been a pain point for the Xianyu team and even for the entire Flutter developer community.
Project Architecture
After the Xianyu team researched the official open-source Lottie-Android library, they found that Flutter had provided implementation solutions that outperform the solutions provided by Android in both data parsing and graphic drawing. Therefore, the Xianyu team, in light of the Lottie-Android library, has implemented a pure Dart package to provide Lottie animation support on Flutter. This package has elaborate functions and high performance.
Fish-Lottie project architecture
As shown in the preceding figure, a project consists of basic modules, an interface layer, and a widget layer. The project has capabilities similar to Lottie-Android, such as vector graphic drawing, filling, and stroking. For more information about the capabilities, see Lottie Capabilities.
Basic Modules
The basic modules directly interact with various capabilities provided by the Flutter SDK. These basic modules are subdivided into the data model module, animation rendering module, data parsing module, and tool module. The workflow to process a JSON file, which contains the entire animation information, is as follows: The data parsing module first parses the data and information contained in the JSON file and transmits them to the data model module. Then, data models from the data model module are transmitted to the animation rendering module to draw graphics by using the drawing capabilities provided by Flutter. Lastly, the tool module is responsible for screen information acquisition, string processing, log printing, and other tool capabilities.
The Interface Layer
The interface layer is mainly responsible for obtaining JSON data and controlling animation rendering. After JSON information is parsed, the data parsing module will eventually generate a LottieComposition object, which carries the complete JSON animation information. Then, the object is passed to LottieDrawable, which passes on the object to the animation rendering module, so that the animation rendering module can obtain the animation information. At last, LottieDrawable calls the animation rendering module to draw and refresh the animation.
The Widget Layer
The widget layer has custom components that are implemented by inheriting Flutter Widget, which is also the interface exposed by the framework to developers. To make a simple Lottie animation player, developers only need to create a LottieAnimationView object and pass the JSON file path to it, and then add the LottieAnimationView object to FlutterUI as a widget. The JSON file path supports three formats, that is, the asset, URL, and file. In addition, the animation control interface and the widget layout interface are available. Developers can pass properties, such as width, height, and alignment, to the AnimationController object when creating the LottieAnimationView object to further customize the animation.
Workflow: from JSON to Animation
Overall Design
In Adobe After Effects (AE), an animation is actually composed of different layers. AE provides multiple layers for the designer to choose from, such as solid color layers (usually for the background), shape layers (for various vector graphics), text layers, and image layers. Each of these layers can be translated, rotated, and scaled. Each layer contains multiple elements. For example, a shape layer may have multiple basic vector graphics and pen path graphics to form a well-designed pattern. Every element may also have its own transformation in addition to the basic transformation, such as color and shape transformations. All the preceding layers and elements compose a complete animation.
As shown in the preceding figure, we create a new solid color layer in AE and fill it with blue. Then, create a new shape layer and add a displacement animation for this layer. That is, two key frames are set with initial and final values at the starting and end positions of the shape layer transformation. Then, add a rectangle to the shape layer and fill it with yellow. Next, we animate the size and roundness of the rectangle in the same way, but the size animation acts from the zeroth second to the third second, while the roundness animation acts from the third to fifth. In this way, we can make an animation, in which the rectangle translates from left to right with self-distension and deformation to a circle.
Then, we export the animation to a JSON file by using the BodyMovin plug-in provided by Lottie. This JSON file contains all the drawing and key frame information of the animation.
As shown in the preceding figure, after we obtain the JSON file, we first use the data parsing module to parse and transmit the layer and animation information produced by the designer in AE to a LottieComposition object. Then, LottieDrawable obtains this LottieComposition object and calls the underlying canvas to draw the graph. At the same time, an AnimationBuilder object is used to control the progress. When the progress changes, Drawable is notified to redraw the graph. Then, the rendering module obtains all property values at the time of the progress so that it can finish playing the animation.
Data Loading and Display
The widget layer provides three methods to obtain JSON files, that is, asset (built-in resources of the program), URL (network resources), and file (file resources). The flowchart for data loading and display is as follows, with the underlying rendering details omitted:
The following takes loading a JSON file by using the fromAsset method as an example. The other two loading methods work in the same way, which is, using LottieCompositionFactory to call one of these loading methods. The loading method is subdivided into three types based on different constructors, that is, asset, file, and URL. According to types, different loading methods in LottieCompositionFactory are called to load corresponding built-in resources, network resources, and file resources for JSON file parsing. This process produces a LottieComposition object, which is asynchronously loaded and parsed. After the parsing is completed, LottieAnimationView is notified to call the parsed object.
Then, we pass the loaded LottieComposition object to our rendering class, and LottieDrawable creates a layer group based on the content of the composition. The Layer group contains multiple layers, such as shape layers and text layers, that are in one-to-one correspondence with the layers created by the designer in AE. Here, note that each layer has different drawing rules and methods. Then, LottieAnimationView obtains the system canvas, passes it to LottieDrawable, and calls the draw method. In this way, we can use the system canvas to draw the animation.
Animation Rendering and Playback
After we load and display the animation, we also need to make it live. To do this, set the value of AnimationController to the LottieDrawable’s progress by using AnimationBuilder, and then trigger the redrawing operation so that the underlying layer can obtain the animation properties through the progress. In this way, the animation will be live. The sequence diagram is as follows:
In LottieAnimationView, we use the built-in AnimationController of Flutter to control the animation. The forward method can increase the animation progress from zero, which is also the starting progress of the animation. By keep calling the setProgress function to pass the current animation progress to each layer, the progress change will finally reach the KeyframeAnimation layer to update the current progress. After the progress changes, the upper layer is notified to redraw the interface and finally set the isDirty variable to true in LottieDrawable. In the setProgress function, after we finish setting the progress, we check the isDirty variable of LottieDrawable. If this variable is true, it proves that the progress has been updated. Then, we call the overridden markNeedPaint() method. As a result, the system marks the current component as to be updated, and Flutter calls the overridden paint function to redraw the entire picture. As the flowchart shows, the drawing progresses layer by layer. At the bottom layer, we obtain the corresponding property values in KeyframeAnimation according to the current progress, and then the drawn picture will change. By continually updating the progress, re-acquiring the properties for the current progress, and redrawing, the animation is played.
Implementation: Android and Flutter
The Widget Layer at the Android End
For Lottie-Android, LottieAnimationView and LottieDrawable compose the entire widget layer. LottieAnimationView inherits from ImageView, and LottieDrawable inherits from Drawable. The entire workflow is basically the same as the preceding procedure, and the developers writes LottieAnimationView in an XML file and sets the JSON file resource path. LottieAnimationView then initiates data acquisition and parsing. After the parsing is completed, the LottieComposition object is passed to LottieDrawable, and then the overridden draw method is called for animation display.
The playback, pause, and progress of the entire animation are controlled by developers by obtaining the reference to LottieAnimationView in the code and calling various methods of LottieAnimationView. However, in fact, the animation is controlled by LottieValueAnimator in LottieDrawable. A LottieValueAnimator object is also created when LottieDrawable is initialized. The LottieValueAnimator object inherits from ValueAnimator and generates an interpolation number ranging from 0 to 1. The current animation progress can be set according to the interpolation value. The pause, playback, and other animation control methods in LottieAnimationView are actually done by calling the corresponding methods of ValueAnimator.
The Widget Layer at the Flutter End
For Flutter, no widget like ImageView and Drawable is available to inherit from and override. Hence, we need to customize a widget, which can be done in any of the following three ways:
(1) Combine native widgets
This method is inapplicable to us as we need to leverage the canvas provided by the system for rendering.
(2) Implement CustomPainter
Flutter provides a self-drawn UI interface, that is, CustomPainter, which provides a 2D canvas. The canvas encapsulates some basic rendering APIs, so that developers can draw various custom graphics by using the canvas.
We can obtain the system canvas in the overridden paint method, and then pass this canvas to LottieDrawable to draw the animation. Then, when the properties are modified and the picture needs to be refreshed, the value of shouldRepaint returns true.
However, there are some persistent problems in this solution. As we all know, LottieAnimationView is embedded into FlutterUI as a widget, and we often need to customize the size of the animation playback area, that is, the LottieAnimationView size. However, when developers do not set the width and height of the area or the set size is greater than the size of the parent layout, we need to adapt the child layout according to the constraints of the parent layout. As no such interface is exposed in CustomPainter provided by Flutter, we cannot obtain the constraint properties of RenderObject that corresponds to this widget. As a result, developers cannot adapt the dimension according to the parent layout constraints when the width and height of LottieAnimationView are not specified. For this reason, we have to give up this solution.
(3) Custom RenderObject
Widgets are lightweight style configuration information in Flutter. The actual class for graphic rendering is RenderObject. Therefore, we can override the paint method in the RenderObject class to obtain the system canvas for rendering. This solution is more complex than the previous one for the following reasons. We need to define a RenderLottie class that inherits from RenderBox, and then override the paint method to pass the system canvas to LottieDrawable. Then, the interface can be redrawn by calling the markNeedPaint method where refresh is required. In addition, for RenderObject, we can obtain the constraint properties of the current component. That means, when developers did not set the LottieAnimationView size or when the size exceeds the parent layout, we can also adapt to the size of the parent layout.
Next, we need to define a component called LeafRenderLottie, which inherits from LeafRenderObjectWidget. Then, override the createRenderObject method and return the RenderLottie object. At last, override the updateRenderObject method to update various properties of RenderLottie, including the progress. So far, we have implemented a LottieWidget.
Afterwards, how can we control the playback of the animation? As LottieAnimationView is embedded into FlutterUI as a widget, we generally do not obtain its reference to call a method. Instead, we can pass in an AnimationController object provided by Flutter, return an AnimationBuilder object in the build method of LottieAnimationView, and pass the AnimationController progress value to LeafRenderLottie. If developers have not passed in AnimationController, we can provide a default controller for simple animation playback. The key code for the preceding purpose is as follows:
@override
void paint(PaintingContext context, Offset offset) {
if (_drawable == null) return;
_drawable.draw(context.canvas, offset & size,
fit: _fit, alignment: _alignment);
}//The paint method of RenderLottie
Text Rendering at the Android End
Canvas in the Android SDK provides the drawText method so that we can use a canvas to draw text directly. The Android implementation is as follows:
private void drawCharacter(String character, Paint paint, Canvas canvas) {
if (paint.getColor() == Color.TRANSPARENT) {
return;
}
if (paint.getStyle() == Paint.Style.STROKE && paint.getStrokeWidth() == 0) {
return;
}
canvas.drawText(character, 0, character.length(), 0, 0, paint);
}
Text Rendering at the Flutter End
Compared with the Android canvas, Flutter Canvas does not provide methods to draw text. After research, we found that Flutter had provided a special TextPainter for text rendering. The Flutter implementation is as follows:
void _drawCharacter(
String character, TextStyle textStyle, Paint paint, Canvas canvas) {
if (paint.color.alpha == 0) {
return;
}
if (paint.style == PaintingStyle.stroke && paint.strokeWidth == 0) {
return;
} if (paint.style == PaintingStyle.fill) {
textStyle = textStyle.copyWith(foreground: paint);
} else if (paint.style == PaintingStyle.stroke) {
textStyle = textStyle.copyWith(background: paint);
}
var painter = TextPainter(
text: TextSpan(text: character, style: textStyle),
textDirection: _textDirection,
);
painter.layout();
painter.paint(canvas, Offset(0, -textStyle.fontSize));
}
The Bezier Curve
Animation at the Android End
In the background section, we mentioned that the Bezier curve is one of the three elements that make up the animation. As the animation is usually not played linearly, if we need to achieve the effect of moving fast first and then slowing down, we need to use a Bezier curve to map the progress to a property value in order to obtain the property value through the progress. The Android SDK provides PathInterpolator to obtain the property value. To do this, we can use two control points in the JSON file to describe the Bezier curve, pass the coordinates of these two control points to PathInterpolator, and then call the getInterpolation method of the interpolator to obtain the mapped property value. The implementation is as follows:
interpolator = PathInterpolatorCompat.create(cp1.x, cp1.y, cp2.x, cp2.y);public static Interpolator create(float controlX1, float controlY1,
float controlX2, float controlY2) {
if (Build.VERSION.SDK_INT >= 21) {
return new PathInterpolator(controlX1, controlY1, controlX2, controlY2);
}
return new PathInterpolatorApi14(controlX1, controlY1, controlX2, controlY2);
}public PathInterpolator(float controlX1, float controlY1, float controlX2, float
controlY2) {
initCubic(controlX1, controlY1, controlX2, controlY2);
}private void initCubic(float x1, float y1, float x2, float y2) {
Path path = new Path();
path.moveTo(0, 0);
path.cubicTo(x1, y1, x2, y2, 1f, 1f);
initPath(path);
}//The key method for generating Bezier curves built in Android
Animation at the Flutter End
As no ready-made path interpolator is provided in Flutter, we have to implement it ourselves according to the source code. After checking the related source code, we found that we only need to pass the coordinates of the two control points in the JSON file to the cubicTo method in the Flutter path to generate the Bezier curve. Then, to obtain the property value, we can implement a method whose input parameter is the t time and return value is the p progress after mapping. The implementation details are similar as the getInterpolation method in PathInterpolator. The implementation is as follows:
interpolator = PathInterpolator.cubic(cp1.dx, cp1.dy, cp2.dx, cp2.dy);factory PathInterpolator.cubic(
double controlX1, double controlY1, double controlX2, double controlY2) {
return PathInterpolator(
_initCubic(controlX1, controlY1, controlX2, controlY2));
}static Path _initCubic(
double controlX1, double controlY1, double controlX2, double controlY2) {
final path = Path();
path.moveTo(0.0, 0.0);
path.cubicTo(controlX1, controlY1, controlX2, controlY2, 1.0, 1.0);
return path;
}//The key method for customizing Flutter Bezier curves
Effects: Android and Flutter
We have implemented a closed-loop demo project with Fish-Lottie. We also used the Lottie JSON file from the Lottie-Android project for testing and found that the release package is comparable to the official sample app in both the fluency and accuracy of animation rendition. Next, I will compare them with a few examples:
The first animation is played on a Flutter page by using Fish-Lottie, and the second animation is played on a native page by using Lottie-Android. You can easily find that Fish-Lottie is comparable to Lottie-Android in both rendering and playback performance.
The animation on the left is a dynamic text animation implemented by using Fish-Lottie, and on the right is a text animation implemented by using Lottie-Android. Similarly, you can easily observe that Fish-Lottie is as powerful as Lottie-Android in terms of dynamic property execution and real-time text rendering. In addition, as our text rendering is different from the native implementation, we can better expose the font style interface so that developers not only can customize the text, but also can dynamically customize the style in real time. This feature is currently not available in Lottie-Android.
Prospects: from Static to Interactive
Currently, Lottie is only used for the static playback of animations. For example, the thumb animation that appears after you click Like, and the heart-shaped animation that appears after you click Favorites. At best, you can control the playback of the entire animation by using the progress bar. However, in the process of implementing the entire framework, I realized that Lottie-Android already has some interactive capabilities. The implementation is as follows:
val shirt = KeyPath("Shirt", "Group 5", "Fill 1")
animationView.addValueCallback(shirt, LottieProperty.COLOR) { Colors.XXX } // The color to customize
The following figure shows the interactive capabilities of the preceding code:
The Lottie-Android Solution
From the preceding code we discovered that, to implement dynamic property control, we need to pass three parameters. The first parameter is similar to a locator, which locates vector graphics whose properties we want to control. The second parameter is a property enumeration variable, which indicates the property type. The last parameter is a callback function, which returns the dynamically changed target value.
As the widget layer is quite different from that of Lottie-Android, Fish-Lottie currently only supports the animation playback capability, and the interactive capability is under development.
The Fish-Lottie Solution
Generally, we do not obtain a widget reference in Flutter to call its methods due to the differences in the implementations of widgets and UI building features between both ends. Therefore, we cannot directly use lottieAnimationView.addValueCallback() to control dynamic properties, like in Lottie-Android. In fact, we have encountered the same problem when controlling the progress of an animation. So, our idea is actually the same as implementing AnimationController. That is, create a PropertiesController object and pass the target graphics, target properties, and callback functions that we want to modify to this controller. Then, pass this controller to LottieDrawable as a parameter of the LottieAnimationView constructor. At last, this property controller initiates the matching for the target graphic rendering class and the setting for callback functions. The underlying methods in the drawing class and frame animation class are consistent with those in Lottie-Android. The basic idea is the same as that of Lottie-Android, except that LottieAnimationView is no longer responsible for property control, but PropertiesController is.
Implementation
With the interaction capability, we can achieve more than controlling the playback of animations. For example, we can make some complex interactions by providing feedback to users’ click events.
As shown in the preceding figure, it is difficult for developers to implement the background animation of the search box. But with Lottie, we can make a flowing jelly background animation and two content animations, which are a night animation with stars and the moon and a daylight animation with clouds. By using click events, we can control the jelly background animation to switch between black and a blue-purple gradient, or change its shape locally. Also, we can control the display or hiding of the two content animations. For example, when you click the Pillow button, the jelly background color switches to a blue-purple gradient, and then the cloud animation appears. If you click the Baby button, the background color switches to black, and then the star-moon animation appears. To give the clouds a 3D effect, we can obtain the offset angle of the mobile phone through the gyroscope sensor, and then change the position of each element in the cloud animation according to the angle. In this way, complex interactive animations, which were previously too expensive or infeasible, can now be easily achieved by using Lottie.