Flutter - 利用贝塞尔曲线实现添加购物车效果

6,136 阅读5分钟

🥦 前言 - 关于贝塞尔曲线

贝塞尔曲线,这个词大家都不陌生,特别是在前端里面,没用过相信也都听过。

我这里也继续啰嗦一下,贝塞尔曲线的知识:

贝塞尔曲线就是这样的一条曲线,它是依据四个位置任意的点坐标绘制出的一条光滑曲线。在历史上,研究贝塞尔曲线的人最初是按照已知曲线参数方程来确定四个点的思路设计出这种矢量曲线绘制法。贝塞尔曲线的有趣之处更在于它的“皮筋效应”,也就是说,随着点有规律地移动,曲线将产生皮筋伸引一样的变换,带来视觉上的冲击。1962年,法国数学家Pierre Bézier第一个研究了这种矢量绘制曲线的方法,并给出了详细的计算公式,因此按照这样的公式绘制出来的曲线就用他的姓氏来命名是为贝塞尔曲线。 - 百度百科

他的出现为计算机矢量图形学奠定了基础,那么我们能用它做什么?

有的人说了,网上一搜一大把,这是必须的,「波浪形状」、「抛物线效果」等等等等。

🍋 贝塞尔曲线的效果和计算公式

这里我就不讲各阶贝塞尔曲线的区别了,直接把效果和公式贴上来。

一阶贝塞尔曲线

二阶贝塞尔曲线

三阶贝塞尔曲线

剩下的还有高阶,就不多赘述了,可推断公式如下:

🥝 实现商品添加购物车效果

复习了一下贝塞尔曲线的原理之后,我们来看一下今天要实现的效果:

实现之前

在实现之前,我们还是先来理清一下思路,首先能肯定的是我们是要使用二阶贝塞尔曲线来实现「抛物线效果」。

二阶贝塞尔曲线所需要的参数:

  1. 起始点 p0
  2. 控制点 p1
  3. 终点 p2

怎么获取坐标点先不提,接着看图,还有一个很重要的地方,就是根据抛物线一起坠落的「小红点」。

「小红点」该如何显示出来?我们继续。

开始实现

下面开始实现上图效果,从哪入手?

1. 先来搞定起点和终点

页面很简单,代码如下:

Column(
  children: <Widget>[
    Expanded(
      child: ListView.builder(
        itemBuilder: (BuildContext context, int index) {
          return Row(
          	// 隐藏无用代码
          );
        },
        itemCount: 100,
      ),
    ),
    Container(
      height: 1,
      color: Colors.grey.withOpacity(0.5),
    ),
    Container(
      height: 60,
      color: Colors.white,
      child: Row(
        children: <Widget>[
          Padding(
            padding: EdgeInsets.only(left: 20),
            child: Icon(
              Icons.shop_two,
              key: _key,
            ),
          )
        ],
      ),
    )
  ],
)

一个Column,上面是 ListView,下面跟着一个 「购物车图标」。

起点是我们 ListView 里面每一个 item 的 + 号,终点就是左下角的「购物车图标」。

终点的坐标很好说,给定一个 GlobalKey,然后在 第一帧回调 中获取位置即可:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((c) {
    // 获取「购物车」的位置
    _endOffset = (_key.currentContext.findRenderObject() as RenderBox)
      .localToGlobal(Offset.zero);
  });
}

那起点的呢?

因为起点是在 ListView 中,还会滚动,这时候可能很多小朋友就会说:“每一个 icon 都给一个 GlobalKey 不就好了嘛!”

小朋友,你确定不是在作死吗?

GlobalKey 使用了一个静态常量 Map 来保存它对应的 Element。 你可以通过 GlobalKey 找到持有该GlobalKey的 Widget,State 和 Element。 注意:GlobalKey 是非常昂贵的,需要谨慎使用。

​ - Vadaski - Flutter | 深入浅出Key

如果这个时候有 10000 个商品列表,岂不是要爆炸?

我们回看刚才获取「购物车」位置的代码,其实也就是用 GlobalKey 来获取 context,用 context 来获取位置,那我们何不直接用一个带 context 的组件?

代码如下:

Builder(
  builder: (context) {
    return IconButton(
      icon: Icon(Icons.add_circle_outline),
      onPressed: () {
        // 通过 Builder 组件来获取 context
        RenderBox box = context.findRenderObject();
        var offset = box.localToGlobal(Offset.zero);
      },
    );
  },
)

直接使用 Builder 来获取该组件的位置即可。

这样我们起点和终点的坐标都拿到了,那控制点呢?

2. 设置二阶贝塞尔曲线的控制点

这就比较简单了,我们可以看一下这个图:

(手残画的,凑合看)

这个控制点我们可以自己随意发挥,看你的效果如何再决定,我这里是这样的:

var x1 = widget.startPosition.dx - 250;
var y1 = widget.startPosition.dy - 100;

二阶贝塞尔曲线所需的值都有了,下面就可以算出位置了。

3. 获取每一帧小红点的位置

还是先把这个图和公式拿过来,其中 P0(起点),P1(控制点),P2(终点)值我们都有了,那还有个 t,我们使用 Flutter 的 Tween 来获取就好了,最后套入公式:

@override
void initState() {
  super.initState();
  _controller =
    AnimationController(duration: Duration(milliseconds: 800), vsync: this);
  _animation = Tween(begin: 0.0, end: 1.0).animate(_controller);

  // 二阶贝塞尔曲线用值
  var x0 = widget.startPosition.dx;
  var y0 = widget.startPosition.dy;

  var x1 = widget.startPosition.dx - 250;
  var y1 = widget.startPosition.dy - 100;

  var x2 = widget.endPosition.dx;
  var y2 = widget.endPosition.dy;

  _animation.addListener(() {
    // t 动态变化的值
    var t = _animation.value;
    if (mounted)
      setState(() {
        left = pow(1 - t, 2) * x0 + 2 * t * (1 - t) * x1 + pow(t, 2) * x2;
        top = pow(1 - t, 2) * y0 + 2 * t * (1 - t) * y1 + pow(t, 2) * y2;
      });
  });

  // 初始化小圆点的位置
  left = widget.startPosition.dx;
  top = widget.startPosition.dy;

  // 显示小圆点的时候动画就开始
  _controller.forward();
}

这样在动画开始以后,就可以获取每一帧小红点的位置了。

4. 把小红点显示出来

如何把小红点给显示出来?点击的时候需要怎么操作呢。

首先我想到的竟然是 IndexStack 包裹住,点击的时候设置小红点的位置,然后把他显示出来。

后来突然想到了 Overlay 😂。

ListViewButton 的代码如下:

IconButton(
  icon: Icon(Icons.add_circle_outline),
  onPressed: () {
    // 点击的时候获取当前 widget 的位置,传入 overlayEntry
    var _overlayEntry = OverlayEntry(builder: (_) {
      RenderBox box = context.findRenderObject();
      var offset = box.localToGlobal(Offset.zero);
      return RedDotPage(
        startPosition: offset,
        endPosition: _endOffset,
      );
    });
    // 显示Overlay
    Overlay.of(context).insert(_overlayEntry);
    // 等待动画结束
    Future.delayed(Duration(milliseconds: 800), () {
      _overlayEntry.remove();
      _overlayEntry = null;
    });
  },
)

其中 RedDotPage 就是我们定义好的小红点页面,给他传入起始点,让 Overlay 显示出来,显示出来的同时就开始做贝塞尔曲线动画了,等到动画结束 remove 掉这个 OverlayEntry 就ok了。

🍍 总结

这就是用 Flutter 实现添加购物车的所有内容,还是有一些细节在里面的。

代码已经提交到了 Github - 添加购物车Demo。

如有缺陷,希望大家提出,共同学习!🤝