本文介紹怎麼在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的跑馬燈,並且還增長了垂直滾動,是否是很簡單呢。
若有問題、意見和建議,均可以在評論區裏告訴我,我將及時修改和參考你的意見和建議,對代碼作出優化。