简介
本文介绍怎么在Flutter里使用ListView实现Android的跑马灯,然后再扩展一下,实现上下滚动。
该小控件已经成功上传到pub.dev,安装方式:
1dependencies: 2 switcher: ^1.0.0+1
效果图
先上效果图:
垂直模式

水平模式

上代码
主要有两种滚动模式,垂直模式和水平模式,所以我们定义两个构造方法。 参数分别有滚动速度(单位是pixels/second)、每次滚动的延迟、滚动的曲线变化和children为空的时候的占位控件。
1class Switcher { 2 const Switcher.vertical({ 3 Key key, 4 @required this.children, 5 this.scrollDelta = _kScrollDelta, 6 this.delayedDuration = _kDelayedDuration, 7 this.curve = Curves.linearToEaseOut, 8 this.placeholder, 9 }) : assert(scrollDelta != null && scrollDelta > 0 && scrollDelta <= _kMaxScrollDelta), 10 assert(delayDuration != null), 11 assert(curve != null), 12 spacing = 0, 13 _scrollDirection = Axis.vertical, 14 super(key: key); 15 16 const Switcher.horizontal({ 17 Key key, 18 @required this.children, 19 this.scrollDelta = _kScrollDelta, 20 this.delayedDuration = _kDelayedDuration, 21 this.curve = Curves.linear, 22 this.placeholder, 23 this.spacing = 10, 24 }) : assert(scrollDelta != null && scrollDelta > 0 && scrollDelta <= _kMaxScrollDelta), 25 assert(delayDuration != null), 26 assert(curve != null), 27 assert(spacing != null && spacing >= 0 && spacing < double.infinity), 28 _scrollDirection = Axis.horizontal, 29 super(key: key); 30}
实现思路
实现思路有两种:
-
第一种是用
ListView; -
第二种是用
CustomPaint自己画;
这里我们选择用ListView方式实现,方便后期扩展可手动滚动,如果用CustomPaint,实现起来就比较麻烦。
接下来我们分析一下究竟该怎么实现:
垂直模式
首先分析一下垂直模式,如果想实现循环滚动,那么children的数量就应该比原来的多一个,当滚动到最后一个的时候,立马跳到第一个,这里的最后一个实际就是原来的第一个,所以用户不会有任何察觉,这种实现方式在前端开发中应用很多,比如实现PageView的循环滑动,所以这里我们先定义childCount:
1_initalizationElements() { 2 _childCount = 0; 3 if (widget.children != null) { 4 _childCount = widget.children.length; 5 } 6 if (_childCount > 0 && widget._scrollDirection == Axis.vertical) { 7 _childCount++; 8 } 9}
当children改变的时候,我们重新计算一次childCount,
1@override 2void didUpdateWidget(Switcher oldWidget) { 3 var childrenChanged = (widget.children?.length ?? 0) != (oldWidget.children?.length ?? 0); 4 if (widget._scrollDirection != oldWidget._scrollDirection || childrenChanged) { 5 _initalizationElements(); 6 _initializationScroll(); 7 } 8 super.didUpdateWidget(oldWidget); 9}
这里判断如果是垂直模式,我们就childCount++,接下来,实现一下build方法:
1@override 2Widget build(BuildContext context) { 3 if (_childCount == 0) { 4 return widget.placeholder ?? SizedBox.shrink(); 5 } 6 return LayoutBuilder( 7 builder: (context, constraints) { 8 return ConstrainedBox( 9 constraints: constraints, 10 child: ListView.separated( 11 itemCount: _childCount, 12 physics: NeverScrollableScrollPhysics(), 13 controller: _controller, 14 scrollDirection: widget._scrollDirection, 15 padding: EdgeInsets.zero, 16 itemBuilder: (context, index) { 17 final child = widget.children[index % widget.children.length]; 18 return Container( 19 alignment: Alignment.centerLeft, 20 height: constraints.constrainHeight(), 21 child: child, 22 ); 23 }, 24 separatorBuilder: (context, index) { 25 return SizedBox( 26 width: widget.spacing, 27 ); 28 }, 29 ), 30 ); 31 }, 32 ); 33}
接下来实现垂直滚动的主要逻辑:
1_animateVertical(double extent) { 2 if (!_controller.hasClients || widget._scrollDirection != Axis.vertical) { 3 return; 4 } 5 if (_selectedIndex == _childCount - 1) { 6 _selectedIndex = 0; 7 _controller.jumpTo(0); 8 } 9 _timer?.cancel(); 10 _timer = Timer(widget.delayedDuration, () { 11 _selectedIndex++; 12 var duration = _computeScrollDuration(extent); 13 _controller.animateTo(extent * _selectedIndex, duration: duration, curve: widget.curve).whenComplete(() { 14 _animateVertical(extent); 15 }); 16 }); 17}
解释一下这段逻辑,先判断ScrollController有没有加载完成,然后当前的滚动方向是不是垂直的,不是就直接返回,然后当前的index是最后一个的时候,立马跳到第一个,index初始化为0,接下来,取消前一个定时器,开一个新的定时器,定时器的时间为我们传进来的间隔时间,然后每间隔widget.delayedDuration的时间滚动一次,这里调用ScrollController.animateTo,滚动距离为每个item的高度乘以当前的索引,滚动时间根据滚动速度算出来:
1Duration _computeScrollDuration(double extent) { 2 return Duration(milliseconds: (extent * Duration.millisecondsPerSecond / widget.scrollDelta).floor()); 3}
这里是我们小学就学过的,距离 = 速度 x 时间,所以根据距离和速度我们就可以得出需要的时间,这里乘以Duration.millisecondsPerSecond的原因是转换成毫秒,因为我们的速度是pixels/second。
当完成当前滚动的时候,进行下一次,这里递归调用_animateVertical,这样我们就实现了垂直的循环滚动。
水平模式
接下去实现水平模式,和垂直模式类似:
1_animateHorizonal(double extent, bool needsMoveToTop) { 2 if (!_controller.hasClients || widget._scrollDirection != Axis.horizontal) { 3 return; 4 } 5 _timer?.cancel(); 6 _timer = Timer(widget.delayedDuration, () { 7 if (needsMoveToTop) { 8 _controller.jumpTo(0); 9 _animateHorizonal(extent, false); 10 } else { 11 var duration = _computeScrollDuration(extent); 12 _controller.animateTo(extent, duration: duration, curve: widget.curve).whenComplete(() { 13 _animateHorizonal(extent, true); 14 }); 15 } 16 }); 17}
这里解释一下needsMoveToTop,因为水平模式下,首尾都要停顿,所以我们加个参数判断下,如果是当前执行的滚动到头部的话,needsMoveToTop传false,如果是已经滚动到了尾部,needsMoveToTop传true,表示我们的下一次的行为是滚动到头部,而不是开始滚动到整个列表。
接下来我们看看在哪里开始滚动。
首先在页面加载的时候我们开始滚动,然后还有当方向和childCount改变的时候,重新开始滚动,所以:
1@override 2void initState() { 3 super.initState(); 4 _initalizationElements(); 5 _initializationScroll(); 6} 7 8@override 9void didUpdateWidget(Switcher oldWidget) { 10 var childrenChanged = (widget.children?.length ?? 0) != (oldWidget.children?.length ?? 0); 11 if (widget._scrollDirection != oldWidget._scrollDirection || childrenChanged) { 12 _initalizationElements(); 13 _initializationScroll(); 14 } 15 super.didUpdateWidget(oldWidget); 16}
然后是_initializationScroll方法:
1_initializationScroll() { 2 SchedulerBinding.instance.addPostFrameCallback((timeStamp) { 3 if (!mounted) { 4 return; 5 } 6 var renderBox = context?.findRenderObject() as RenderBox; 7 if (!_controller.hasClients || _childCount == 0 || renderBox == null || !renderBox.hasSize) { 8 return; 9 } 10 var position = _controller.position; 11 _timer?.cancel(); 12 _timer = null; 13 position.moveTo(0); 14 _selectedIndex = 0; 15 if (widget._scrollDirection == Axis.vertical) { 16 _animateVertical(renderBox.size.height); 17 } else { 18 var maxScrollExtent = position.maxScrollExtent; 19 _animateHorizonal(maxScrollExtent, false); 20 } 21 }); 22}
这里在页面绘制完成的时候,我们判断,如果ScrollController没有加载,childCount == 0或者大小没有计算完成的时候直接返回,然后获取position,取消上一个计时器,然后把列表滚到头部,index初始化为0,判断是垂直模式,开始垂直滚动,如果是水平模式开始水平滚动。
这里注意,垂直滚动的时候,每次的滚动距离是每个item的高度,而水平滚动的时候,滚动距离是列表可滚动的最大长度。
到这里我们已经实现了Android的跑马灯,而且还增加了垂直滚动,是不是很简单呢。
如有问题、意见和建议,都可以在评论区里告诉我,我将及时修改和参考你的意见和建议,对代码做出优化。