头图

​哈喽,我是老刘

几年前分享了一篇GestureDetector嵌套ListView的文章

Flutter多控件滑动事件联动(滑动冲突处理)

由于文章中只给出了关键部位的代码,另外使用的技术也偏底层

所以很多同学私信我要完整的源码

这里把原先的方案整理一下,另外也给出完整的代码供大家参考

我们先一点一点来看这个问题

滑动关闭组件

首先大家一定在各种App中见过这个滑动关闭组件
down1 00_00_00-00_00_30.gif

就是手指向下滑动,组件跟随手指移动

手指抬起后组件滑出屏幕

我们先来实现这个组件,然后来讨论一下如果组件中的内容时一个ListView,要怎么处理

如果这个组件的内容时固定内容,不是ListView这样的可滚动组件

实现起来其实很简单

我这里直接放源码

import 'package:flutter/material.dart';

class CloseOnSwipeDownWidget extends StatefulWidget {
  final Widget child;

  const CloseOnSwipeDownWidget({
    Key? key,
    required this.child,
  }) : super(key: key);

  @override
  CloseOnSwipeDownWidgetState createState() => CloseOnSwipeDownWidgetState();
}

class CloseOnSwipeDownWidgetState extends State
    with TickerProviderStateMixin {
  double yOffset = 0.0;
  double initialPosition = 0.0;
  bool isAnimatingOut = false;
  int animTime = 0;

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onVerticalDragDown: (details) {
        initialPosition = details.globalPosition.dy;
      },
      onVerticalDragUpdate: (details) {
        double updatedPosition = details.globalPosition.dy;
        double deltaY = updatedPosition - initialPosition;

        animTime = 0;

        setState(() {
          yOffset = yOffset + deltaY;
          initialPosition = updatedPosition;
        });
      },
      onVerticalDragEnd: (details) {
        animTime = 300;

        if (yOffset > 200) {
          // 触发滑出动画
          _startSlideOutAnimation();
        } else {
          // 触发返回原始位置的动画
          _startReturnToOriginalPositionAnimation();
        }
      },
      child: Stack(
        children: [
          AnimatedPositioned(  // 组件跟随手指位移,以及抬起手指后组件移动动画
            duration: Duration(milliseconds: animTime),
            curve: Curves.easeInOut,
            top: yOffset,
            left: 0,
            right: 0,
            child: widget.child,
            onEnd: () {
              if(isAnimatingOut) {
                Navigator.of(context).pop();
              }
            },
          ),
        ],
      ),
    );
  }

  // 开始滑出动画
  void _startSlideOutAnimation() {
    setState(() {
      isAnimatingOut = true;
      yOffset = MediaQuery.of(context).size.height;
    });
  }

  // 开始返回原始位置的动画
  void _startReturnToOriginalPositionAnimation() {
    setState(() {
      yOffset = 0.0;
    });
  }
}

这里的原理很简单

就是通过GestureDetector检测用户的滑动行为

并且通过AnimatedPositioned将用户手指的每一段位移转换成整个组件的移动

并且在最终手指抬起时,通过AnimatedPositioned的动画效果让组件移出屏幕或者复位

我们写一个页面来使用这个组件

class TestPage extends StatelessWidget {
  const TestPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: CloseOnSwipeDownWidget(
        child: Column(
          children: [
            SizedBox(
              height: MediaQuery.of(context).size.height - 500,
            ), // 让组件内容在页面底部
            Container(
              height: 500,
              color: Colors.blue,
              alignment: Alignment.bottomCenter,
              child: const Center(
                child: Text('我是内容'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

看一下效果
Video_2024-01-29_133117~1.gif
那么现在如果把传入的内容换成一个ListView
你会发现ListView内部的内容可以正常滑动
但是外部的GestureDetector无法响应用户的手势了
我们下面就来解决这个问题

GestureDetector嵌套ListView

首先我们要知道为什么嵌入ListView后GestureDetector会失效

这是Flutter的竞技场机制导致的

用户的一个滑动行为其实在底层时通过down、move和up三种事件完成的

当一个down事件出现后,如果手指按下的坐标位置有多个组件可以响应滑动事件

就是我们目前例子中的GestureDetector嵌套ListView的场景

Flutter框架会将这些组件都加入竞技场

然后通过一定的逻辑选择一个组件胜出

通常同类组件嵌套时最内层的组件胜出

胜出的组件会处理接下来的move和up事件,其它组件则不会继续处理这些事件了

在GestureDetector嵌套ListView的场景中

ListView最终胜出,所以后续的事件都交由ListView处理

而GestureDetector收不到后续的事件,也就不会响应用户的手势了

因此,我们解决这个问题的第一步就是要让GestureDetector在这种场景下也能收到后续的事件

决胜竞技场

其实要做到这一步很简单

GestureDetector真正处理用户手势事件的是内部的Recognizer

比如处理上下滑动的是VerticalDragGestureRecognizer

而Recognizer在竞技场失败后也可以单方面宣布自己胜出

这样即使在竞技场失败了,GestureDetector也能收到后续的手势事件

因此我们现定义一个单方面宣布胜出的Recognizer

​
class _MyVerticalDragGestureRecognizer extends VerticalDragGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    // 单方面宣布自己胜出
    acceptGesture(pointer);
  }
}

​

接下来,把这个Recognizer加入到GestureDetector中

这时就需要用到一个GestureDetector的底层组件RawGestureDetector

通过它我们可以自己指定需要的Recognizer

​
RawGestureDetector(
      gestures: {
        _MyVerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                _MyVerticalDragGestureRecognizer>(
            () => _MyVerticalDragGestureRecognizer(),
            (_MyVerticalDragGestureRecognizer recognizer) {
          recognizer
            ..onStart = (DragStartDetails details) {
              
            }
            ..onUpdate = (DragUpdateDetails details) {
              
            }
            ..onEnd = (DragEndDetails details) {
             
            };
        }),
      },
      child: ...
    );

​

这其中的onStart、onUpdate和onEnd 方法

就对应了GestureDetector中的onVerticalDragDown、onVerticalDragUpdate和onVerticalDragEnd方法

好的,到目前为止,我们已经解决了竞技场造成的只有ListView能收到手势事件的问题

但是这样的话就会造成用户滑动,内外两层组件都在移动的问题

因此,接下来我们就来解决如何两个滑动组件如何相互配合

监听ListView的滚动

ListView是ScrollView的子类

所有的ScrollView都会在滚动过程中沿着组件树向上发出各种滚动状态变化的通知

通过监听这些通知事件,就可以判断ScrollView的滚动状态

​
NotificationListener(  // 监听内部ListView的滑动变化
              onNotification: (ScrollNotification notification) {
                if (notification is OverscrollNotification && notification.overscroll < 0) {
                  // 用户向下滑动,ListView已经滑动到顶部,处理GestureDetector的滑动事件
                } else if (notification is ScrollUpdateNotification) {
                  // 用户在ListView中执行滑动动作,关闭外部GestureDetector的滑动处理
                } else {
                  
                }

                return false;
              },
              child:  //ListView
            ),

​

好的,把这些组合起来,完整的代码如下

​
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

class CloseOnSwipeDownWidget2 extends StatefulWidget {
  final Widget child;

  const CloseOnSwipeDownWidget2({
    Key? key,
    required this.child,
  }) : super(key: key);

  @override
  CloseOnSwipeDownWidget2State createState() => CloseOnSwipeDownWidget2State();
}

class CloseOnSwipeDownWidget2State extends State
    with TickerProviderStateMixin {
  double yOffset = 0.0;
  double initialPosition = 0.0;
  bool isAnimatingOut = false;
  int animTime = 0;
  bool needDrag = true;

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: {
        _MyVerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                _MyVerticalDragGestureRecognizer>(
            () => _MyVerticalDragGestureRecognizer(),
            (_MyVerticalDragGestureRecognizer recognizer) {
          recognizer
            ..onStart = (DragStartDetails details) {
              initialPosition = details.globalPosition.dy;
            }
            ..onUpdate = (DragUpdateDetails details) {
              if (!needDrag) {
                return;
              }
              double updatedPosition = details.globalPosition.dy;
              double deltaY = updatedPosition - initialPosition;

              animTime = 0;

              setState(() {
                yOffset = yOffset + deltaY;
                initialPosition = updatedPosition;
              });
            }
            ..onEnd = (DragEndDetails details) {
              animTime = 300;

              if (yOffset > 200) {
                // 触发滑出动画
                _startSlideOutAnimation();
              } else {
                // 触发返回原始位置的动画
                _startReturnToOriginalPositionAnimation();
              }
            };
        }),
      },
      child: Stack(
        children: [
          AnimatedPositioned(
            duration: Duration(milliseconds: animTime),
            curve: Curves.easeInOut,
            top: yOffset,
            left: 0,
            right: 0,
            child: NotificationListener(  // 监听内部ListView的滑动变化
              onNotification: (ScrollNotification notification) {
                if (notification is OverscrollNotification && notification.overscroll < 0) {
                  // 用户向下滑动,ListView已经滑动到顶部,处理GestureDetector的滑动事件
                  needDrag = true;
                } else if (notification is ScrollUpdateNotification) {
                  // 用户在ListView中执行滑动动作,关闭外部GestureDetector的滑动处理
                  needDrag = false;
                } else {
                }

                return false;
              },
              child: widget.child,
            ),
            onEnd: () {
              if (isAnimatingOut) {
                Navigator.of(context).pop();
              }
            },
          ),
        ],
      ),
    );
  }

  // 开始滑出动画
  void _startSlideOutAnimation() {
    setState(() {
      isAnimatingOut = true;
      yOffset = MediaQuery.of(context).size.height;
    });
  }

  // 开始返回原始位置的动画
  void _startReturnToOriginalPositionAnimation() {
    setState(() {
      yOffset = 0.0;
    });
  }
}

class _MyVerticalDragGestureRecognizer extends VerticalDragGestureRecognizer {
  bool needDrag = true;

  @override
  void rejectGesture(int pointer) {
    // 单方面宣布自己胜出
    acceptGesture(pointer);
  }
}

​

简单来说就是通过needDrag来判断外部GestureDetector是否跟随用户手势移动

needDrag的值基于监听ListView的状态

当ListView已经滑动到顶部,就开始响应用户的手势动作

下面是使用这个组件的代码

​
class TestPage2 extends StatelessWidget {
  const TestPage2({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: CloseOnSwipeDownWidget2(
        child: Column(
          children: [
            SizedBox(
              height: MediaQuery.of(context).size.height - 500,
            ), // 让组件内容在页面底部
            Container(
              height: 500,
              color: Colors.blue,
              child: ListView.builder(
                  itemCount: 20,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text('index $index'),
                    );
                  }),
            ),
          ],
        ),
      ),
    );
  }
}

​

实现效果如下
drag_close2.gif

好了,关于手势组件嵌套的问题就先聊到这里

如果看到这里的同学有学习Flutter的兴趣,欢迎联系老刘,我们互相学习。

点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。

可以作为Flutter学习的知识地图。

覆盖90%开发场景的《Flutter开发手册》


程序员老刘
1 声望2 粉丝

客户端架构师,客户端团队负责人。一个月带领客户端团队从0基础迁移到Flutter 。目前团队已使用Flutter五年。