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