Flutter交互實(shí)戰(zhàn)-即刻App探索頁下拉&拖拽效果

image

Flutter最近比較熱門,但是Flutter成體系的文章并不多反粥,前期避免不了踩坑卢肃;我這篇文章主要介紹如何使用Flutter實(shí)現(xiàn)一個(gè)比較復(fù)雜的手勢(shì)交互,順便分享一下我在使用Flutter過程中遇到的一些小坑才顿,減少大家入坑莫湘;

先睹為快

本項(xiàng)目支持ios&android運(yùn)行,效果如下



image

image

對(duì)了郑气,順便分享一下生成gif的小竅門幅垮,建議用手機(jī)自帶錄屏功能導(dǎo)出mp4文件到電腦,然后電腦端用ffmpeg命令行處理尾组,控制gif的質(zhì)量和文件大小忙芒,我的建議是分辨率控制在270p,幀率在10左右演怎;

交互分析

看文章的小伙伴最好能手持即刻App,親自體驗(yàn)一下探索頁的交互避乏,是黃色Logo黃色主題色的即刻爷耀;有人稱‘黃即’;

image

即刻App原版功能有卡片旋轉(zhuǎn)拍皮,卡片撤回和卡片自動(dòng)移除歹叮,時(shí)間關(guān)系暫時(shí)沒有實(shí)現(xiàn),但核心的功能都在铆帽;

從一個(gè)Android開發(fā)者的習(xí)慣來看待咆耿,這個(gè)交互可拆分內(nèi)外兩層控件,外層我們需要一個(gè)整體下拉的控件爹橱,我稱為下拉控件萨螺;內(nèi)層我們需要實(shí)現(xiàn)一個(gè)上、下愧驱、左慰技、右四方向拖拽移動(dòng)的控件,我們稱為卡片控件组砚;下拉控件卡片控件不僅要處理手勢(shì)吻商,還需要處理子Widget的布局;下面我再分析細(xì)節(jié)功能:

下拉控件:

  • 子控件從上到下豎直擺放糟红,頂部菜單默認(rèn)隱藏在屏幕外
  • 下拉手勢(shì)所有子控件下移艾帐,菜單視覺差效果
  • 支持點(diǎn)擊自動(dòng)展開乌叶、收起效果

卡片控件

  • 卡片層疊布局,錯(cuò)落有致
  • 最上層卡片支持手勢(shì)拖拽
  • 其他卡片相應(yīng)拖拽小幅位移
  • 松手移除卡片

碼上入手

熱身

套用App開發(fā)伎倆柒爸,實(shí)現(xiàn)上面的交互無非就是控件布局和手勢(shì)識(shí)別准浴。當(dāng)然Flutter開發(fā)也是這些套路,只不過萬物皆是Widget揍鸟,在Flutter中常用的基本布局有Column兄裂、RowStack等阳藻,手勢(shì)識(shí)別有Listener晰奖、GestureDetectorRawGestureDetector等腥泥,這是本文重點(diǎn)講解的控件匾南,不限于上面這幾個(gè)Widget,因?yàn)镕lutter提供的Widget太多了蛔外,重點(diǎn)的控件需要牢記外蛆楞,其他時(shí)候真是現(xiàn)用現(xiàn)查;

所以下面我們從布局和手勢(shì)這兩個(gè)大的技術(shù)點(diǎn)夹厌,來一一擊破功能點(diǎn)豹爹;

布局?jǐn)[放

這里所謂的布局,包括Widget的尺寸大小和位置的控制矛纹,一般都是父Widget掌管子Widget的命運(yùn)臂聋,F(xiàn)lutter就是一層一層Widget嵌套,不要擔(dān)心或南,下面從外到內(nèi)具體案例講解孩等;

下拉控件

首先我們要實(shí)現(xiàn)最外層布局,效果是:子Widget豎直擺放采够,且最上面的Widget默認(rèn)需要擺放在屏幕外肄方;

image

如上圖所示,紅色區(qū)域是屏幕范圍蹬癌,header是頭部隱藏的菜單布局权她,content是卡片布局的主體;

先說入的坑

豎直布局我最先想到的是Column逝薪,我想要的效果是content高度和父Widget的高度一致伴奥,我首先想到是讓Expanded包裹content,結(jié)果是content的高度永遠(yuǎn)等于Column高度減header高度翼闽,造成現(xiàn)象就是content高度不填充拾徙,或者是擠壓現(xiàn)象,如果繼續(xù)使用Colunm可能就得放棄Expanded感局,手動(dòng)給content賦值高度尼啡,沒準(zhǔn)是個(gè)辦法暂衡,但我不愿意手動(dòng)賦值content的高度,太不優(yōu)雅了崖瞭,最后果斷棄用Column狂巢;

另一個(gè)問題是如何隱藏header,我想到兩種方案:

  1. 采用外層Transform包裹整個(gè)布局书聚,內(nèi)層Transform包裹header唧领,然后賦值內(nèi)層dy = -headerHeight,隨著手勢(shì)下拉動(dòng)態(tài)雌续,并不改變headerTransform斩个,而是改變最外層Transformdy
  2. 動(dòng)態(tài)改變header高度驯杜,初始高度為0受啥,隨著手勢(shì)下拉動(dòng)態(tài)計(jì)算;

但是上面這兩種都有坑鸽心,第一種方式會(huì)影響控件的點(diǎn)擊事件滚局,onTap方法不會(huì)被回調(diào);第二種由于高度在不斷改變顽频,會(huì)影響header內(nèi)部子Widget的布局藤肢,很難做視覺差的控制;

最終方案

最后采用Stack來布局糯景,通過Stack配合Positioned嘁圈,實(shí)現(xiàn)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控制高度和位置灭贷,而且兩兩配合使用,topbottom可以理解成marginTop和marginBottom略贮,height顧名思義是直接Widget的高度甚疟,如果top配置bottom,意味著高度等于parentHeight-top-bottom逃延,如果top/bottom配合height使用览妖,高度一般是固定的,當(dāng)然topbottom是接受負(fù)數(shù)的揽祥;

再分析代碼讽膏,首先_offsetY是下拉距離,是一個(gè)改變的量初始值為0拄丰,content需要設(shè)置top = _offsetYbottom = -_offsetY府树,改變的是上下位置俐末,高度不會(huì)改變;同理奄侠,header是采用topheight控制卓箫,高度固定,只需要?jiǎng)討B(tài)改變top即可垄潮;

用Flutter寫布局真的很簡單烹卒,我極力推崇使用Stack布局,因?yàn)樗容^靈活弯洗,沒有太多的限制旅急,用好Stack主要還得用好Positioned,學(xué)好它沒錯(cuò)涂召;

卡片控件

卡片實(shí)現(xiàn)的效果就是依次層疊坠非,錯(cuò)落有致,這個(gè)很容易想到Stack來實(shí)現(xiàn)果正,當(dāng)然有了上面踩坑炎码,用Stack算是很輕松了;

image

重疊的效果使用Stack很簡單秋泳,錯(cuò)落有致的效果實(shí)在起來可能性就比較多了潦闲,比如可以使用Positioned,也可以包裹Container改變margin或者padding迫皱,但是考慮到角度的旋轉(zhuǎn)歉闰,我選擇使用Transform,因?yàn)?code>Transform不僅可以玩轉(zhuǎn)位移卓起,還有角度和縮放等和敬,其內(nèi)部實(shí)際上是操作一個(gè)矩陣變換;Transform挺好用戏阅,但是在Transform多層嵌套的某些特殊情況下昼弟,會(huì)存在不響應(yīng)onTap事件的情況,我想這應(yīng)該是Transform的bug奕筐,拖拽事件暫時(shí)沒有發(fā)現(xiàn)問題舱痘,這個(gè)是不是bug有待確認(rèn),暫時(shí)不影響使用离赫;

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,
                ),
              )
            ],
          ),
        )),
  );
}

簡單總結(jié)一下卡片布局代碼芭逝,CardStackWidget是管理卡片Stack的父控件,負(fù)責(zé)對(duì)每個(gè)卡片進(jìn)行布局渊胸,_CardWidget是對(duì)單獨(dú)卡片內(nèi)部進(jìn)行布局旬盯,總體來說沒有什么難點(diǎn),細(xì)節(jié)控制邏輯是在對(duì)上層_CardWidget和底層_CardWidget偏移量的計(jì)算;

布局的內(nèi)容就講這么多胖翰,整體來說還是比較簡單频丘,所謂的有些坑也不一定算是坑,只是不適應(yīng)某些應(yīng)用場景罷了泡态;

手勢(shì)識(shí)別

Flutter手勢(shì)識(shí)別最常用的是ListenerGestureDetector這兩個(gè)Widget搂漠,其中Listener主要針對(duì)原始觸摸點(diǎn)進(jìn)行處理,GestureDetector已經(jīng)對(duì)原始觸摸點(diǎn)加工成了不同的手勢(shì)某弦;這兩個(gè)類的方法介紹如下桐汤;

Listener

Listener({
  Key key,
  this.onPointerDown, //手指按下回調(diào)
  this.onPointerMove, //手指移動(dòng)回調(diào)
  this.onPointerUp,//手指抬起回調(diào)
  this.onPointerCancel,//觸摸事件取消回調(diào)
  this.behavior = HitTestBehavior.deferToChild, //在命中測(cè)試期間如何表現(xiàn)
  Widget child
})

GestureDetector手勢(shì)回調(diào):

Property/Callback Description
onTapDown 用戶每次和屏幕交互時(shí)都會(huì)被調(diào)用
onTapUp 用戶停止觸摸屏幕時(shí)觸發(fā)
onTap 短暫觸摸屏幕時(shí)觸發(fā)
onTapCancel 用戶觸摸了屏幕,但是沒有完成Tap的動(dòng)作時(shí)觸發(fā)
onDoubleTap 用戶在短時(shí)間內(nèi)觸摸了屏幕兩次
onLongPress 用戶觸摸屏幕時(shí)間超過500ms時(shí)觸發(fā)
onVerticalDragDown 當(dāng)一個(gè)觸摸點(diǎn)開始跟屏幕交互靶壮,同時(shí)在垂直方向上移動(dòng)時(shí)觸發(fā)
onVerticalDragStart 當(dāng)觸摸點(diǎn)開始在垂直方向上移動(dòng)時(shí)觸發(fā)
onVerticalDragUpdate 屏幕上的觸摸點(diǎn)位置每次改變時(shí)怔毛,都會(huì)觸發(fā)這個(gè)回調(diào)
onVerticalDragEnd 當(dāng)用戶停止移動(dòng),這個(gè)拖拽操作就被認(rèn)為是完成了腾降,就會(huì)觸發(fā)這個(gè)回調(diào)
onVerticalDragCancel 用戶突然停止拖拽時(shí)觸發(fā)
onHorizontalDragDown 當(dāng)一個(gè)觸摸點(diǎn)開始跟屏幕交互拣度,同時(shí)在水平方向上移動(dòng)時(shí)觸發(fā)
onHorizontalDragStart 當(dāng)觸摸點(diǎn)開始在水平方向上移動(dòng)時(shí)觸發(fā)
onHorizontalDragUpdate 屏幕上的觸摸點(diǎn)位置每次改變時(shí),都會(huì)觸發(fā)這個(gè)回調(diào)
onHorizontalDragEnd 水平拖拽結(jié)束時(shí)觸發(fā)
onHorizontalDragCancel onHorizontalDragDown沒有成功完成時(shí)觸發(fā)
onPanDown 當(dāng)觸摸點(diǎn)開始跟屏幕交互時(shí)觸發(fā)
onPanStart 當(dāng)觸摸點(diǎn)開始移動(dòng)時(shí)觸發(fā)
onPanUpdate 屏幕上的觸摸點(diǎn)位置每次改變時(shí)螃壤,都會(huì)觸發(fā)這個(gè)回調(diào)
onPanEnd pan操作完成時(shí)觸發(fā)
onScaleStart 觸摸點(diǎn)開始跟屏幕交互時(shí)觸發(fā)抗果,同時(shí)會(huì)建立一個(gè)焦點(diǎn)為1.0
onScaleUpdate 跟屏幕交互時(shí)觸發(fā),同時(shí)會(huì)標(biāo)示一個(gè)新的焦點(diǎn)
onScaleEnd 觸摸點(diǎn)不再跟屏幕有任何交互奸晴,同時(shí)也表示這個(gè)scale手勢(shì)完成

ListenerGestureDetector如何抉擇冤馏,首先GestureDetector是基于Listener封裝,它解決了大部分手勢(shì)沖突寄啼,我們使用GestureDetector就夠用了逮光,但是GestureDetector不是萬能的,必要時(shí)候需要自定義RawGestureDetector墩划;

另外一個(gè)很重要的概念涕刚,F(xiàn)lutter手勢(shì)事件是一個(gè)從內(nèi)Widget向外Widget的冒泡機(jī)制,假設(shè)內(nèi)外Widget同時(shí)監(jiān)聽豎直方向的拖拽事件onVerticalDragUpdate乙帮,往往都是內(nèi)層控件獲得事件杜漠,外層事件被動(dòng)取消;這樣的概念和Android父布局?jǐn)r截機(jī)制就完全不同了蚣旱;

雖然Flutter沒有外層攔截機(jī)制碑幅,但是似乎還有一線希望戴陡,那就是IgnorePointerAbsorbPointerWidget塞绿,這倆哥們可以忽略或者阻止子Widget樹不響應(yīng)Event;

手勢(shì)分析

基本原理介紹完了恤批,接下來分析案例交互异吻,上面說了我把整體布局拆分成了下拉控件和卡片控件,分析即刻App的拖拽的行為:當(dāng)下拉控件沒有展開下拉菜單時(shí),卡片控件是可以相應(yīng)上诀浪、左棋返、右三個(gè)方向的手勢(shì),下拉控件只相應(yīng)一個(gè)向下方向的手勢(shì)雷猪;當(dāng)下拉菜單展開時(shí)睛竣,卡片不能相應(yīng)任何手勢(shì),下拉控件可以相應(yīng)豎直方向的所有事件求摇;

image

上圖更加形象解釋兩種狀態(tài)下的手勢(shì)響應(yīng)射沟,下拉控件是父Widget,卡片控件是子Widget与境,由于子Widget能優(yōu)先響手勢(shì)验夯,所以在初始階段,我們不能讓子Widget響應(yīng)向下的手勢(shì)摔刁;

由于GestureDetector只封裝水平和豎直方向的手勢(shì)挥转,且兩種手勢(shì)不能同時(shí)使用,我們從GestureDetector源碼來看共屈,能不能封裝一個(gè)監(jiān)聽不同四個(gè)方向的手勢(shì)绑谣,;

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是一個(gè)map域仇,豎直方向的手勢(shì)在VerticalDragGestureRecognizer這個(gè)類;

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中卦方,我們只關(guān)注重寫的方法:

  • _hasSufficientPendingDragDeltaToAccept方法是關(guān)鍵邏輯蠕蚜,控制是否接受該拖拽手勢(shì)
  • _getDeltaForDetails返回拖拽進(jìn)度的dx、dy偏移量
  • _getPrimaryValueFromOffset返回單方向手勢(shì)value,不同方向(同時(shí)擁有水平和豎直)的可以傳null
  • _isFlingGesture是否該手勢(shì)的Fling行為

自定義DragGestureRecognizer

想實(shí)現(xiàn)接受三個(gè)方向的手勢(shì)脾歇,自定義DragGestureRecognizer是一個(gè)好的思路;我希望接受上从祝、下错邦、左、右四個(gè)方向的參數(shù)家坎,根據(jù)參數(shù)不同監(jiān)聽不同的手勢(shì)行為嘱能,照葫蘆畫瓢自定義一個(gè)接受方向的GestureRecognizer

DirectionGestureRecognizer

class DirectionGestureRecognizer extends _DragGestureRecognizer {
  int direction;
  //接受中途變動(dòng)
  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一份代碼出來虱疏,然后重寫主要的方法惹骂,根據(jù)不同入?yún)⑻幚聿煌氖謩?shì)邏輯;

注意事項(xiàng)

敲黑板了做瞪,在自定義DragGestureRecognizer時(shí):_getDeltaForDetails返回值表示dxdy的偏移量对粪,在只存在水平或者只存在豎直方向的情況下右冻,需要將另一個(gè)方向的dxdy置0;

當(dāng)前Widget樹有且只存在一個(gè)手勢(shì)時(shí)著拭,手勢(shì)判斷的邏輯_hasSufficientPendingDragDeltaToAccept可能不會(huì)被調(diào)用纱扭,這時(shí)候一定要重寫_getDeltaForDetails控制返回dxdy

如何使用

自定義的DirectionGestureRecognizer可以配置left儡遮、right乳蛾、updown四個(gè)方向的手勢(shì)鄙币,而且支持不同方向的組合屡久;

比如我們只想監(jiān)聽豎直向下方向,就創(chuàng)建DirectionGestureRecognizer(DirectionGestureRecognizer.down)的手勢(shì)識(shí)別爱榔;

想監(jiān)聽上被环、左、右的手勢(shì)详幽,創(chuàng)建DirectionGestureRecognizer(DirectionGestureRecognizer.left | DirectionGestureRecognizer.right | DirectionGestureRecognizer.up)的手勢(shì)識(shí)別筛欢;

DirectionGestureRecognizer就像一把磨刀石,刀已經(jīng)磨鋒利唇聘,砍材就很輕松了版姑,下面進(jìn)行控件的手勢(shì)實(shí)現(xiàn);

下拉控件手勢(shì)

PullDragWidget

_contentGestures = {
//向下的手勢(shì)
  DirectionGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>(
          () => DirectionGestureRecognizer(DirectionGestureRecognizer.down),
          (instance) {
    instance.onDown = _onDragDown;
    instance.onStart = _onDragStart;
    instance.onUpdate = _onDragUpdate;
    instance.onCancel = _onDragCancel;
    instance.onEnd = _onDragEnd;
  }),
  //點(diǎn)擊的手勢(shì)
  TapGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
          () => TapGestureRecognizer(), (instance) {
    instance.onTap = _onContentTap;
  })
};

Widget build(BuildContext context) {
  return RawGestureDetector(//返回RawGestureDetector
      behavior: HitTestBehavior.translucent,
      gestures: _contentGestures,//手勢(shì)在此
      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是一個(gè)RawGestureDetector用來監(jiān)聽手勢(shì)剥险,其中gestures支持向下拖拽和點(diǎn)擊兩個(gè)手勢(shì);當(dāng)下拉控件處于_opened狀態(tài)說header已經(jīng)拉下來宪肖,此時(shí)配合IgnorePointer表制,禁用子Widget所有的事件監(jiān)聽,自然內(nèi)部的卡片就相應(yīng)不了任何事件控乾;

卡片控件手勢(shì)

同下拉控件一樣么介,卡片控件只需要監(jiān)聽其余三個(gè)方向的手勢(shì),即可完成任務(wù):

CardStackWidget

_cardGestures = {
  DirectionGestureRecognizer://監(jiān)聽上左右三個(gè)方向
      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;
  })
};

手勢(shì)答疑

  • 為什么不用 onPanDown onPanUpdate onPanEnd 來拖動(dòng)蜕衡?

這是掘金評(píng)論提的問題壤短,我解答一下:在GestureDetector中有Pan手勢(shì)和Drag手勢(shì),這兩個(gè)手勢(shì)都能用處拖拽的場景慨仿,但不同的是Drag手勢(shì)僅限于水平豎直方向的監(jiān)聽久脯,Pan手勢(shì)不約束方向任意方向都能監(jiān)聽,除此之外觸發(fā)條件也不一致镰吆,Pan手勢(shì)的觸發(fā)條件是滑動(dòng)動(dòng)屏幕的距離distance大于kTouchSlop*2帘撰,Drag手勢(shì)的觸發(fā)條件是dx或者dy大于kTouchSlopdx鼎姊、dydistance形成勾股定理的三個(gè)邊長骡和;假設(shè)同樣在監(jiān)聽豎直滑動(dòng)這種場景,VerticalDrag總是比Pan先觸發(fā)相寇;如果下拉控件用VerticalDrag卡片控件用Pan慰于,下拉控件會(huì)優(yōu)先獲取向上的拖拽,卡片控件就會(huì)失去向上拖拽的機(jī)會(huì)唤衫,這就實(shí)現(xiàn)不了交互了婆赠,退一步即使Pan的觸發(fā)條件跟VerticalDrag一樣,由于Flutter的事件傳遞是從內(nèi)到外的佳励,這會(huì)導(dǎo)致外層下拉控件完全失去響應(yīng)機(jī)會(huì)休里。以上我的個(gè)人理解,如有誤導(dǎo)還請(qǐng)大佬評(píng)論指正赃承。

手勢(shì)小結(jié)

分析Flutter手勢(shì)冒泡的特性妙黍,父Widget既沒有響應(yīng)事件的優(yōu)先權(quán),也沒有監(jiān)聽單獨(dú)方向(left瞧剖、right 拭嫁、updown)的手勢(shì)抓于,只能自己想辦法自定義GestureRecognizer做粤,把原本VerticalHorizontal兩個(gè)方向的手勢(shì)識(shí)別擴(kuò)展成leftright 捉撮、up 怕品、down四個(gè)方向,區(qū)分開會(huì)產(chǎn)生沖突的手勢(shì)巾遭;

當(dāng)然也可能有其他的方案來實(shí)現(xiàn)該交互的手勢(shì)識(shí)別肉康,條條大路通羅馬,我只是拋磚引玉灼舍,大家有好的方案可以積極留言提出寶貴意見迎罗;

總結(jié)

知識(shí)點(diǎn)

由于篇幅有限并沒有介紹完該交互的所有內(nèi)容,深表遺憾片仿,總結(jié)歸納一下代碼中用到的知識(shí)點(diǎn):

  • Column纹安、RowExpanded砂豌、Stack厢岂、PositionedTransform等Widget的使用阳距;
  • GestureDetector塔粒、RawGestureDetectorIgnorePointer等Widget的使用筐摘;
  • 自定義GestureRecognizer實(shí)現(xiàn)自定義手勢(shì)識(shí)別卒茬;
  • AnimationController船老、Tween等動(dòng)畫的使用;
  • EventBus的使用圃酵;

最后

上面章節(jié)主要介紹在當(dāng)前場景下用Flutter布局和手勢(shì)的實(shí)戰(zhàn)技巧柳畔,其中更深層次手勢(shì)競技和分發(fā)的源碼級(jí)分析,有機(jī)會(huì)再做深入學(xué)習(xí)和分享郭赐;

另外本篇并不是循序漸進(jìn)的零基礎(chǔ)入門薪韩,對(duì)剛接觸的同學(xué)可能感覺有點(diǎn)懵,但是沒有關(guān)系捌锭,建議你clone一份代碼跑起來效果俘陷,沒準(zhǔn)就能提起自己學(xué)習(xí)的興趣;

最最后观谦,本篇所有代碼都是開源的拉盾,你的點(diǎn)贊是對(duì)我最大的鼓勵(lì)。

項(xiàng)目地址:https://github.com/HitenDev/FlutterDragCard

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末豁状,一起剝皮案震驚了整個(gè)濱河市盾剩,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌替蔬,老刑警劉巖告私,帶你破解...
    沈念sama閱讀 216,402評(píng)論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異承桥,居然都是意外死亡驻粟,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門凶异,熙熙樓的掌柜王于貴愁眉苦臉地迎上來蜀撑,“玉大人,你說我怎么就攤上這事剩彬】崧螅” “怎么了?”我有些...
    開封第一講書人閱讀 162,483評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵喉恋,是天一觀的道長沃饶。 經(jīng)常有香客問我,道長轻黑,這世上最難降的妖魔是什么糊肤? 我笑而不...
    開封第一講書人閱讀 58,165評(píng)論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮氓鄙,結(jié)果婚禮上馆揉,老公的妹妹穿的比我還像新娘。我一直安慰自己抖拦,他們只是感情好升酣,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評(píng)論 6 388
  • 文/花漫 我一把揭開白布舷暮。 她就那樣靜靜地躺著,像睡著了一般噩茄。 火紅的嫁衣襯著肌膚如雪下面。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,146評(píng)論 1 297
  • 那天巢墅,我揣著相機(jī)與錄音诸狭,去河邊找鬼券膀。 笑死君纫,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的芹彬。 我是一名探鬼主播蓄髓,決...
    沈念sama閱讀 40,032評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼舒帮!你這毒婦竟也來了会喝?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,896評(píng)論 0 274
  • 序言:老撾萬榮一對(duì)情侶失蹤玩郊,失蹤者是張志新(化名)和其女友劉穎肢执,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體译红,經(jīng)...
    沈念sama閱讀 45,311評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡预茄,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評(píng)論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了侦厚。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片耻陕。...
    茶點(diǎn)故事閱讀 39,696評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖刨沦,靈堂內(nèi)的尸體忽然破棺而出诗宣,到底是詐尸還是另有隱情,我是刑警寧澤想诅,帶...
    沈念sama閱讀 35,413評(píng)論 5 343
  • 正文 年R本政府宣布召庞,位于F島的核電站,受9級(jí)特大地震影響来破,放射性物質(zhì)發(fā)生泄漏裁眯。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評(píng)論 3 325
  • 文/蒙蒙 一讳癌、第九天 我趴在偏房一處隱蔽的房頂上張望穿稳。 院中可真熱鬧,春花似錦晌坤、人聲如沸逢艘。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽它改。三九已至疤孕,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間央拖,已是汗流浹背祭阀。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評(píng)論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留鲜戒,地道東北人专控。 一個(gè)月前我還...
    沈念sama閱讀 47,698評(píng)論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像遏餐,于是被迫代替她去往敵國和親伦腐。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容