当前位置:   article > 正文

Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套_flutter 纵向pageview 接listview

flutter 纵向pageview 接listview

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

正常嵌套

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

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

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

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

  • VerticalDragGestureRecognizer 处理垂直方向的手势
  • HorizontalDragGestureRecognizer 处理水平方向的手势

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

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

  1. body: MediaQuery(
  2. ///调高 touchSlop 到 50 ,这样 pageview 滑动可能有点点影响,
  3. ///但是大概率处理了斜着滑动触发的问题
  4. data: MediaQuery.of(context).copyWith(
  5. gestureSettings: DeviceGestureSettings(
  6. touchSlop: 50,
  7. )),
  8. child: PageView(
  9. scrollDirection: Axis.horizontal,
  10. pageSnapping: true,
  11. children: [
  12. HandlerListView(),
  13. HandlerListView(),
  14. ],
  15. ),
  16. ),
  17. 复制代码

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

  1. class HandlerListView extends StatefulWidget {
  2. @override
  3. _MyListViewState createState() => _MyListViewState();
  4. }
  5. class _MyListViewState extends State<HandlerListView> {
  6. @override
  7. Widget build(BuildContext context) {
  8. return MediaQuery(
  9. ///这里 touchSlop 需要调回默认
  10. data: MediaQuery.of(context).copyWith(
  11. gestureSettings: DeviceGestureSettings(
  12. touchSlop: kTouchSlop,
  13. )),
  14. child: ListView.separated(
  15. itemCount: 15,
  16. itemBuilder: (context, index) {
  17. return ListTile(
  18. title: Text('Item $index'),
  19. );
  20. },
  21. separatorBuilder: (context, index) {
  22. return const Divider(
  23. thickness: 3,
  24. );
  25. },
  26. ),
  27. );
  28. }
  29. }
  30. 复制代码

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

 

同方向 PageView 嵌套 ListView

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

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

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

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

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

  • 通过 NeverScrollableScrollPhysics 禁止了 PageViewListView 的滚动效果
  • 通过顶部 RawGestureDetector VerticalDragGestureRecognizer 自己管理手势事件
  • 配置 PageControllerScrollController 用于获取状态
  1. body: RawGestureDetector(
  2. gestures: <Type, GestureRecognizerFactory>{
  3. VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
  4. VerticalDragGestureRecognizer>(
  5. () => VerticalDragGestureRecognizer(),
  6. (VerticalDragGestureRecognizer instance) {
  7. instance
  8. ..onStart = _handleDragStart
  9. ..onUpdate = _handleDragUpdate
  10. ..onEnd = _handleDragEnd
  11. ..onCancel = _handleDragCancel;
  12. })
  13. },
  14. behavior: HitTestBehavior.opaque,
  15. child: PageView(
  16. controller: _pageController,
  17. scrollDirection: Axis.vertical,
  18. ///屏蔽默认的滑动响应
  19. physics: const NeverScrollableScrollPhysics(),
  20. children: [
  21. ListView.builder(
  22. controller: _listScrollController,
  23. ///屏蔽默认的滑动响应
  24. physics: const NeverScrollableScrollPhysics(),
  25. itemBuilder: (context, index) {
  26. return ListTile(title: Text('List Item $index'));
  27. },
  28. itemCount: 30,
  29. ),
  30. Container(
  31. color: Colors.green,
  32. child: Center(
  33. child: Text(
  34. 'Page View',
  35. style: TextStyle(fontSize: 50),
  36. ),
  37. ),
  38. )
  39. ],
  40. ),
  41. ),

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

  • 通过 ScrollController 判断 ListView 是否可见
  • 判断触摸位置是否在 ListIView 范围内
  • 根据状态判断通过哪个 Controller 去生产 Drag 对象,用于响应后续的滑动事件
  1. void _handleDragStart(DragStartDetails details) {
  2. ///先判断 Listview 是否可见或者可以调用
  3. ///一般不可见时 hasClients false ,因为 PageView 也没有 keepAlive
  4. if (_listScrollController?.hasClients == true &&
  5. _listScrollController?.position.context.storageContext != null) {
  6. ///获取 ListView 的 renderBox
  7. final RenderBox? renderBox = _listScrollController
  8. ?.position.context.storageContext
  9. .findRenderObject() as RenderBox;
  10. ///判断触摸的位置是否在 ListView 内
  11. ///不在范围内一般是因为 ListView 已经滑动上去了,坐标位置和触摸位置不一致
  12. if (renderBox?.paintBounds
  13. .shift(renderBox.localToGlobal(Offset.zero))
  14. .contains(details.globalPosition) ==
  15. true) {
  16. _activeScrollController = _listScrollController;
  17. _drag = _activeScrollController?.position.drag(details, _disposeDrag);
  18. return;
  19. }
  20. }
  21. ///这时候就可以认为是 PageView 需要滑动
  22. _activeScrollController = _pageController;
  23. _drag = _pageController?.position.drag(details, _disposeDrag);
  24. }

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

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

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

  • 如果不需要就继续用前面得到的 _drag?.update(details)响应 ListView 滚动
  • 如果需要就通过 _pageController 切换新的 _drag 对象用于响应
  1. void _handleDragUpdate(DragUpdateDetails details) {
  2. if (_activeScrollController == _listScrollController &&
  3. ///手指向上移动,也就是快要显示出底部 PageView
  4. details.primaryDelta! < 0 &&
  5. ///到了底部,切换到 PageView
  6. _activeScrollController?.position.pixels ==
  7. _activeScrollController?.position.maxScrollExtent) {
  8. ///切换相应的控制器
  9. _activeScrollController = _pageController;
  10. _drag?.cancel();
  11. ///参考 Scrollable 里
  12. ///因为是切换控制器,也就是要更新 Drag
  13. ///拖拽流程要切换到 PageView 里,所以需要 DragStartDetails
  14. ///所以需要把 DragUpdateDetails 变成 DragStartDetails
  15. ///提取出 PageView 里的 Drag 相应 details
  16. _drag = _pageController?.position.drag(
  17. DragStartDetails(
  18. globalPosition: details.globalPosition,
  19. localPosition: details.localPosition),
  20. _disposeDrag);
  21. }
  22. _drag?.update(details);
  23. }

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

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

  • 在切换之后 ListView 的位置没有保存下来
  • 产品要求去除 ListView 的边缘溢出效果

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

  • 通过 with AutomaticKeepAliveClientMixinListView 在切换之后也保持滑动位置
  • 通过 ScrollConfiguration.of(context).copyWith(overscroll: false) 快速去除 Scrollable 的边缘 Material 效果
  1. child: PageView(
  2. controller: _pageController,
  3. scrollDirection: Axis.vertical,
  4. ///去掉 Android 上默认的边缘拖拽效果
  5. scrollBehavior:
  6. ScrollConfiguration.of(context).copyWith(overscroll: false),
  7. ///对 PageView 里的 ListView 做 KeepAlive 记住位置
  8. class KeepAliveListView extends StatefulWidget {
  9. final ScrollController? listScrollController;
  10. final int itemCount;
  11. KeepAliveListView({
  12. required this.listScrollController,
  13. required this.itemCount,
  14. });
  15. @override
  16. KeepAliveListViewState createState() => KeepAliveListViewState();
  17. }
  18. class KeepAliveListViewState extends State<KeepAliveListView>
  19. with AutomaticKeepAliveClientMixin {
  20. @override
  21. Widget build(BuildContext context) {
  22. super.build(context);
  23. return ListView.builder(
  24. controller: widget.listScrollController,
  25. ///屏蔽默认的滑动响应
  26. physics: const NeverScrollableScrollPhysics(),
  27. itemBuilder: (context, index) {
  28. return ListTile(title: Text('List Item $index'));
  29. },
  30. itemCount: widget.itemCount,
  31. );
  32. }
  33. @override
  34. bool get wantKeepAlive => true;
  35. }

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

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

同方向 ListView 嵌套 PageView

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

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

  1. RawGestureDetector(
  2. gestures: <Type, GestureRecognizerFactory>{
  3. VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
  4. VerticalDragGestureRecognizer>(
  5. () => VerticalDragGestureRecognizer(),
  6. (VerticalDragGestureRecognizer instance) {
  7. instance
  8. ..onStart = _handleDragStart
  9. ..onUpdate = _handleDragUpdate
  10. ..onEnd = _handleDragEnd
  11. ..onCancel = _handleDragCancel;
  12. })
  13. },
  14. behavior: HitTestBehavior.opaque,
  15. child: ListView.builder(
  16. ///屏蔽默认的滑动响应
  17. physics: NeverScrollableScrollPhysics(),
  18. controller: _listScrollController,
  19. itemCount: 5,
  20. itemBuilder: (context, index) {
  21. if (index == 0) {
  22. return Container(
  23. height: 300,
  24. child: KeepAlivePageView(
  25. pageController: _pageController,
  26. itemCount: itemCount,
  27. ),
  28. );
  29. }
  30. return Container(
  31. height: 300,
  32. color: Colors.greenAccent,
  33. child: Center(
  34. child: Text(
  35. "Item $index",
  36. style: TextStyle(fontSize: 40, color: Colors.blue),
  37. ),
  38. ));
  39. }),
  40. )

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

  • ListView 如果已经滑动过,就不响应顶部 PageView 的事件
  • 如果此时 ListView 处于顶部未滑动,判断手势位置是否在 PageView 里,如果是响应 PageView 的事件
  1. void _handleDragStart(DragStartDetails details) {
  2. ///只要不是顶部,就不响应 PageView 的滑动
  3. ///所以这个判断只支持垂直 PageView 在 ListView 的顶部
  4. if (_listScrollController.offset > 0) {
  5. _activeScrollController = _listScrollController;
  6. _drag = _listScrollController.position.drag(details, _disposeDrag);
  7. return;
  8. }
  9. ///此时处于 ListView 的顶部
  10. if (_pageController.hasClients) {
  11. ///获取 PageView
  12. final RenderBox renderBox =
  13. _pageController.position.context.storageContext.findRenderObject()
  14. as RenderBox;
  15. ///判断触摸范围是不是在 PageView
  16. final isDragPageView = renderBox.paintBounds
  17. .shift(renderBox.localToGlobal(Offset.zero))
  18. .contains(details.globalPosition);
  19. ///如果在 PageView 里就切换到 PageView
  20. if (isDragPageView) {
  21. _activeScrollController = _pageController;
  22. _drag = _activeScrollController.position.drag(details, _disposeDrag);
  23. return;
  24. }
  25. }
  26. ///不在 PageView 里就继续响应 ListView
  27. _activeScrollController = _listScrollController;
  28. _drag = _listScrollController.position.drag(details, _disposeDrag);
  29. }

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

  1. void _handleDragUpdate(DragUpdateDetails details) {
  2. var scrollDirection = _activeScrollController.position.userScrollDirection;
  3. ///判断此时响应的如果还是 _pageController,是不是到了最后一页
  4. if (_activeScrollController == _pageController &&
  5. scrollDirection == ScrollDirection.reverse &&
  6. ///是不是到最后一页了,到最后一页就切换回 pageController
  7. (_pageController.page != null &&
  8. _pageController.page! >= (itemCount - 1))) {
  9. ///切换回 ListView
  10. _activeScrollController = _listScrollController;
  11. _drag?.cancel();
  12. _drag = _listScrollController.position.drag(
  13. DragStartDetails(
  14. globalPosition: details.globalPosition,
  15. localPosition: details.localPosition),
  16. _disposeDrag);
  17. }
  18. _drag?.update(details);
  19. }

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

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

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

  1. import 'package:flutter/gestures.dart';
  2. void main() {
  3. debugPrintGestureArenaDiagnostics = true;
  4. runApp(MyApp());
  5. }
  6. 复制代码

最后

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

  1. ///listView 联动 listView
  2. class ListViewLinkListView extends StatefulWidget {
  3. @override
  4. _ListViewLinkListViewState createState() => _ListViewLinkListViewState();
  5. }
  6. class _ListViewLinkListViewState extends State<ListViewLinkListView> {
  7. ScrollController _primaryScrollController = ScrollController();
  8. ScrollController _subScrollController = ScrollController();
  9. Drag? _primaryDrag;
  10. Drag? _subDrag;
  11. @override
  12. void initState() {
  13. super.initState();
  14. }
  15. @override
  16. void dispose() {
  17. _primaryScrollController.dispose();
  18. _subScrollController.dispose();
  19. super.dispose();
  20. }
  21. void _handleDragStart(DragStartDetails details) {
  22. _primaryDrag =
  23. _primaryScrollController.position.drag(details, _disposePrimaryDrag);
  24. _subDrag = _subScrollController.position.drag(details, _disposeSubDrag);
  25. }
  26. void _handleDragUpdate(DragUpdateDetails details) {
  27. _primaryDrag?.update(details);
  28. ///除以10实现差量效果
  29. _subDrag?.update(DragUpdateDetails(
  30. sourceTimeStamp: details.sourceTimeStamp,
  31. delta: details.delta / 30,
  32. primaryDelta: (details.primaryDelta ?? 0) / 30,
  33. globalPosition: details.globalPosition,
  34. localPosition: details.localPosition));
  35. }
  36. void _handleDragEnd(DragEndDetails details) {
  37. _primaryDrag?.end(details);
  38. _subDrag?.end(details);
  39. }
  40. void _handleDragCancel() {
  41. _primaryDrag?.cancel();
  42. _subDrag?.cancel();
  43. }
  44. void _disposePrimaryDrag() {
  45. _primaryDrag = null;
  46. }
  47. void _disposeSubDrag() {
  48. _subDrag = null;
  49. }
  50. @override
  51. Widget build(BuildContext context) {
  52. return Scaffold(
  53. appBar: AppBar(
  54. title: Text("ListViewLinkListView"),
  55. ),
  56. body: RawGestureDetector(
  57. gestures: <Type, GestureRecognizerFactory>{
  58. VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
  59. VerticalDragGestureRecognizer>(
  60. () => VerticalDragGestureRecognizer(),
  61. (VerticalDragGestureRecognizer instance) {
  62. instance
  63. ..onStart = _handleDragStart
  64. ..onUpdate = _handleDragUpdate
  65. ..onEnd = _handleDragEnd
  66. ..onCancel = _handleDragCancel;
  67. })
  68. },
  69. behavior: HitTestBehavior.opaque,
  70. child: ScrollConfiguration(
  71. ///去掉 Android 上默认的边缘拖拽效果
  72. behavior:
  73. ScrollConfiguration.of(context).copyWith(overscroll: false),
  74. child: Row(
  75. children: [
  76. new Expanded(
  77. child: ListView.builder(
  78. ///屏蔽默认的滑动响应
  79. physics: NeverScrollableScrollPhysics(),
  80. controller: _primaryScrollController,
  81. itemCount: 55,
  82. itemBuilder: (context, index) {
  83. return Container(
  84. height: 300,
  85. color: Colors.greenAccent,
  86. child: Center(
  87. child: Text(
  88. "Item $index",
  89. style: TextStyle(
  90. fontSize: 40, color: Colors.blue),
  91. ),
  92. ));
  93. })),
  94. new SizedBox(
  95. width: 5,
  96. ),
  97. new Expanded(
  98. child: ListView.builder(
  99. ///屏蔽默认的滑动响应
  100. physics: NeverScrollableScrollPhysics(),
  101. controller: _subScrollController,
  102. itemCount: 55,
  103. itemBuilder: (context, index) {
  104. return Container(
  105. height: 300,
  106. color: Colors.deepOrange,
  107. child: Center(
  108. child: Text(
  109. "Item $index",
  110. style:
  111. TextStyle(fontSize: 40, color: Colors.white),
  112. ),
  113. ),
  114. );
  115. }),
  116. ),
  117. ],
  118. ),
  119. ),
  120. ));
  121. }
  122. }

 

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/463153
推荐阅读
相关标签
  

闽ICP备14008679号