Flutter自定义widget 纯手撸一个循环滚动的组件(包含手势和动画)

效果图

iShot_2022-08-07_11.32.50.gif

这是看了RenderObject的源码,写的一个总结性的小widget。使用到了Flutter里的自定义RenderObjectWidget 手势 动画等知识点。

该widget实现的功能

  • 左右无限循环滚动
  • 模拟物理阻尼滑动
  • 滑动结束自动归位
缘起

之前项目中有用到首页Banner的组件(左右无限滑动,自动切换),那会一直奉行拿来主义有轮子能跑就行,不去重复造轮子。所以在网上找了受欢迎的几个组件,比较好奇都是怎么实现的无限切换。大致分为两类:

  1. 定义一个很大的ListView比如30w个,然后把当前的位置定位到中间第15w个下标,以这样的方式实现左右无限滑动,左右两边都可以滑15w次,从日常使用的角度来讲实现了无限滑动。
  2. 在实际的轮播数量之上额外再加上2个widget,第1项前面加入最后1项widget,最后1项的后面加入第1项widget。比如有6个轮播图,那实际顺序就是F A B C D E F A。默认显示下标1的widgetA,当往右边滑动时就会到下标0显示出F,在完成切换到下标0后重新设置PageView的位置为(n-2),也就是下标6对应的F实现无限滑动, 左滑同理

这两种方式都是使用框架提供的基础组建封装而成的,第一种方式从程序的角度来讲是伪无限,第二种方式是真无限但是在第一项和最后一项会停住没法连续滚动 像这个样子:

iShot_2022-08-07_12.59.53.gif

我希望的样子是这样:

iShot_2022-08-07_13.00.59.gif

无限滑动

前面有提到的两种方案比较容易实现,代码量很少使用现有的滑动widget(ListView和PageView)即可。这里开始正式讲不借助ListView、PageView来实现无限滑动的逻辑。

忘掉上面的两种方式,我们这里是通过RenderObject实现那么一切的widget都听我号令。只要想通什么是无限循环----即首位相连,第一个前面的永远是最后一个,最后一个的前面永远是第一个。

先看完整的布局过程,实际只会显示中间无阴影的部分。这里是为了便于理解,将布局都绘制出来,用阴影遮挡表示该区域不可见。

为了方便描述红色的1取名为firstChildfirstChild处于可见区域时布局应该是这个样子,才能保证右滑的时候左边能显示出正确的widget。

初始状态的样子 image.png firstChild右滑一格 image.png firstChild左滑一格 image.png firstChild左滑两格 image.png 为了方便计算没有采用两边widget平均摆放的布局方式。而是左边只放一个,右边 按顺序摆放。附上动态滑动的gif

右滑

iShot_2022-08-07_13.42.27.gif

左滑

iShot_2022-08-07_13.43.57.gif

看到这里其实无限滑动的原理已经非常明显了,根据左右滑动的方向,和距离来动态的布局就可以了 所谓知易行难,原理是看明白了,那么这个动态布局怎么去实现呢?

布局

关键点在于红色1的wiget下文用firstChild来代替。firstChild是第一个子widget,后续的其他子widget布局位置都依赖于firstChild。也就是说我们只需要管理好firstChild的位置就可以了。管理firstChild也就是管理好它在左滑和右滑时的位置变换

前置条件:子widget同宽,可见区域的大小等于子widget的大小

左右边界:左边界(-size.width) 右边界((n-1)*size.width)

firstChild左滑的位置变换

image.pngfirstChild超过黄线的位置-size.width时(上图),就把它放到紫6的后面(n-1)*size.width(下图) image.png

firstChild右滑的位置变换

image.png 当firstChild超过黄线位置(n-1)*size.width时(上图),又把它放到最左边的位置-size.width(下图) image.png

正常滑动

其他情况下就根据手势滑动的方向和距离线性加减就行了。只是到了这两个边界值时进行位置调换代码如下

//firstChild到达右边界
if (_offset.dx >= (count - 1) * size.width) {
  double d = _offset.dx - (count - 1) * size.width;
  _offset = Offset(-size.width + d, 0);
  data.offset = _offset;
} 
//first到达左边界
else if (_offset.dx < -size.width) {
  double d = _offset.dx + size.width;
  _offset = Offset((count - 1) * size.width + d, 0);
}
复制代码
其他子widget的位置处理

前面提过只需要处理好firstChild的位置就行了,为什么呢?因为我们的布局是一个线性排列的布局并且子widget宽高相等,只要知道起始位置当前是第几个就可以算出来准确的位置。比如第n个child在x轴上的偏移量就是double currentDx = n * wdith + firstChild.dx 乘以n个宽度+firstChild在x轴的偏移量。然后处理下超过右边界的情况即可:用左边界的位置+超出右边界的距离

//计算i的位置:乘以i个宽度+firstChild在x轴的偏移量
Offset _next = Offset(i * size.width + _offset.dx, 0);
//超过右边界,
if (_next.dx >= (count - 1) * size.width) {
  //计算溢出右边界的距离
  double overflowOffset = _next.dx - (count - 1) * size.width;
  //左边界的位置+超出的距离
  _next = Offset(overflowOffset - size.width, 0);
}
复制代码

到这里一个无限滑动的widget已经完成百分之99了,剩下的就是加上水平滑动手势。把滑动的数据距离传给firstChild即可

void _dragOnUpdate(DragUpdateDetails details) {
  _offset = Offset(details.delta.dx + _offset.dx, 0);
  markNeedsLayout();}
复制代码
阻尼滑动

想要在松开手指后,使widget继续保持滑动就需要在手势识别器的onEnd方法上做文章。在onEnd回调中会传一个手指离开时滑动的速度primaryVelocity有了初始速度我们就可以模拟出列表滑动的整个衰减过程。 根据物理公式v = v0+at。可以假定一个加速度a=300,先计算出动画执行的时间t。

t = (v-v0)/a 最后的速度肯定为0,所以直接用v0/a的绝对值就是t。也就是primaryVelocity/300。由于快速滑动时permiaryVelocity很大,简单点就是把t限制在1-3秒以内。

根据位移公式:s=v0t+½at²计算滑动的距离s

double a = 300;
double t = (math.max(math.min((v / a).abs(), 3), 1));
double s = 0;
//  位移公式:s=v0t+½at²
s = v.abs() * t + a * t * t / 2;
//s缩小十倍,恢复运动的方向
s = s / 10 * (v > 0 ? 1 : -1);
复制代码

s缩小十倍是因为计算出来的距离太大了,导致动画播放的时候特别快。 有了动画时间t和手指离开后需要滑动的距离就可以写动画了,在手指离开后播放动画。关键代码如下:

//用于估值当前动画值所滑动的距离
var tween = Tween<double>(begin: 0.0, end: s)
    .chain(CurveTween(curve: Curves.easeOutExpo));
animation?.dispose();
animation = null;
//上次滚动的距离
double lastS = 0;
animation ??= AnimationController(
    vsync: ticker, duration: Duration(seconds: t.abs().ceil()))
  ..addListener(() {
    double currentS = -tween.evaluate(animation!);
    if (currentS != 0) {
      //增量计算滑动的距离(_offset是firstChild的坐标)
      _offset = Offset(_offset.dx + (lastS - currentS), 0);
    }
    lastS = currentS;
    markNeedsLayout();
  });
animation!.forward(from: 0);
复制代码

到这里手指离开屏幕后,模拟阻尼滑动的动画也已经完成了,只是s的随机性不能保证widget和可视区域对齐。接下来就是最后一步,自动对齐

自动归位

前面提到在手指离开后的阻尼滑动结束时无法对齐,是因为s是根据primaryVelocity计算的,每一次速度不同就会导致停在不同的位置。那么想要他自动对齐也很简单,就是让firstChild_offset+s的值是可见区域width的倍数就行了。 代码如下:

//停止的位置
double endPos = _offset.dx + s;
//取余数
double remPos = endPos % size.width;
//补整
double complement =
    remPos < (size.width / 2) ? -remPos : size.width - remPos;
s = s + complement;
复制代码

4行代码搞定自动归位。

写在最后

这两天一直在构思这个循环滑动的widge怎么实现,在笔记本上整整图画了两页手稿,最终决定以这种方式实现,算是比较偏高级一点的自定义widget,包涵的内容也比较丰富(手势、动画、RenderObject),但是整个代码量才100多行,可读性还是很强的,后续链接贴在评论区供有需要的同学浏览。欢迎小伙伴在评论区留言讨论

猜你喜欢

转载自juejin.im/post/7129030461770170375