Flutter 抖音点赞效果实现

·  阅读 1984

如今的各种短视频上都能看到双击或者不断连击满屏的心随处滚动。无图无真相,我们先来看看实现的效果。

Screenrecorder-2021-06-08-15-40-55-715.gif 随着不断点击心形会不断产生,按照曲线运动。通过图我们可以看到每点击一次产生一个红心,并且在点击的位置产生,之后红心沿着曲线运动,在运动的过程中旋转,改变透明度。所以要实现如上效果我们需要解决的问题有红心开始的位置确定,红心运动的曲线,在曲线上旋转,改变红心的透明度。

基础

在正式开始之前我们需要一些基础知识,他们包括如何使用点击事件并获取点击位置,如何定义曲线,如何使用平移,旋转和透明度动画。

基础之点击位置

在flutter中要使用点击事件我们通常使用GestureDetector,它的构造函数如下

image.png 可以看到这里已经封装好了比如单击,双击,长按,按下,抬起等等事件的封装。在本文中为了简化操作我们直接使用onTapDown事件,而这个事件定义为final GestureTapDownCallback? onTapDown,这里的callback其实是一个函数

typedef GestureTapDownCallback = void Function(TapDownDetails details)
复制代码

它的details参数仅仅是一个类,

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;
}
复制代码

这里封装了三个参数,其中globaPosition为相对于屏幕的坐标,localPosition则是相对于widget的坐标,kind则点击的设备类型。所以我们已经解决了第一个问题,这里的globalPosition就是我们想要的点击位置。

基础之曲线

flutter中的曲线可以通过path来定义,关于path的基础知识我们在之前的《Flutter自定义view》中已经介绍了。这里就直奔主题了。心形的运动轨迹是一个曲线,这种平滑的曲线我们一般通过二阶或者三阶贝塞尔曲线来获得,path中的方法为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';
复制代码

可以看到这里需要传入三个点,(x1,y1)和(x2,y2)为控制点也就是下图的红绿两点,(x3,y3)为终点也就是下图最后一个空心点。下面两张图可以展示出改变控制点就可以得到不同的曲线(黑色曲线为运动轨迹),所以关于曲线的生成我们也解决了。

image.png

image.png

基础之变换

在原生开发中我们也经常会用到变换,比如平移,旋转,缩放,透明度等。flutter中同样给我们提供了方法,这里我们使用Transform,如果只是想实现平移直接使用Transform.translate即可。 他的定义如下

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);
复制代码

其实就是构造Transform时将它的transform属性通过Matrix4.translationValues(offset.dx, offset.dy, 0.0)进行赋值。当然如果使用这个的话我们需要配合AnimatedBuilder来使用,关于原理在本篇中暂不介绍。比如我要在屏幕中实现平移到(300,0)的位置,实现的代码如下

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),
      child: Image.asset(
        "images/red_heart.webp",
        width: 50,
        height: 50,
      ),
    );
  }

}
复制代码

效果如下

Screenrecorder-2021-06-08-17-02-05-471.gif 如果要实现旋转则直接使用Transform.rotate,他其实是对Transform构造函数的另一种封装

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);
复制代码

就是把transform属性通过Matrix4.roationZ(angle)进行初始化。如果我上面的buildContent部分换成如下内容

  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,
      ),
    );
  }
复制代码

则效果为

Screenrecorder-2021-06-08-17-13-28-906.gif 通过上面我们可以看到只要配置相应的transform,他就能实现相应的效果。如果我们想让他一边平移一边旋转,通过上面的分析我们很容易实现。

Widget buildContent(BuildContext context, Widget child) {
    return Transform(
      transform:Matrix4.translationValues(animationController.value*300, 0, 0.0)..rotateZ(pi*animationController.value/2),
      child: Image.asset(
        "images/red_heart.webp",
        width: 50,
        height: 50,
      ),
    );
  }
复制代码

效果为

Screenrecorder-2021-06-08-17-31-45-704.gif 至于透明度的变化只需将child利用Opacity组件包裹即可,我们将buildContent替换成如下

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

效果如下

Screenrecorder-2021-06-08-17-37-03-500.gif 至此我们需要准备的基本元素已经介绍完毕,接下来就是如何组装这些元素。

实现

有了需要的组件我们就可以真正开始实现上述点赞效果。可以看到每次点击都产生一个红心,而且他负责自己的动画,多次点击的每个红心相互之间互不影响。所以我们可以将这个显示及变换封装成一个widget,他负责动画。

实现之路径

我们想要的效果是在点击的位置开始,沿着贝塞尔曲线运动的心形,所以这个曲线的起点应该是点击的位置,通过上面分析公式可以看到,即使起点一样,控制点不同或者是终点和控制点都不同,他们的曲线也不同,为了达到这种不同的效果,我们可以利用随机数来生成,比如这里的实现如下

  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;
  }
复制代码

这里的widget.offset则是传递进这个负责动画的widget中的点击的点。这里我们又使用了PathMetric,之所以使用路径测量是因为平移时我们需要获取在当前路径中的动画某一时刻对应的位置以及该位置的角度,不熟悉的可以看《Flutter自定义view》。定义好了路径之后,剩下的就是平移旋转透明度动画的实现,上面都已经介绍完毕了,这里直接上代码

Widget buildContent(BuildContext context, Widget child) {
    tangent = pathMetric.getTangentForOffset(pathMetric.length * animationController.value);
    double angle = tangent.angle;
    return Transform(
      transform: Matrix4.translationValues(
          tangent.position.dx, tangent.position.dy, 0.0)
        ..rotateZ(angle),
      child: Opacity(
        child: Image.asset(
          "images/red_heart.webp",
          width: 50,
          height: 50,
        ),
        opacity: 1 - animationController.value,
      ),
    );
  }
复制代码

完整的widget如下

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;

  //开始动画
  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 Transform(
      transform: Matrix4.translationValues(
          tangent.position.dx, tangent.position.dy, 0.0)
        ..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;
  }
}

复制代码

至此我们已经定义了完整的每个心,接下来其实就是添加心。

实现之父widget

这个点赞其实是蒙在需要的widget之上的,这里我们采用Stack实现即可,而且在点击的过程中不断的想stack中添加widget,这里就需要一个list来存储我们的widget也就是上面的LikeIconWidget.

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,
    );
  }
}

复制代码

这里的@required其实就是点赞的背景,比如抖音的视频,将整体添加点击事件,没点击一次添加一个widget,就可以实现上述的效果。至此我们实现完毕,其实这里的好多都可以进行变量抽取,为了简化就没做,感兴趣的可以试试。欢迎大家批评指正。

分类:
前端