Flutter互動實戰-即刻App探索頁下拉&拖拽效果
Flutter最近比較熱門,但是Flutter成體系的文章並不多,前期避免不了踩坑;我這篇文章主要介紹如何使用Flutter實現一個比較複雜的手勢互動,順便分享一下我在使用Flutter過程中遇到的一些小坑,減少大家入坑;
先睹為快
本專案支援ios&android執行,效果如下




對了,順便分享一下生成 gif
的小竅門,建議用手機自帶錄屏功能匯出 mp4
檔案到電腦,然後電腦端用 ffmpeg
命令列處理,控制 gif
的質量和檔案大小,我的建議是解析度控制在270p,幀率在10左右;
互動分析
看文章的小夥伴最好能手持即刻App,親自體驗一下探索頁的互動,是黃色Logo黃色主題色的即刻;有人簡稱‘黃即’;

即刻App原版功能有卡片旋轉,卡片撤回和卡片自動移除,時間關係我暫時沒有去實現,但是核心功能一點都不會砍;
以我Android開發習慣來看,互動分為可拆分內外兩層,外層我們需要一個整體下拉的控制元件,內層我們需要實現一個上、下、左、右四方向拖拽移動的控制元件,我們稱為卡片控制元件;同時這兩層還需要處理子Widget的佈局,再看細節:
下拉控制元件:
- 子控制元件從上到下豎直襬放,頂部選單預設隱藏在螢幕外
- 下拉手勢所有子控制元件下移,選單視覺差效果
- 支援點選自動展開、收起效果
卡片控制元件
- 卡片層疊佈局,錯落有致
- 最上層卡片支援手勢拖拽
- 其他卡片相應拖拽小幅位移
- 鬆手移除卡片
碼上入手
熱身
套用App開發伎倆,實現上面的互動無非就是控制元件佈局和手勢識別。當然在Flutter中也跑不掉這兩點,在Flutter中常用的基本佈局有 Column
、 Row
、 Stack
等,手勢識別有 Listener
、 GestureDetector
、 RawGestureDetector
等,本文的講解不限於上面這幾個Widget,因為Flutter提供的Widget太多了,真是用到啥查啥;
所以下面我們從佈局和手勢這兩個大的技術點,來一一擊破功能點;
佈局擺放
這裡所謂的佈局,包括Widget的尺寸大小和位置的控制,一般都是父Widget掌管子Widget的命運,Flutter就是一層一層Widget巢狀,不要擔心,下面從外到內具體案例講解;
下拉控制元件
首先我們要實現最外層佈局,效果是:子Widget豎直襬放,且最上面的Widget預設需要擺放在螢幕外;

如上圖所示,紅色區域是螢幕範圍, header
是頭部隱藏的選單佈局, content
是卡片佈局的主體;
先說入的坑
豎直佈局我最先想到的是 Column
,我想要的效果是 content
高度和父Widget的高度一致,我首先想到是讓 Expanded
包裹 content
,結果是content的高度永遠等於 Column
高度減 header
高度,造成現象就是content高度不填充,或者是擠壓現象,如果繼續使用 Colunm
可能就得放棄 Expanded
,手動給 content
賦值高度,沒準是個辦法,但我不願意手動賦值 content
,我不想為了實現而實現,果斷放棄用 Column
;
另一個問題是如何隱藏 header
,我想到兩種方案:
- 採用外層
Transform
包裹整個佈局,內層Transform
包裹header
,然後賦值內層dy = -headerHeight
,隨著手勢下拉動態,並不改變header
的Transform
,而是改變最外層Transform
的dy
; - 動態改變
header
高度,初始高度為0,隨著手勢下拉動態計算;
但是上面這兩種都有坑,第一種方式會影響控制元件的點選事件, onTap
方法不會被回撥;第二種由於高度在不斷改變,會影象 header
內部子Widget的佈局,很難做視覺差的控制;
最終方案
最後採用 Stack
來佈局,通過 Stack
配合 Positioned
,實現 header
佈局在螢幕外,而且可以做到讓 content
佈局填充父Widget;
PullDragWidget
Widget build(BuildContext context) { return RawGestureDetector( behavior: HitTestBehavior.translucent, gestures: _contentGestures, child: Stack( children: <Widget>[ Positioned(//content佈局 top: _offsetY, bottom: -_offsetY, left: 0, right: 0, child: IgnorePointer( ignoring: _opened, child: widget.child, )), Positioned(////header佈局 top: -widget.dragHeight + _offsetY, bottom: null, left: 0, right: 0, height: widget.dragHeight, child: _headerWidget()), ], )); } 複製程式碼
首先解釋一下 Positioned
的基本用法, top
、 bottom
、 height
控制高度和位置,而且兩兩配合使用, top
和 bottom
可以理解成marginTop和marginBottom, height
顧名思義是直接Widget的高度,如果 top
配置 bottom
,意味著高度等於 parentHeight-top-bottom
,如果 top
/ bottom
配合 height
使用,高度一般是固定的,當然 top
和 bottom
是接受負數的;
再分析程式碼,首先 _offsetY
是下拉距離,是一個改變的量初始值為0, content
需要設定 top = _offsetY
和 bottom = -_offsetY
,改變的是上下位置,高度不會改變;同理, header
是採用 top
和 height
控制,高度固定,只需要動態改變 top
即可;
用Flutter寫佈局真的很簡單,我極力推崇使用 Stack
佈局,因為它比較靈活,沒有太多的限制,用好 Stack
主要還得用好 Positioned
,學好它沒錯;
卡片控制元件
卡片實現的效果就是依次層疊,錯落有致,這個很容易想到 Stack
來實現,當然有了上面踩坑,用 Stack
算是很輕鬆了;

重疊的效果使用Stack很簡單,錯落有致的效果實在起來可能性就比較多了,比如可以使用 Positioned
,也可以包裹 Container
改變 margin
或者 padding
,但是考慮到角度的旋轉,我選擇使用 Transform
,因為 Transform
不僅可以玩轉位移,還有角度和縮放等,其實就是一個矩陣變換;but但是我對 Transform
持有疑問:執行完變換之後,有某些情況是不能正常的相應觸控事件,這可能是 Transform
的bug;
CardStackWidget
Widget build(BuildContext context) { if (widget.cardList == null || widget.cardList.length == 0) { return Container(); } List<Widget> children = new List(); int length = widget.cardList.length; int count = (length > widget.cardCount) ? widget.cardCount : length; for (int i = 0; i < count; i++) { double dx = i == 0 ? _totalDx : -_ratio * widget.offset; double dy = i == 0 ? _totalDy : _ratio * widget.offset; Widget cardWidget = _CardWidget( cardEntity: widget.cardList[i], position: i, dx: dx, dy: dy, offset: widget.offset, ); if (i == 0) { cardWidget = RawGestureDetector( gestures: _cardGestures, behavior: HitTestBehavior.deferToChild, child: cardWidget, ); } children.add(Container( child: cardWidget, alignment: Alignment.topCenter, padding: widget.cardPadding, )); } return Stack( children: children.reversed.toList(), ); } 複製程式碼
_CardWidget
Widget build(BuildContext context) { return AspectRatio( aspectRatio: 0.75, child: Transform( transform: Matrix4.translationValues( dx + (offset * position.toDouble()), dy + (-offset * position.toDouble()), 0), child: ClipRRect( borderRadius: BorderRadius.circular(10), child: Stack( fit: StackFit.expand, children: <Widget>[ Image.network( cardEntity.picUrl, fit: BoxFit.cover, ), Container(color: const Color(0x5a000000)), Container( margin: EdgeInsets.all(20), alignment: Alignment.center, child: Text( cardEntity.text, textAlign: TextAlign.center, style: TextStyle( letterSpacing: 2, fontSize: 22, color: Colors.white, fontWeight: FontWeight.bold), maxLines: 4, ), ) ], ), )), ); } 複製程式碼
簡單總結一下卡片佈局程式碼, CardStackWidget
是管理卡片 Stack
的父控制元件,負責對每個卡片進行佈局, _CardWidget
是對單獨卡片內部進行佈局,總體來說沒有什麼難點,細節控制邏輯是在對上層 _CardWidget
和底層 _CardWidget
偏移量的計算;
佈局的內容就講這麼多,整體來說還是比較簡單,所謂的有些坑也不一定算是坑,只是不適應某些應用場景罷了;
手勢識別
Flutter手勢識別最常用的是 Listener
和 GestureDetector
這兩個Widget,其中 Listener
主要針對原始觸控點進行處理, GestureDetector
已經對原始觸控點加工成了不同的手勢;這兩個類的方法介紹如下;
Listener
Listener({ Key key, this.onPointerDown, //手指按下回調 this.onPointerMove, //手指移動回撥 this.onPointerUp,//手指抬起回撥 this.onPointerCancel,//觸控事件取消回撥 this.behavior = HitTestBehavior.deferToChild, //在命中測試期間如何表現 Widget child }) 複製程式碼
GestureDetector手勢回撥:
Property/Callback | Description |
---|---|
onTapDown | 使用者每次和螢幕互動時都會被呼叫 |
onTapUp | 使用者停止觸控式螢幕幕時觸發 |
onTap | 短暫觸控式螢幕幕時觸發 |
onTapCancel | 使用者觸摸了螢幕,但是沒有完成Tap的動作時觸發 |
onDoubleTap | 使用者在短時間內觸摸了螢幕兩次 |
onLongPress | 使用者觸控式螢幕幕時間超過500ms時觸發 |
onVerticalDragDown | 當一個觸控點開始跟螢幕互動,同時在垂直方向上移動時觸發 |
onVerticalDragStart | 當觸控點開始在垂直方向上移動時觸發 |
onVerticalDragUpdate | 螢幕上的觸控點位置每次改變時,都會觸發這個回撥 |
onVerticalDragEnd | 當用戶停止移動,這個拖拽操作就被認為是完成了,就會觸發這個回撥 |
onVerticalDragCancel | 使用者突然停止拖拽時觸發 |
onHorizontalDragDown | 當一個觸控點開始跟螢幕互動,同時在水平方向上移動時觸發 |
onHorizontalDragStart | 當觸控點開始在水平方向上移動時觸發 |
onHorizontalDragUpdate | 螢幕上的觸控點位置每次改變時,都會觸發這個回撥 |
onHorizontalDragEnd | 水平拖拽結束時觸發 |
onHorizontalDragCancel | onHorizontalDragDown沒有成功完成時觸發 |
onPanDown | 當觸控點開始跟螢幕互動時觸發 |
onPanStart | 當觸控點開始移動時觸發 |
onPanUpdate | 螢幕上的觸控點位置每次改變時,都會觸發這個回撥 |
onPanEnd | pan操作完成時觸發 |
onScaleStart | 觸控點開始跟螢幕互動時觸發,同時會建立一個焦點為1.0 |
onScaleUpdate | 跟螢幕互動時觸發,同時會標示一個新的焦點 |
onScaleEnd | 觸控點不再跟螢幕有任何互動,同時也表示這個scale手勢完成 |
Listener
和 GestureDetector
如何抉擇,首先 GestureDetector
是基於 Listener
封裝,它解決了大部分手勢衝突,我們使用 GestureDetector
就夠用了,但是 GestureDetector
不是萬能的,必要時候需要自定義 RawGestureDetector
;
另外一個很重要的概念,Flutter手勢事件是一個從內Widget向外Widget的冒泡機制,假設內外Widget同時監聽豎直方向的拖拽事件 onVerticalDragUpdate
,往往都是內層控制元件獲得事件,外層事件被動取消;這樣的概念和Android父佈局攔截機制就完全不同了;
雖然Flutter沒有外層攔截機制,但是似乎還有一線希望,那就是 IgnorePointer
和 AbsorbPointer
Widget,這倆哥們可以忽略或者阻止子Widget樹不響應Event;
手勢分析
基本原理介紹完了,接下來分析案例互動,上面說了我把整體佈局拆分成了下拉控制元件和卡片控制元件,分析即刻App的拖拽的行為:當下拉控制元件沒有展開下拉選單時,卡片控制元件是可以相應上、左、右三個方向的手勢,下拉控制元件只相應一個向下方向的手勢;當下拉選單展開時,卡片不能相應任何手勢,下拉控制元件可以相應豎直方向的所有事件;

上圖更加形象解釋兩種狀態下的手勢響應,下拉控制元件是父Widget,卡片控制元件是子Widget,由於子Widget能優先響手勢,所以在初始階段,我們不能讓子Widget響應向下的手勢;
由於 GestureDetector
只封裝水平和豎直方向的手勢,且兩種手勢不能同時使用,我們從 GestureDetector
原始碼來看,能不能封裝一個監聽不同四個方向的手勢,;
GestureDetector
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{}; if (onVerticalDragDown != null || onVerticalDragStart != null || onVerticalDragUpdate != null || onVerticalDragEnd != null || onVerticalDragCancel != null) { gestures[VerticalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>( () => VerticalDragGestureRecognizer(debugOwner: this), (VerticalDragGestureRecognizer instance) { instance ..onDown = onVerticalDragDown ..onStart = onVerticalDragStart ..onUpdate = onVerticalDragUpdate ..onEnd = onVerticalDragEnd ..onCancel = onVerticalDragCancel; }, ); } return RawGestureDetector( gestures: gestures, behavior: behavior, excludeFromSemantics: excludeFromSemantics, child: child, ); 複製程式碼
GestureDetector
最終返回的是 RawGestureDetector
,其中 gestures
是一個 map
,豎直方向的手勢在 VerticalDragGestureRecognizer
這個類;
VerticalDragGestureRecognizer
class VerticalDragGestureRecognizer extends DragGestureRecognizer { /// Create a gesture recognizer for interactions in the vertical axis. VerticalDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner); @override bool _isFlingGesture(VelocityEstimate estimate) { final double minVelocity = minFlingVelocity ?? kMinFlingVelocity; final double minDistance = minFlingDistance ?? kTouchSlop; return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance; } @override bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop; @override Offset _getDeltaForDetails(Offset delta) => Offset(0.0, delta.dy); @override double _getPrimaryValueFromOffset(Offset value) => value.dy; @override String get debugDescription => 'vertical drag'; } 複製程式碼
VerticalDragGestureRecognizer
繼承 DragGestureRecognizer
,大部分邏輯都在 DragGestureRecognizer
中,我們只關注重寫的方法:
_hasSufficientPendingDragDeltaToAccept _getDeltaForDetails _getPrimaryValueFromOffset _isFlingGesture
自定義DragGestureRecognizer
想實現接受三個方向的手勢,自定義 DragGestureRecognizer
是一個好的思路;我希望接受上、下、左、右四個方向的引數,根據引數不同監聽不同的手勢行為,照葫蘆畫瓢自定義一個接受方向的 GestureRecognizer
:
DirectionGestureRecognizer
class DirectionGestureRecognizer extends _DragGestureRecognizer { int direction; //接受中途變動 ChangeGestureDirection changeGestureDirection; //不同方向 static int left = 1 << 1; static int right = 1 << 2; static int up = 1 << 3; static int down = 1 << 4; static int all = left | right | up | down; DirectionGestureRecognizer(this.direction, {Object debugOwner}) : super(debugOwner: debugOwner); @override bool _isFlingGesture(VelocityEstimate estimate) { if (changeGestureDirection != null) { direction = changeGestureDirection(); } final double minVelocity = minFlingVelocity ?? kMinFlingVelocity; final double minDistance = minFlingDistance ?? kTouchSlop; if (_hasAll) { return estimate.pixelsPerSecond.distanceSquared > minVelocity && estimate.offset.distanceSquared > minDistance; } else { bool result = false; if (_hasVertical) { result |= estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance; } if (_hasHorizontal) { result |= estimate.pixelsPerSecond.dx.abs() > minVelocity && estimate.offset.dx.abs() > minDistance; } return result; } } bool get _hasLeft => _has(DirectionGestureRecognizer.left); bool get _hasRight => _has(DirectionGestureRecognizer.right); bool get _hasUp => _has(DirectionGestureRecognizer.up); bool get _hasDown => _has(DirectionGestureRecognizer.down); bool get _hasHorizontal => _hasLeft || _hasRight; bool get _hasVertical => _hasUp || _hasDown; bool get _hasAll => _hasLeft && _hasRight && _hasUp && _hasDown; bool _has(int flag) { return (direction & flag) != 0; } @override bool get _hasSufficientPendingDragDeltaToAccept { if (changeGestureDirection != null) { direction = changeGestureDirection(); } // if (_hasAll) { //return _pendingDragOffset.distance > kPanSlop; // } bool result = false; if (_hasUp) { result |= _pendingDragOffset.dy < -kTouchSlop; } if (_hasDown) { result |= _pendingDragOffset.dy > kTouchSlop; } if (_hasLeft) { result |= _pendingDragOffset.dx < -kTouchSlop; } if (_hasRight) { result |= _pendingDragOffset.dx > kTouchSlop; } return result; } @override Offset _getDeltaForDetails(Offset delta) { if (_hasAll || (_hasVertical && _hasHorizontal)) { return delta; } double dx = delta.dx; double dy = delta.dy; if (_hasVertical) { dx = 0; } if (_hasHorizontal) { dy = 0; } Offset offset = Offset(dx, dy); return offset; } @override double _getPrimaryValueFromOffset(Offset value) { return null; } @override String get debugDescription => 'orientation_' + direction.toString(); } 複製程式碼
重寫主要的識別方法,根據不同的引數處理不同的手勢邏輯;
注意事項
但是這裡有一些注意事項: _getDeltaForDetails
返回水平豎直方向的偏移量,在手勢交叉方向的偏移量適情況需要置0;
當前Widget樹只純在一個手勢時,手勢判斷的邏輯 _hasSufficientPendingDragDeltaToAccept
可能不會被呼叫,一定要重寫 _getDeltaForDetails
控制返回結果;
如何使用
自定義的 DirectionGestureRecognizer
可以配置 left
、 right
、 up
、 down
四個方向的手勢,而且支援不同的組合;
比如我們只想監聽豎直向下方向,就建立 DirectionGestureRecognizer(DirectionGestureRecognizer.down)
的手勢識別;
想監聽上、左、右的手勢,建立 DirectionGestureRecognizer(DirectionGestureRecognizer.left | DirectionGestureRecognizer.right | DirectionGestureRecognizer.up)
的手勢識別;
DirectionGestureRecognizer
就像一把磨刀石,刀已經磨鋒利,砍材就很輕鬆了,下面進行控制元件的手勢實現;
下拉控制元件手勢
PullDragWidget
_contentGestures = { //向下的手勢 DirectionGestureRecognizer: GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>( () => DirectionGestureRecognizer(DirectionGestureRecognizer.down), (instance) { instance.onDown = _onDragDown; instance.onStart = _onDragStart; instance.onUpdate = _onDragUpdate; instance.onCancel = _onDragCancel; instance.onEnd = _onDragEnd; }), //點選的手勢 TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( () => TapGestureRecognizer(), (instance) { instance.onTap = _onContentTap; }) }; Widget build(BuildContext context) { return RawGestureDetector(//返回RawGestureDetector behavior: HitTestBehavior.translucent, gestures: _contentGestures,//手勢在此 child: Stack( children: <Widget>[ Positioned( top: _offsetY, bottom: -_offsetY, left: 0, right: 0, child: IgnorePointer( ignoring: _opened, child: widget.child, )), Positioned( top: -widget.dragHeight + _offsetY, bottom: null, left: 0, right: 0, height: widget.dragHeight, child: _headerWidget()), ], )); } 複製程式碼
PullDragWidget
是下拉拖拽控制元件,根Widget是一個 RawGestureDetector
用來監聽手勢,其中 gestures
支援向下拖拽和點選兩個手勢;當下拉控制元件處於 _opened
狀態說 header
已經拉下來,此時配合 IgnorePointer
,禁用子Widget所有的事件監聽,自然內部的卡片就相應不了任何事件;
卡片控制元件手勢
同下拉控制元件一樣,卡片控制元件只需要監聽其餘三個方向的手勢,即可完成任務:
CardStackWidget
_cardGestures = { DirectionGestureRecognizer://監聽上左右三個方向 GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>( () => DirectionGestureRecognizer(DirectionGestureRecognizer.left | DirectionGestureRecognizer.right | DirectionGestureRecognizer.up), (instance) { instance.onDown = _onPanDown; instance.onUpdate = _onPanUpdate; instance.onEnd = _onPanEnd; }), TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( () => TapGestureRecognizer(), (instance) { instance.onTap = _onCardTap; }) }; 複製程式碼
小結
根據Flutter手勢冒泡的特性,父Widget既沒有響應事件的優先權,也沒有監聽單獨方向的手勢,只能自己想辦法自定義 GestureRecognizer
,把原本 Vertical
和 Horizontal
兩個方向的手勢識別擴充套件成 left
、 right
、 up
、 down
四個方向,分開監聽可能會衝突的手勢;當然也可能有其他的方案來實現手勢的監聽,希望大家能提出寶貴意見;
總結
知識點
由於篇幅有限並沒有完全介紹該互動的所有內容,我歸納一下程式碼中用到的知識點:
-
Column
、Row
、Expanded
、Stack
、Positioned
、Transform
等Widget; -
GestureDetector
、RawGestureDetector
、IgnorePointer
等Widget; - 自定義
GestureRecognizer
實現自定義手勢識別; -
AnimationController
、Tween
等動畫的使用; -
EventBus
的使用;
最後
上面章節主要介紹在當前場景下用Flutter佈局和手勢的實戰技巧,其中更深層次手勢競技和分發的原始碼級分析,有機會再做深入學習和分享;
另外本篇並不是循序漸進的零基礎入門,對剛接觸的同學可能感覺有點懵,但是沒有關係,建議你 clone
一份程式碼跑起來效果,沒準就能提起自己學習的興趣;