内容简介:Flutter最近比较热门,但是Flutter成体系的文章并不多,前期避免不了踩坑;我这篇文章主要介绍如何使用Flutter实现一个比较复杂的手势交互,顺便分享一下我在使用Flutter过程中遇到的一些小坑,减少大家入坑;对了,顺便分享一下生成看文章的小伙伴最好能手持即刻App,亲自体验一下探索页的交互,是黄色Logo黄色主题色的即刻;有人简称‘黄即’;
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
一份代码跑起来效果,没准就能提起自己学习的兴趣;
最最后,本篇所有代码都是开源的,你的点赞是对我最大的鼓励。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。