注册

Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套

这次的 Flutter 小技巧是 ListViewPageView 的花式嵌套,不同 Scrollable 的嵌套冲突问题相信大家不会陌生,今天就通过 ListViewPageView 的三种嵌套模式带大家收获一些不一样的小技巧。

正常嵌套

最常见的嵌套应该就是横向 PageView 加纵向 ListView 的组合,一般情况下这个组合不会有什么问题,除非你硬是要斜着滑

最近刚好遇到好几个人同时在问:“斜滑 ListView 容易切换到 PageView 滑动” 的问题,如下 GIF 所示,当用户在滑动 ListView 时,滑动角度带上倾斜之后,可能就会导致滑动的是 PageView 而不是 ListView

c8a4bb3354c24a26956eaaf8c17614fa~tplv-k3u1fbpfcp-zoom-in-crop-mark:3024:0:0:0.awebp

虽然从我个人体验上并不觉得这是个问题,但是如果产品硬是要你修改,难道要自己重写 PageView 的手势响应吗?

我们简单看一下,不管是 PageView 还是 ListView 它们的滑动效果都来自于 Scrollable ,而 Scrollable 内部针对不同方向的响应,是通过 RawGestureDetector 完成:

  • VerticalDragGestureRecognizer 处理垂直方向的手势

  • HorizontalDragGestureRecognizer 处理水平方向的手势

所以简单看它们响应的判断逻辑,可以看到一个很有趣的方法 computeHitSlop根据 pointer 的类型确定当然命中需要的最小像素,触摸默认是 kTouchSlop (18.0)

image-20220613103745974

看到这你有没有灵光一闪:如果我们把 PageView 的 touchSlop 修改了,是不是就可以调整它响应的灵敏度? 恰好在 computeHitSlop 方法里,它可以通过 DeviceGestureSettings 来配置,而 DeviceGestureSettings 来自于 MediaQuery ,所以如下代码所示:

body: MediaQuery(
 ///调高 touchSlop 到 50 ,这样 pageview 滑动可能有点点影响,
 ///但是大概率处理了斜着滑动触发的问题
 data: MediaQuery.of(context).copyWith(
     gestureSettings: DeviceGestureSettings(
   touchSlop: 50,
)),
 child: PageView(
   scrollDirection: Axis.horizontal,
   pageSnapping: true,
   children: [
     HandlerListView(),
     HandlerListView(),
  ],
),
),

小技巧一:通过嵌套一个 MediaQuery ,然后调整 gestureSettingstouchSlop 从而修改 PageView 的灵明度 ,另外不要忘记,还需要把 ListViewtouchSlop 切换会默认 的 kTouchSlop

class HandlerListView extends StatefulWidget {
 @override
 _MyListViewState createState() => _MyListViewState();
}
class _MyListViewState extends State<HandlerListView> {
 @override
 Widget build(BuildContext context) {
   return MediaQuery(
     ///这里 touchSlop 需要调回默认
     data: MediaQuery.of(context).copyWith(
         gestureSettings: DeviceGestureSettings(
       touchSlop: kTouchSlop,
    )),
     child: ListView.separated(
       itemCount: 15,
       itemBuilder: (context, index) {
         return ListTile(
           title: Text('Item $index'),
        );
      },
       separatorBuilder: (context, index) {
         return const Divider(
           thickness: 3,
        );
      },
    ),
  );
}
}

最后我们看一下效果,如下 GIF 所示,现在就算你斜着滑动,也很触发 PageView 的水平滑动,只有横向移动时才会触发 PageView 的手势,当然, 如果要说这个粗暴的写法有什么问题的话,大概就是降低了 PageView 响应的灵敏度

xiehuabudong

同方向 PageView 嵌套 ListView

介绍完常规使用,接着来点不一样的,在垂直切换的 PageView 里嵌套垂直滚动的 ListView , 你第一感觉是不是觉得不靠谱,为什么会有这样的场景?

对于产品来说,他们不会考虑你如何实现的问题,他们只会拍着脑袋说淘宝可以,为什么你不行,所以如果是你,你会怎么做?

而关于这个需求,社区目前讨论的结果是:PageViewListView 的滑动禁用,然后通过 RawGestureDetector 自己管理

如果对实现逻辑分析没兴趣,可以直接看本小节末尾的 源码链接

看到自己管理先不要慌,虽然要自己实现 PageViewListView 的手势分发,但是其实并不需要重写 PageViewListView ,我们可以复用它们的 Darg 响应逻辑,如下代码所示:

  • 通过 NeverScrollableScrollPhysics 禁止了 PageViewListView 的滚动效果

  • 通过顶部 RawGestureDetectorVerticalDragGestureRecognizer 自己管理手势事件

  • 配置 PageControllerScrollController 用于获取状态

body: RawGestureDetector(
 gestures: <Type, GestureRecognizerFactory>{
   VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
           VerticalDragGestureRecognizer>(
      () => VerticalDragGestureRecognizer(),
      (VerticalDragGestureRecognizer instance) {
     instance
      ..onStart = _handleDragStart
      ..onUpdate = _handleDragUpdate
      ..onEnd = _handleDragEnd
      ..onCancel = _handleDragCancel;
  })
},
 behavior: HitTestBehavior.opaque,
 child: PageView(
   controller: _pageController,
   scrollDirection: Axis.vertical,
   ///屏蔽默认的滑动响应
   physics: const NeverScrollableScrollPhysics(),
   children: [
     ListView.builder(
       controller: _listScrollController,
       ///屏蔽默认的滑动响应
       physics: const NeverScrollableScrollPhysics(),
       itemBuilder: (context, index) {
         return ListTile(title: Text('List Item $index'));
      },
       itemCount: 30,
    ),
     Container(
       color: Colors.green,
       child: Center(
         child: Text(
           'Page View',
           style: TextStyle(fontSize: 50),
        ),
      ),
    )
  ],
),
),

接着我们看 _handleDragStart 实现,如下代码所示,在产生手势 details 时,我们主要判断:

  • 通过 ScrollController 判断 ListView 是否可见

  • 判断触摸位置是否在 ListIView 范围内

  • 根据状态判断通过哪个 Controller 去生产 Drag 对象,用于响应后续的滑动事件

  void _handleDragStart(DragStartDetails details) {

   if (_listScrollController?.hasClients == true &&
       _listScrollController?.position.context.storageContext != null) {
     ///获取 ListView 的 renderBox
     final RenderBox? renderBox = _listScrollController
         ?.position.context.storageContext
        .findRenderObject() as RenderBox;

     if (renderBox?.paintBounds
            .shift(renderBox.localToGlobal(Offset.zero))
            .contains(details.globalPosition) ==
         true) {
       _activeScrollController = _listScrollController;
       _drag = _activeScrollController?.position.drag(details, _disposeDrag);
       return;
    }
  }

   ///这时候就可以认为是 PageView 需要滑动
   _activeScrollController = _pageController;
   _drag = _pageController?.position.drag(details, _disposeDrag);
}

前面我们主要在触摸开始时,判断需要响应的对象时ListView 还是 PageView ,然后通过 _activeScrollController 保存当然响应对象,并且通过 Controller 生成用于响应手势信息的 Drag 对象。

简单说:滑动事件发生时,默认会建立一个 Drag 用于处理后续的滑动事件,Drag 会对原始事件进行加工之后再给到 ScrollPosition 去触发后续滑动效果。

接着在 _handleDragUpdate 方法里,主要是判断响应是不是需要切换到 PageView:

  • 如果不需要就继续用前面得到的 _drag?.update(details)响应 ListView 滚动

  • 如果需要就通过 _pageController 切换新的 _drag 对象用于响应

void _handleDragUpdate(DragUpdateDetails details) {
 if (_activeScrollController == _listScrollController &&

     ///手指向上移动,也就是快要显示出底部 PageView
     details.primaryDelta! < 0 &&

     ///到了底部,切换到 PageView
     _activeScrollController?.position.pixels ==
         _activeScrollController?.position.maxScrollExtent) {
   ///切换相应的控制器
   _activeScrollController = _pageController;
   _drag?.cancel();

   ///参考 Scrollable 里
   ///因为是切换控制器,也就是要更新 Drag
   ///拖拽流程要切换到 PageView 里,所以需要 DragStartDetails
   ///所以需要把 DragUpdateDetails 变成 DragStartDetails
   ///提取出 PageView 里的 Drag 相应 details
   _drag = _pageController?.position.drag(
       DragStartDetails(
           globalPosition: details.globalPosition,
           localPosition: details.localPosition),
       _disposeDrag);
}
 _drag?.update(details);
}

这里有个小知识点:如上代码所示,我们可以简单通过 details.primaryDelta 判断滑动方向和移动的是否是主轴

最后如下 GIF 所示,可以看到 PageView 嵌套 ListView 同方向滑动可以正常运行了,但是目前还有个两个小问题,从图示可以看到:

  • 在切换之后 ListView 的位置没有保存下来

  • 产品要求去除 ListView 的边缘溢出效果

7777777777777

所以我们需要对 ListView 做一个 KeepAlive ,然后用简单的方法去除 Android 边缘滑动的 Material 效果:

  • 通过 with AutomaticKeepAliveClientMixinListView 在切换之后也保持滑动位置

  • 通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Scrollable 的边缘 Material 效果

child: PageView(
 controller: _pageController,
 scrollDirection: Axis.vertical,
 ///去掉 Android 上默认的边缘拖拽效果
 scrollBehavior:
     ScrollConfiguration.of(context).copyWith(overscroll: false),


///对 PageView 里的 ListView 做 KeepAlive 记住位置
class KeepAliveListView extends StatefulWidget {
 final ScrollController? listScrollController;
 final int itemCount;

 KeepAliveListView({
   required this.listScrollController,
   required this.itemCount,
});

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

class KeepAliveListViewState extends State<KeepAliveListView>
   with AutomaticKeepAliveClientMixin {
 @override
 Widget build(BuildContext context) {
   super.build(context);
   return ListView.builder(
     controller: widget.listScrollController,

     ///屏蔽默认的滑动响应
     physics: const NeverScrollableScrollPhysics(),
     itemBuilder: (context, index) {
       return ListTile(title: Text('List Item $index'));
    },
     itemCount: widget.itemCount,
  );
}

 @override
 bool get wantKeepAlive => true;
}

所以这里我们有解锁了另外一个小技巧:通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Android 滑动到边缘的 Material 2效果,为什么说 Material2, 因为 Material3 上变了,具体可见: Flutter 3 下的 ThemeExtensions 和 Material3

000000000

本小节源码可见: github.com/CarGuo/gsy_…

同方向 ListView 嵌套 PageView

那还有没有更非常规的?答案是肯定的,毕竟产品的小脑袋,怎么会想不到在垂直滑动的 ListView 里嵌套垂直切换的 PageView 这种需求。

有了前面的思路,其实实现这个逻辑也是异曲同工:PageViewListView 的滑动禁用,然后通过 RawGestureDetector 自己管理,不同的就是手势方法分发的差异。

RawGestureDetector(
         gestures: <Type, GestureRecognizerFactory>{
           VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                   VerticalDragGestureRecognizer>(
              () => VerticalDragGestureRecognizer(),
              (VerticalDragGestureRecognizer instance) {
             instance
              ..onStart = _handleDragStart
              ..onUpdate = _handleDragUpdate
              ..onEnd = _handleDragEnd
              ..onCancel = _handleDragCancel;
          })
        },
         behavior: HitTestBehavior.opaque,
         child: ListView.builder(
               ///屏蔽默认的滑动响应
               physics: NeverScrollableScrollPhysics(),
               controller: _listScrollController,
               itemCount: 5,
               itemBuilder: (context, index) {
                 if (index == 0) {
                   return Container(
                     height: 300,
                     child: KeepAlivePageView(
                       pageController: _pageController,
                       itemCount: itemCount,
                    ),
                  );
                }
                 return Container(
                     height: 300,
                     color: Colors.greenAccent,
                     child: Center(
                       child: Text(
                         "Item $index",
                         style: TextStyle(fontSize: 40, color: Colors.blue),
                      ),
                    ));
              }),
      )

同样是在 _handleDragStart 方法里,这里首先需要判断:

  • ListView 如果已经滑动过,就不响应顶部 PageView 的事件

  • 如果此时 ListView 处于顶部未滑动,判断手势位置是否在 PageView 里,如果是响应 PageView 的事件

  void _handleDragStart(DragStartDetails details) {
   if (_listScrollController.offset > 0) {
     _activeScrollController = _listScrollController;
     _drag = _listScrollController.position.drag(details, _disposeDrag);
     return;
  }
   if (_pageController.hasClients) {
     ///获取 PageView
     final RenderBox renderBox =
         _pageController.position.context.storageContext.findRenderObject()
             as RenderBox;

     ///判断触摸范围是不是在 PageView
     final isDragPageView = renderBox.paintBounds
        .shift(renderBox.localToGlobal(Offset.zero))
        .contains(details.globalPosition);

     ///如果在 PageView 里就切换到 PageView
     if (isDragPageView) {
       _activeScrollController = _pageController;
       _drag = _activeScrollController.position.drag(details, _disposeDrag);
       return;
    }
  }

   ///不在 PageView 里就继续响应 ListView
   _activeScrollController = _listScrollController;
   _drag = _listScrollController.position.drag(details, _disposeDrag);
}

接着在 _handleDragUpdate 方法里,判断如果 PageView 已经滑动到最后一页,也将滑动事件切换到 ListView

06ea891f2926080a215872323bfe2b19.png

当然,同样还有 KeepAlive 和去除列表 Material 边缘效果,最后运行效果如下 GIF 所示。

22222222222

本小节源码可见:github.com/CarGuo/gsy_…

最后再补充一个小技巧:如果你需要 Flutter 打印手势竞技的过程,可以配置 debugPrintGestureArenaDiagnostics = true;来让 Flutter 输出手势竞技的处理过程

import 'package:flutter/gestures.dart';
void main() {
 debugPrintGestureArenaDiagnostics = true;
 runApp(MyApp());
}

image-20220613115808538

最后

最后总结一下,本篇介绍了如何通过 Darg 解决各种因为嵌套而导致的手势冲突,相信大家也知道了如何利用 ControllerDarg 来快速自定义一些滑动需求,例如 ListView 联动 ListView 的差量滑动效果:

08421cca5b122a252707cf419e3d6512.png

f92657f8a550f0b33e48fbacdc1761fa.png

d6169210f2c6007b8abc31f77d92d266.jpg

44444444444444


作者:恋猫de小郭
来源:juejin.cn/post/7116267156655833102

0 个评论

要回复文章请先登录注册