Flutter最近比较热门,但是Flutter成体系的文章并不多,前期避免不了踩坑;我这篇文章主要介绍如何使用Flutter实现一个比较复杂的手势交互,顺便分享一下我在使用Flutter过程中遇到的一些小坑,减少大家入坑;

先睹为快

本项目支持ios&android运行,效果如下

           

对了,顺便分享一下生成gif的小窍门,建议用手机自带录屏功能导出mp4文件到电脑,然后电脑端用ffmpeg命令行处理,控制gif的质量和文件大小,我的建议是分辨率控制在270p,帧率在10左右;

交互分析

看文章的小伙伴最好能手持即刻App,亲自体验一下探索页的交互,是×××Logo×××主题色的即刻;有人简称‘黄即’;



即刻App原版功能有卡片旋转,卡片撤回和卡片自动移除,时间关系我暂时没有去实现,但是核心功能一点都不会砍;

以我Android开发习惯来看,交互分为可拆分内外两层,外层我们需要一个整体下拉的控件,内层我们需要实现一个上、下、左、右四方向拖拽移动的控件,我们称为卡片控件;同时这两层还需要处理子Widget的布局,再看细节:

下拉控件:

  • 子控件从上到下竖直摆放,顶部菜单默认隐藏在屏幕外

  • 下拉手势所有子控件下移,菜单视觉差效果

  • 支持点击自动展开、收起效果

卡片控件

  • 卡片层叠布局,错落有致

  • 最上层卡片支持手势拖拽

  • 其他卡片相应拖拽小幅位移

  • 松手移除卡片

码上入手

热身

套用App开发伎俩,实现上面的交互无非就是控件布局和手势识别。当然在Flutter中也跑不掉这两点,在Flutter中常用的基本布局有ColumnRowStack等,手势识别有ListenerGestureDetectorRawGestureDetector等,本文的讲解不限于上面这几个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,我想到两种方案:

  1. 采用外层Transform包裹整个布局,内层Transform包裹header,然后赋值内层dy = -headerHeight,随着手势下拉动态,并不改变headerTransform,而是改变最外层Transformdy

  2. 动态改变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的基本用法,topbottomheight控制高度和位置,而且两两配合使用,topbottom可以理解成marginTop和marginBottom,height顾名思义是直接Widget的高度,如果top配置bottom,意味着高度等于parentHeight-top-bottom,如果top/bottom配合height使用,高度一般是固定的,当然topbottom是接受负数的;

再分析代码,首先_offsetY是下拉距离,是一个改变的量初始值为0,content需要设置top = _offsetYbottom = -_offsetY,改变的是上下位置,高度不会改变;同理,header是采用topheight控制,高度固定,只需要动态改变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手势识别最常用的是ListenerGestureDetector这两个Widget,其中Listener主要针对原始触摸点进行处理,GestureDetector已经对原始触摸点加工成了不同的手势;这两个类的方法介绍如下;

Listener

Listener({   Key key,   this.onPointerDown, //手指按下回调   this.onPointerMove, //手指移动回调   this.onPointerUp,//手指抬起回调   this.onPointerCancel,//触摸事件取消回调   this.behavior = HitTestBehavior.deferToChild, //在命中测试期间如何表现   Widget child }) 复制代码

GestureDetector手势回调:

Property/CallbackDescription
onTapDown用户每次和屏幕交互时都会被调用
onTapUp用户停止触摸屏幕时触发
onTap短暂触摸屏幕时触发
onTapCancel用户触摸了屏幕,但是没有完成Tap的动作时触发
onDoubleTap用户在短时间内触摸了屏幕两次
onLongPress用户触摸屏幕时间超过500ms时触发
onVerticalDragDown当一个触摸点开始跟屏幕交互,同时在垂直方向上移动时触发
onVerticalDragStart当触摸点开始在垂直方向上移动时触发
onVerticalDragUpdate屏幕上的触摸点位置每次改变时,都会触发这个回调
onVerticalDragEnd当用户停止移动,这个拖拽操作就被认为是完成了,就会触发这个回调
onVerticalDragCancel用户突然停止拖拽时触发
onHorizontalDragDown当一个触摸点开始跟屏幕交互,同时在水平方向上移动时触发
onHorizontalDragStart当触摸点开始在水平方向上移动时触发
onHorizontalDragUpdate屏幕上的触摸点位置每次改变时,都会触发这个回调
onHorizontalDragEnd水平拖拽结束时触发
onHorizontalDragCancelonHorizontalDragDown没有成功完成时触发
onPanDown当触摸点开始跟屏幕交互时触发
onPanStart当触摸点开始移动时触发
onPanUpdate屏幕上的触摸点位置每次改变时,都会触发这个回调
onPanEndpan操作完成时触发
onScaleStart触摸点开始跟屏幕交互时触发,同时会建立一个焦点为1.0
onScaleUpdate跟屏幕交互时触发,同时会标示一个新的焦点
onScaleEnd触摸点不再跟屏幕有任何交互,同时也表示这个scale手势完成

ListenerGestureDetector如何抉择,首先GestureDetector是基于Listener封装,它解决了大部分手势冲突,我们使用GestureDetector就够用了,但是GestureDetector不是万能的,必要时候需要自定义RawGestureDetector

另外一个很重要的概念,Flutter手势事件是一个从内Widget向外Widget的冒泡机制,假设内外Widget同时监听竖直方向的拖拽事件onVerticalDragUpdate,往往都是内层控件获得事件,外层事件被动取消;这样的概念和Android父布局拦截机制就完全不同了;

虽然Flutter没有外层拦截机制,但是似乎还有一线希望,那就是IgnorePointerAbsorbPointerWidget,这俩哥们可以忽略或者阻止子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

想实现接受三个方向的手势,自定义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可以配置leftrightupdown四个方向的手势,而且支持不同的组合;

比如我们只想监听竖直向下方向,就创建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,把原本VerticalHorizontal两个方向的手势识别扩展成leftrightupdown四个方向,分开监听可能会冲突的手势;当然也可能有其他的方案来实现手势的监听,希望大家能提出宝贵意见;

总结

知识点

由于篇幅有限并没有完全介绍该交互的所有内容,我归纳一下代码中用到的知识点:

  • ColumnRowExpandedStackPositionedTransform等Widget;

  • GestureDetectorRawGestureDetectorIgnorePointer等Widget;

  • 自定义GestureRecognizer实现自定义手势识别;

  • AnimationControllerTween等动画的使用;

  • EventBus的使用;

最后

上面章节主要介绍在当前场景下用Flutter布局和手势的实战技巧,其中更深层次手势竞技和分发的源码级分析,有机会再做深入学习和分享;

另外本篇并不是循序渐进的零基础入门,对刚接触的同学可能感觉有点懵,但是没有关系,建议你clone一份代码跑起来效果,没准就能提起自己学习的兴趣;

最最后,本篇所有代码都是开源的,你的点赞是对我最大的鼓励。