Flutter最近比較熱門,可是Flutter成體系的文章並很少,前期避免不了踩坑;我這篇文章主要介紹如何使用Flutter實現一個比較複雜的手勢交互,順便分享一下我在使用Flutter過程當中遇到的一些小坑,減小你們入坑;android
本項目支持ios
和android
平臺,效果以下ios
對了,順便分享一下生成gif
的小竅門,建議用手機自帶錄屏功能導出mp4
文件到電腦,而後電腦端用ffmpeg
命令行處理,控制gif
的質量和文件大小,個人建議是分辨率控制在270p,幀率在10左右;git
看文章的小夥伴最好能手持即刻App,親自體驗一下探索頁的交互,是黃色Logo黃色主題色的即刻,人稱‘黃即’;github
即刻App原版功能有卡片旋轉,卡片撤回和卡片自動移除,時間關係暫時沒有實現,但核心的功能都在;bash
從一個Android開發者的習慣來看待,這個交互可拆份內外兩層控件,外層咱們須要一個總體下拉的控件,我稱爲下拉控件
;內層咱們須要實現一個上、下、左、右四方向拖拽移動的控件,咱們稱爲卡片控件
;下拉控件
和卡片控件
不只要處理手勢,還須要處理子Widget的佈局;下面我再分析細節功能:app
下拉控件:ide
卡片控件佈局
套用App開發伎倆,實現上面的交互無非就是控件佈局和手勢識別。固然Flutter開發也是這些套路,只不過萬物皆是Widget,在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
不只能夠玩轉位移,還有角度和縮放等,其內部其實是操做一個矩陣變換;Transform
挺好用,可是在Transform
多層嵌套的某些特殊狀況下,會存在不響應onTap
事件的狀況,我想這應該是Transform
的bug,拖拽事件暫時沒有發現問題,這個是否是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
返回拖拽進度的dx、dy偏移量_getPrimaryValueFromOffset
返回單方向手勢value,不一樣方向(同時擁有水平和豎直)的能夠傳null_isFlingGesture
是否該手勢的Fling行爲想實現接受三個方向的手勢,自定義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();
}
複製代碼
因爲DragGestureRecognizer
的不少方法是私有的,想從新只能copy一份代碼出來,而後重寫主要的方法,根據不一樣入參處理不一樣的手勢邏輯;
注意事項
敲黑板了,在自定義DragGestureRecognizer
時:_getDeltaForDetails
返回值表示dx
和dy
的偏移量,在只存在水平或者只存在豎直方向的狀況下,須要將另外一個方向的dx
或dy
置0;
當前Widget樹有且只存在一個手勢時,手勢判斷的邏輯_hasSufficientPendingDragDeltaToAccept
可能不會被調用,這時候必定要重寫_getDeltaForDetails
控制返回dx
和dy
;
如何使用
自定義的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;
})
};
複製代碼
onPanDown
onPanUpdate
onPanEnd
來拖動?這是掘金評論提的問題,我解答一下:在GestureDetector
中有Pan
手勢和Drag
手勢,這兩個手勢都能用處拖拽的場景,但不一樣的是Drag
手勢僅限於水平
和豎直
方向的監聽,Pan
手勢不約束方向任意方向都能監聽,除此以外觸發條件也不一致,Pan
手勢的觸發條件是滑動動屏幕的距離distance
大於kTouchSlop*2
,Drag
手勢的觸發條件是dx
或者dy
大於kTouchSlop
,dx
、dy
和distance
造成勾股定理的三個邊長;假設一樣在監聽豎直滑動這種場景,VerticalDrag
老是比Pan
先觸發;若是下拉控件用VerticalDrag
卡片控件用Pan
,下拉控件會優先獲取向上的拖拽,卡片控件就會失去向上拖拽的機會,這就實現不了交互了,退一步即便Pan
的觸發條件跟VerticalDrag
同樣,因爲Flutter的事件傳遞是從內到外的,這會致使外層下拉控件徹底失去響應機會。以上個人我的理解,若有誤導還請大佬評論指正。
分析Flutter手勢冒泡的特性,父Widget既沒有響應事件的優先權,也沒有監聽單獨方向(left
、right
、up
、down
)的手勢,只能本身想辦法自定義GestureRecognizer
,把本來Vertical
和Horizontal
兩個方向的手勢識別擴展成left
、right
、up
、down
四個方向,區分開會產生衝突的手勢;
固然也可能有其餘的方案來實現該交互的手勢識別,條條大路通羅馬,我只是拋磚引玉,你們有好的方案能夠積極留言提出寶貴意見;
因爲篇幅有限並無介紹完該交互的全部內容,深表遺憾,總結概括一下代碼中用到的知識點:
Column
、Row
、Expanded
、Stack
、Positioned
、Transform
等Widget的使用;GestureDetector
、RawGestureDetector
、IgnorePointer
等Widget的使用;GestureRecognizer
實現自定義手勢識別;AnimationController
、Tween
等動畫的使用;EventBus
的使用;上面章節主要介紹在當前場景下用Flutter佈局和手勢的實戰技巧,其中更深層次手勢競技和分發的源碼級分析,有機會再作深刻學習和分享;
另外本篇並非按部就班的零基礎入門,對剛接觸的同窗可能感受有點懵,可是沒有關係,建議你clone
一份代碼跑起來效果,沒準就能提起本身學習的興趣;
最最後,本篇全部代碼都是開源的,你的點贊是對我最大的鼓勵。