You can see double – or double-tap hearts rolling all over the screen on all kinds of short videos these days. There is no picture, there is no truth. Let’s look at the effect first.

As you click on it, the heart will keep coming up. Follow the curve. From the figure we can see that each click produces a red heart, and it is produced at the clicked position. Then the red heart moves along the curve, rotating and changing the transparency during the movement. So in order to achieve this effect we need to solve the problem of determining the starting position of the heart, the curve of the heart’s movement, rotating on the curve, changing the transparency of the heart.

basis

Before we get started we need some basics, including how to use click events and get click positions, how to define curves, and how to use pan, rotate, and transparency animations.

Base click location

To use click events in FLUTTER we usually use GestureDetector, whose constructor is as follows

And you can see that there’s already encapsulation of things like click, double click, long press, down, lift, and so on. For simplicity in this article we use the onTapDown event directly, which is defined asfinal GestureTapDownCallback? onTapDownThe callback is actually a function

typedef GestureTapDownCallback = void Function(TapDownDetails details)
Copy the code

Its Details argument is just a class,

class TapDownDetails { /// Creates details for a [GestureTapDownCallback]. /// /// The [globalPosition] argument must not be null. TapDownDetails({ this.globalPosition = Offset.zero, Offset? localPosition, this.kind, }) : assert(globalPosition ! = null), localPosition = localPosition ?? globalPosition; /// The global position at which the pointer contacted the screen. final Offset globalPosition; /// The kind of the device that initiated the event. final PointerDeviceKind? kind; /// The local position at which the pointer contacted the screen. final Offset localPosition; }Copy the code

Three parameters are encapsulated here: globaPosition is the coordinate relative to the screen, localPosition is the coordinate relative to the widget, and KIND is the type of device to click on. So we’ve solved the first problem, where the globalPosition is where we want to click.

Base curve

The curve in a flutter can be defined by path, the basics of which were introduced in the previous “Flutter Custom View”. I’ll cut to the chase here. The motion path of the heart shape is a curve, which is generally obtained by the second or third order Bessel curve. The method in path is path.cubicto (ctrL1.dx, ctrL1.dy, ctrl2.dx, ctrl2.dy, end.dx, end.dy);

/// Adds a cubic bezier segment that curves from the current point
  /// to the given point (x3,y3), using the control points (x1,y1) and
  /// (x2,y2).
  void cubicTo(double x1, double y1, double x2, double y2, double x3, double y3) native 'Path_cubicTo';
Copy the code

As you can see, three points need to be passed in here, (x1,y1) and (x2,y2) as the control points (the red and green points in the figure below), and (x3,y3) as the end point (the hollow point in the figure below). The following two figures show that different curves can be obtained by changing the control points (the black curve is the motion track), so we have also solved the generation of curves.

Transformation of foundations

In native development we often use transformations such as pan, rotate, scale, transparency, etc. Flutter also provides a method for us. Here we use Transform. If we just want to translate, we use transform.translate. His definition is as follows

Transform.translate({ Key? key, required Offset offset, this.transformHitTests = true, Widget? child, }) : Transform = Matrix4. TranslationValues (offset. Dx, offset.. dy, 0.0), origin = null, alignment = null, super (key: key, child: child);Copy the code

Is actually the Transform properties of tectonic Transform when it happens through Matrix4. TranslationValues (offset. Dx, offset.. dy, 0.0) for the assignment. Of course, if we use this, we need to use it with AnimatedBuilder. The principle will not be introduced in this article. For example, if I want to move to (300,0) on the screen, the code is as follows

class TransformDemoWidget extends StatefulWidget { @override State<StatefulWidget> createState() { return TransformDemoWidgetState(); } } class TransformDemoWidgetState extends State<TransformDemoWidget> with TickerProviderStateMixin { AnimationController animationController; @override void initState() { animationController = new AnimationController(vsync: this, duration: Duration(seconds: 1)).. repeat(reverse: true); animationController.addStatusListener((status) { print("animaState==>>$status"); }); super.initState(); } @override void dispose() { print("LikeIconState==>>dispose"); animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("TransformDemo"),), body: Container( child: AnimatedBuilder( animation: animationController, builder: buildContent, ), ), ); } Widget buildContent(BuildContext context, Widget child) {return Transform. Translate (offset: offset (animationController. Value * 300, 0), the child: Image.asset( "images/red_heart.webp", width: 50, height: 50, ), ); }}Copy the code

Results the following

Use it directly if you want to implement rotationTransform.rotate, which is just another wrapper around the Transform constructor

Transform.rotate({
    Key? key,
    required double angle,
    this.origin,
    this.alignment = Alignment.center,
    this.transformHitTests = true,
    Widget? child,
  }) : transform = Matrix4.rotationZ(angle),
       super(key: key, child: child);
Copy the code

The transform property is initialized via matrix4.roationz (Angle). If I replace the buildContent section above with something like this

  Widget buildContent(BuildContext context, Widget child) {
    return Transform.rotate(
      angle:2*pi*animationController.value,
      child: Image.asset(
        "images/red_heart.webp",
        width: 50,
        height: 50,
      ),
    );
  }
Copy the code

The effect of

From the above we can see that as long as the corresponding transform is configured, it can achieve the corresponding effect. If we want it to rotate as it pans, we can do that easily with the above analysis.

Widget buildContent(BuildContext context, Widget child) {return Transform (Transform: Matrix4 translationValues (animationController. Value * 300, 0, 0.0). The. rotateZ(pi*animationController.value/2), child: Image.asset( "images/red_heart.webp", width: 50, height: 50, ), ); }Copy the code

Effect of

As for the change of transparency, the child is wrapped with the Opacity component, and we replace buildContent with the following

  Widget buildContent(BuildContext context, Widget child) {
    return Opacity(opacity: 1-animationController.value,
        child: Image.asset(
          "images/red_heart.webp",
          width: 50,
          height: 50,
        ),
    );
  }
Copy the code

Results the following

Now that we’ve covered the basic elements we need to prepare, it’s time to assemble them.

implementation

With the components we need, we can really start to implement the likes described above. You can see that each click produces a heart, and he is responsible for his own animation, and each heart clicked multiple times does not affect each other. So we can encapsulate this display and transform into a widget, which is responsible for animation.

Implementation path

We want is to click on the location of the start, the Bessel curve movement of the heart, so the starting point of the curve should be click position, through the analysis of the above formula can see, even if the starting point, the control points or the finish and control points are different, their curve is different also, in order to achieve the effect of this difference, we can be generated using a random number, For example, the implementation here is as follows

  void initPath() {
    path.moveTo(widget.offset.dx, widget.offset.dy);
    Offset end =
        Offset(Random().nextDouble() * 400, Random().nextDouble() * 50);
    Offset ctrl1 = formatMiddlePoint();
    Offset ctrl2 = formatMiddlePoint();

    path.cubicTo(ctrl1.dx, ctrl1.dy, ctrl2.dx, ctrl2.dy, end.dx, end.dy);

    PathMetrics pathMetrics = path.computeMetrics();
    pathMetric = pathMetrics.first;
    tangent = pathMetric
        .getTangentForOffset(pathMetric.length * animationController.value);
  }

  Offset formatMiddlePoint() {
    double x = (Random().nextDouble() * 600);
    double y = (Random().nextDouble() * 300 / 4);
    Offset pointF = Offset(x, y);
    return pointF;
  }
Copy the code

The widget.offset here is the click point passed into the widget responsible for the animation. Here we used PathMetric again. The reason for using path measurement is that when translating, we need to obtain the position and Angle of the position corresponding to a certain moment in the animation of the current path. For those unfamiliar, see the “Flutter Custom View”. After defining the path, all that remains is the implementation of the pan rotation transparency animation, which has been introduced above, and here directly on the code

Widget buildContent(BuildContext context, Widget child) { tangent = pathMetric.getTangentForOffset(pathMetric.length * animationController.value); double angle = tangent.angle; Return the Transform (Transform: Matrix4. TranslationValues (tangent. Position. Dx, tangent. Position. Dy, 0.0). The. rotateZ(angle), child: Opacity( child: Image.asset( "images/red_heart.webp", width: 50, height: 50, ), opacity: 1 - animationController.value, ), ); }Copy the code

The complete widget is shown below

class LikeIconWidget extends StatefulWidget { final Offset offset; const LikeIconWidget({Key key, this.offset}) : super(key: key); @override State<StatefulWidget> createState() { return LikeIconState(); } } class LikeIconState extends State<LikeIconWidget> with TickerProviderStateMixin { AnimationController animationController; Path path = Path(); Tangent tangent; PathMetric pathMetric; / / animation startAnimation () async {await animationController. Forward (); } @override void initState() { animationController = new AnimationController(vsync: this, duration: Duration(seconds: 1)); animationController.addStatusListener((status) { print("animaState==>>$status"); if (status == AnimationStatus.completed) {} }); initPath(); startAnimation(); super.initState(); // _loadImage(); } @override void dispose() { print("LikeIconState==>>dispose"); animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( child: AnimatedBuilder( animation: animationController, builder: buildContent, ), ); } Widget buildContent(BuildContext context, Widget child) { tangent = pathMetric.getTangentForOffset(pathMetric.length * animationController.value); double angle = tangent.angle; Return the Transform (Transform: Matrix4. TranslationValues (tangent. Position. Dx, tangent. Position. Dy, 0.0). The. rotateZ(angle), child: Opacity( child: Image.asset( "images/red_heart.webp", width: 50, height: 50, ), opacity: 1 - animationController.value, ), ); } void initPath() { path.moveTo(widget.offset.dx, widget.offset.dy); Offset end = Offset(Random().nextDouble() * 400, Random().nextDouble() * 50); Offset ctrl1 = formatMiddlePoint(); Offset ctrl2 = formatMiddlePoint(); path.cubicTo(ctrl1.dx, ctrl1.dy, ctrl2.dx, ctrl2.dy, end.dx, end.dy); PathMetrics pathMetrics = path.computeMetrics(); pathMetric = pathMetrics.first; tangent = pathMetric .getTangentForOffset(pathMetric.length * animationController.value); } Offset formatMiddlePoint() { double x = (Random().nextDouble() * 600); double y = (Random().nextDouble() * 300 / 4); Offset pointF = Offset(x, y); return pointF; }}Copy the code

Now that we have defined each heart in its entirety, the next step is to actually add hearts.

Implementation parent widget

In this case, we can use the Stack to implement it. In addition, we need a list to store our widgets, like the LikeIconWidget above.

class LikePage extends StatefulWidget { @required final Widget child; const LikePage({Key key, this.child}) : super(key: key); @override State<StatefulWidget> createState() { return _LikePageState(); } } class _LikePageState extends State<LikePage> { List<LikeIconWidget> items = []; @override void initState() { super.initState(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("likeAnim"), ), body: GestureDetector( child: Stack( children: [ widget.child, _getIconStack(), ], ), onTapDown: (details) { buildIcons(details); })); } buildIcons(TapDownDetails details) { print("itemLength==>>TapDownDetails ==>> ${items.length}"); setState(() { items.add(LikeIconWidget(offset: details.globalPosition)); }); } _getIconStack() { return Stack( children: items, ); }}Copy the code

In this case, @required is actually the background of the “like”. For example, in the video of Douyin, click events will be added as a whole. The above effect can be achieved by adding a widget without clicking on it. So far we are done, in fact, a lot of variables here can be extracted, I didn’t do it for simplicity, if you are interested, you can try. Your comments are welcome.