GestureDetector

GestureDetector是一个用于手势识别的功能性组件,我们通过它可以来识别点击、双击、长按事件、拖动、缩放等手势。

GestureDetector实际上是指针事件的语义化封装,接下来我们详细介绍一下各种手势识别。

事件回调函数的参数

事件回调函数的参数一般是类,其常用属性:

  • globalPosition 相对于全局坐标(屏幕) 原点(左上角) 的偏移,处置方向,包括顶部的状态栏高度也算在内。
  • localPosition 相对于当前组件的偏移

点击事件

点击相关事件包括:

  • onTapDown:按下时回调。
  • onTapUp:抬起时回调。
  • onTap:点击事件回调。
  • onTapCancel:点击取消事件回调。

按下然后抬起调用顺序:onTapDown-> onTapUp-> onTap
按下后移动的调用顺序:onTapDown-> onTapCancel(这种情况下不在调用onTapUp和onTap。)

  1. //当前组件距全屏左上角边距,left: 10, top: 10; 顶部状态栏高度 24
  2. GestureDetector(
  3. child: Container(width: 300, height: 200, color: Colors.red),
  4. onTapDown: (TapDownDetails details) {
  5. print('onTapDown ${details.globalPosition}'); //Offset(11.1, 36.6)
  6. print('onTapDown ${details.localPosition}'); //Offset(1.1, 2.6)
  7. },
  8. onTapUp: (TapUpDetails tapUpDetails) {
  9. print('onTapUp');
  10. },
  11. onTap: () {
  12. print('onTap');
  13. },
  14. onTapCancel: () {
  15. print('onTapCancel');
  16. },
  17. ),

双击事件

双击是快速且连续2次在同一个位置点击,双击监听使用onDoubleTap方法。

当同时监听onTap和onDoubleTap事件时,只会触发一个事件,如果触发onTap事件,onTap将会延迟触发(延迟时间为系统判断是onDoubleTap事件的间隔),因为系统将会等待一段时间来判断是否为onDoubleTap事件,如果用户只监听了onTap事件则没有延迟。

  1. GestureDetector(
  2. child: Container(width: 300, height: 200, color: Colors.red),
  3. onDoubleTap: () => print('onDoubleTap'),
  4. //onTap: () => print('onTap'),
  5. ),

长按事件

长按事件(LongPress)包含长按开始、移动、抬起、结束事件,说明如下:

  • onLongPressStart:长按开始事件回调。
  • onLongPressMoveUpdate:长按移动事件回调。
  • onLongPressUp:长按抬起事件回调。
  • onLongPressEnd:长按结束事件回调。
  • onLongPress:长按事件回调。

用户按下 -> 移动 -> 抬起的过程调用顺序

onLongPressStart -> onLongPress -> onLongPressMoveUpdate -> … -> onLongPressMoveUpdate -> onLongPressEnd -> onLongPressUp

  1. GestureDetector(
  2. child: Container(width: 300, height: 200, color: Colors.red),
  3. onLongPressStart: (LongPressStartDetails details) {
  4. print('onLongPressStart');
  5. },
  6. onLongPressMoveUpdate: (LongPressMoveUpdateDetails v) {
  7. print('onLongPressMoveUpdate');
  8. },
  9. onLongPressUp: (){
  10. print('onLongPressUp');
  11. },
  12. onLongPressEnd: (LongPressEndDetails v) {
  13. print('onLongPressEnd');
  14. },
  15. onLongPress: () => print('onLongPress'),
  16. ),

水平/垂直拖动事件

对于拖动和滑动事件是没有区分的,他们本质上是一样的。

垂直/水平拖动事件包括按下、开始、移动更新、结束、取消事件,说明如下:

  • 任意方向拖动
    • onPanDown 拖动按下事件回调
    • onPanStart 拖动开始事件回调
    • onPanUpdate 指针移动更新事件回调
    • onPanEnd 拖动结束事件回调
    • onPanCancel 拖动取消事件回调
  • 只允许垂直方向拖动
    • onVerticalDragDown
    • onVerticalDragStart
    • onVerticalDragUpdate
    • onVerticalDragEnd
    • onVerticalDragCancel
  • 只允许水平方向拖动
    • onHorizontalDragDown
    • onHorizontalDragStart
    • onHorizontalDragUpdate
    • onHorizontalDragEnd
    • onHorizontalDragCancel

用户拖动调用顺序

onPanDown -> onPanStart -> onPanUpdate -> … -> onPanUpdate -> onPanEnd

  1. GestureDetector(
  2. child: Container(width: 300, height: 200, color: Colors.red),
  3. onVerticalDragStart: (v) => print('onVerticalDragStart'),
  4. onVerticalDragDown: (v) => print('onVerticalDragDown'),
  5. onVerticalDragUpdate: (v) => print('onVerticalDragUpdate'),
  6. onVerticalDragCancel: () => print('onVerticalDragCancel'),
  7. onVerticalDragEnd: (v) => print('onVerticalDragEnd'),
  8. ),

回调函数参数的其它属性
  • DragUpdateDetails.delta:当用户在屏幕上滑动时,会触发多次Update事件,delta指一次Update事件的滑动的偏移量。
  • DragEndDetails.velocity:该属性代表用户抬起手指时的滑动速度(包含x、y两个轴的),示例中并没有处理手指抬起时的速度,常见的效果是根据用户抬起手指时的速度做一个减速动画。

示例

fdtdttyddasdyrt.gif

  1. import "package:flutter/material.dart";
  2. import 'package:app1/coms/base/empty_null/empty_null.dart';
  3. class TestPage extends StatefulWidget {
  4. @override
  5. _TestPageState createState() => _TestPageState();
  6. }
  7. class _TestPageState extends State<TestPage> {
  8. double _top = 0.0; //距顶部的偏移
  9. double _left = 0.0; //距左边的偏移
  10. @override
  11. Widget build(BuildContext context) {
  12. return Scaffold(
  13. appBar: EmptyNull.appBar(),
  14. body: Stack(
  15. children: <Widget>[
  16. Positioned(
  17. top: _top,
  18. left: _left,
  19. child: GestureDetector(
  20. child: Container(width: 50, height: 50, color: Colors.red),
  21. onPanDown: (DragDownDetails e) {
  22. print("用户手指按下globalPosition:${e.globalPosition}"); //相对于屏幕的偏移位置
  23. print("用户手指按下localPosition:${e.localPosition}"); //相对于当前组件的偏移位置
  24. },
  25. //手指滑动时会触发此回调
  26. onPanUpdate: (DragUpdateDetails e) {
  27. // print(e.globalPosition);
  28. // print(e.localPosition);
  29. // print('${e.delta.dx}, ${e.delta.dy}'); //两次指针移动事件(PointerMoveEvent)的距离
  30. setState(() {
  31. _left += e.delta.dx;
  32. _top += e.delta.dy;
  33. });
  34. },
  35. onPanEnd: (DragEndDetails e) {
  36. print(e.velocity); //打印滑动结束时在x、y轴上的速度
  37. },
  38. ),
  39. )
  40. ],
  41. ),
  42. );
  43. }
  44. }

缩放事件

缩放(Scale)包含缩放开始、更新、结束。说明如下:

  • onScaleStart:缩放开始事件回调。
  • onScaleUpdate:缩放更新事件回调。
  • onScaleEnd:缩放结束事件回调。

缩放需要2个指针,对于手机就是2个手指进行缩放操作,调用顺序如下:

onScaleStart -> onScaleUpdate -> … -> onScaleUpdate -> onScaleEnd

回调参数定义
  1. ScaleUpdateDetails({
  2. this.focalPoint = Offset.zero,
  3. Offset? localFocalPoint,
  4. this.scale = 1.0,
  5. this.horizontalScale = 1.0,
  6. this.verticalScale = 1.0,
  7. this.rotation = 0.0,
  8. })
  1. GestureDetector(
  2. child: Container(width: 300, height: 200, color: Colors.red),
  3. onScaleStart: (v) => print('onScaleStart'),
  4. onScaleUpdate: (ScaleUpdateDetails v) => print('onScaleUpdate'),
  5. onScaleEnd: (ScaleEndDetails v) => print('onScaleEnd'),
  6. ),

bug: 缩放动手时,单根手指依旧可以触发onScaleUpdate回调,且返回缩放value值为1

注:产生新bug:重新再点击图片缩放,开始value值会变为1。

  1. import "package:flutter/material.dart";
  2. import 'package:app1/routes/application.dart';
  3. import 'package:app1/coms/base/empty_null/empty_null.dart';
  4. import 'package:flutter/cupertino.dart';
  5. int pointers = 0; //
  6. class TestPage extends StatefulWidget {
  7. @override
  8. _TestPageState createState() => _TestPageState();
  9. }
  10. class _TestPageState extends State<TestPage> {
  11. double _lastWidth = 200.0;
  12. double _width = 200.0;
  13. @override
  14. Widget build(BuildContext context) {
  15. Application.setupViewPort(context);
  16. return Scaffold(
  17. appBar: EmptyNull.appBar(),
  18. body: Center(
  19. child: Listener(
  20. onPointerDown: (event) => pointers++,
  21. onPointerUp: (event) => pointers--,
  22. child: GestureDetector(
  23. behavior: HitTestBehavior.opaque,
  24. child: Image.network(
  25. "https://images.h128.com/upload/202012/20/202012201302083641.jpg",
  26. width: _width,
  27. fit: BoxFit.cover,
  28. ),
  29. onScaleUpdate: (ScaleUpdateDetails details) {
  30. if (pointers > 1) {
  31. setState(() {
  32. var currentWidth = _lastWidth * details.scale;
  33. if (currentWidth >= 400) {
  34. currentWidth = 400;
  35. } else if (currentWidth <= 100) {
  36. currentWidth = 100;
  37. }
  38. _width = currentWidth;
  39. });
  40. }
  41. },
  42. onScaleEnd: (v) {
  43. _lastWidth = _width;
  44. },
  45. ),
  46. ),
  47. ),
  48. );
  49. }
  50. }

GestureRecognizer

GestureDetector内部是使用一个或多个GestureRecognizer来识别各种手势的,而GestureRecognizer的作用就是通过Listener来将原始指针事件转换为语义手势,GestureDetector直接可以接收一个子widget。GestureRecognizer是一个抽象类,一种手势的识别器对应一个GestureRecognizer的子类,Flutter实现了丰富的手势识别器,我们可以直接使用。

示例

假设我们要给一段富文本(RichText)的不同部分分别添加点击事件处理器,但是TextSpan并不是一个widget,这时我们不能用GestureDetector,但TextSpan有一个recognizer属性,它可以接收一个GestureRecognizer

假设我们需要在点击时给文本变色:
image.png

  1. import 'package:flutter/gestures.dart';
  2. import "package:flutter/material.dart";
  3. import 'package:app1/coms/base/empty_null/empty_null.dart';
  4. class TestPage extends StatefulWidget {
  5. @override
  6. _TestPageState createState() => _TestPageState();
  7. }
  8. class _TestPageState extends State<TestPage> {
  9. TapGestureRecognizer _tapGestureRecognizer = TapGestureRecognizer();
  10. bool _flag = false; //变色开关
  11. @override
  12. void dispose() {
  13. //用到GestureRecognizer的话一定要调用其dispose方法释放资源(主要是取消内部的计时器)
  14. _tapGestureRecognizer.dispose();
  15. super.dispose();
  16. }
  17. @override
  18. Widget build(BuildContext context) {
  19. return Scaffold(
  20. appBar: EmptyNull.appBar(),
  21. body: Text.rich(
  22. TextSpan(
  23. children: [
  24. TextSpan(text: '你好世界'),
  25. TextSpan(
  26. text: '点我变色',
  27. style: TextStyle(fontSize: 30, color: _flag ? Colors.red : Colors.grey),
  28. recognizer: _tapGestureRecognizer
  29. ..onTap = () {
  30. setState(() {
  31. _flag = !_flag;
  32. });
  33. }),
  34. TextSpan(text: "你好世界"),
  35. ],
  36. ),
  37. ),
  38. );
  39. }
  40. }

手势竞争与冲突

竞争

如果在上例中我们同时监听水平和垂直方向的拖动事件,那么我们斜着拖动时哪个方向会生效?实际上取决于第一次移动时两个轴上的位移分量,哪个轴的大,哪个轴在本次滑动事件竞争中就胜出。实际上Flutter中的手势识别引入了一个Arena的概念,Arena直译为“竞技场”的意思,每一个手势识别器(GestureRecognizer)都是一个“竞争者”(GestureArenaMember),当发生滑动事件时,他们都要在“竞技场”去竞争本次事件的处理权,而最终只有一个“竞争者”会胜出(win)。例如,假设有一个ListView,它的第一个子组件也是ListView,如果现在滑动这个子ListView,父ListView会动吗?答案是否定的,这时只有子ListView会动,因为这时子ListView会胜出而获得滑动事件的处理权。

示例

我们以拖动手势为例,同时识别水平和垂直方向的拖动手势,当用户按下手指时就会触发竞争(水平方向和垂直方向),一旦某个方向“获胜”,则直到当次拖动手势结束都会沿着该方向移动。代码如下:

  1. import 'package:flutter/gestures.dart';
  2. import "package:flutter/material.dart";
  3. import 'package:app1/routes/application.dart';
  4. import 'package:app1/coms/base/empty_null/empty_null.dart';
  5. import 'package:flutter/cupertino.dart';
  6. class TestPage extends StatefulWidget {
  7. @override
  8. _TestPageState createState() => _TestPageState();
  9. }
  10. class _TestPageState extends State<TestPage> {
  11. double _top = 0.0;
  12. double _left = 0.0;
  13. @override
  14. Widget build(BuildContext context) {
  15. Application.setupViewPort(context);
  16. return Scaffold(
  17. appBar: EmptyNull.appBar(),
  18. body: Stack(
  19. children: [
  20. Positioned(
  21. top: _top,
  22. left: _left,
  23. child: GestureDetector(
  24. child: Container(width: 50, height: 50, color: Colors.red),
  25. //垂直方向拖动事件
  26. onVerticalDragUpdate: (DragUpdateDetails details) {
  27. setState(() {
  28. _top += details.delta.dy;
  29. });
  30. },
  31. onHorizontalDragUpdate: (DragUpdateDetails details) {
  32. setState(() {
  33. _left += details.delta.dx;
  34. });
  35. },
  36. ),
  37. ),
  38. ],
  39. ),
  40. );
  41. }
  42. }

此示例运行后,每次拖动只会沿一个方向移动(水平或垂直),而竞争发生在手指按下后首次移动(move)时,此例中具体的“获胜”条件是:首次移动时的位移在水平和垂直方向上的分量大的一个获胜。

冲突

由于手势竞争最终只有一个胜出者,所以,当有多个手势识别器时,可能会产生冲突。假设有一个widget,它可以左右拖动,现在我们也想检测在它上面手指按下和抬起的事件,代码如下:

  1. class GestureConflictTestRouteState extends State<GestureConflictTestRoute> {
  2. double _left = 0.0;
  3. @override
  4. Widget build(BuildContext context) {
  5. return Stack(
  6. children: <Widget>[
  7. Positioned(
  8. left: _left,
  9. child: GestureDetector(
  10. child: CircleAvatar(child: Text("A")), //要拖动和点击的widget
  11. onHorizontalDragUpdate: (DragUpdateDetails details) {
  12. setState(() {
  13. _left += details.delta.dx;
  14. });
  15. },
  16. onHorizontalDragEnd: (details){
  17. print("onHorizontalDragEnd");
  18. },
  19. onTapDown: (details){
  20. print("down");
  21. },
  22. onTapUp: (details){
  23. print("up");
  24. },
  25. ),
  26. )
  27. ],
  28. );
  29. }
  30. }

现在我们按住圆形“A”拖动然后抬起手指,控制台日志如下:

  1. I/flutter (17539): down
  2. I/flutter (17539): onHorizontalDragEnd

我们发现没有打印”up”,这是因为在拖动时,刚开始按下手指时在没有移动时,拖动手势还没有完整的语义,此时TapDown手势胜出(win),此时打印”down”,而拖动时,拖动手势会胜出,当手指抬起时,onHorizontalDragEndonTapUp发生了冲突,但是因为是在拖动的语义中,所以onHorizontalDragEnd胜出,所以就会打印 “onHorizontalDragEnd”。如果我们的代码逻辑中,对于手指按下和抬起是强依赖的,比如在一个轮播图组件中,我们希望手指按下时,暂停轮播,而抬起时恢复轮播,但是由于轮播图组件中本身可能已经处理了拖动手势(支持手动滑动切换),甚至可能也支持了缩放手势,这时我们如果在外部再用onTapDownonTapUp来监听的话是不行的。这时我们应该怎么做?其实很简单,通过Listener监听原始指针事件就行:

  1. Positioned(
  2. top:80.0,
  3. left: _leftB,
  4. child: Listener(
  5. onPointerDown: (details) {
  6. print("down");
  7. },
  8. onPointerUp: (details) {
  9. //会触发
  10. print("up");
  11. },
  12. child: GestureDetector(
  13. child: CircleAvatar(child: Text("B")),
  14. onHorizontalDragUpdate: (DragUpdateDetails details) {
  15. setState(() {
  16. _leftB += details.delta.dx;
  17. });
  18. },
  19. onHorizontalDragEnd: (details) {
  20. print("onHorizontalDragEnd");
  21. },
  22. ),
  23. ),
  24. )

手势冲突只是手势级别的,而手势是对原始指针的语义化的识别,所以在遇到复杂的冲突场景时,都可以通过Listener直接识别原始指针事件来解决冲突。