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

网友投稿 298 2022-09-21

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

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

正常嵌套

最常见的嵌套应该就是横向 ​​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​​ ,所以如下代码所示:

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​​ ,然后调整 ​​gestureSettings​​ 的 ​​touchSlop​​ 从而修改 ​​PageView​​ 的灵明度 ,另外不要忘记,还需要把 ​​ListView​​​ 的 ​​touchSlop​​​ 切换会默认 的 ​​kTouchSlop​​ :

class HandlerListView extends StatefulWidget{ @override _MyListViewState createState() => _MyListViewState();}class _MyListViewState extends State { @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​​ 响应的灵敏度。

同方向 PageView 嵌套 ListView

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

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

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

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

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

通过​​NeverScrollableScrollPhysics​​​ 禁止了​​PageView​​​ 和​​ListView​​ 的滚动效果通过顶部​​RawGestureDetector ​​​的​​VerticalDragGestureRecognizer​​ 自己管理手势事件配置​​PageController​​​ 和​​ScrollController​​ 用于获取状态

body: RawGestureDetector( gestures: { 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) { ///先判断 Listview 是否可见或者可以调用 ///一般不可见时 hasClients false ,因为 PageView 也没有 keepAlive if (_listScrollController?.hasClients == true && _listScrollController?.position.context.storageContext != null) { ///获取 ListView 的 renderBox final RenderBox? renderBox = _listScrollController ?.position.context.storageContext .findRenderObject() as RenderBox; ///判断触摸的位置是否在 ListView 内 ///不在范围内一般是因为 ListView 已经滑动上去了,坐标位置和触摸位置不一致 if (renderBox?.paintBounds .shift(renderBox.localToGlobal(Offset.zero)) .contains(details.globalPosition) == true) { _activeScrollController = _listScrollController; _drag = _activeScrollController?.position.drag(details, _disposeDrag); return; } } ///这时候就可以认为是 PageView 需要滑动

前面我们主要在触摸开始时,判断需要响应的对象时​​ 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

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

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

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

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

通过​​with AutomaticKeepAliveClientMixin​​​ 让​​ListView​​ 在切换之后也保持滑动位置通过​​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 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​​ 。

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

同方向 ListView 嵌套 PageView

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

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

RawGestureDetector( gestures: { 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) { ///只要不是顶部,就不响应 PageView 的滑动 ///所以这个判断只支持垂直 PageView 在 ListView 的顶部 if (_listScrollController.offset > 0) { _activeScrollController = _listScrollController; _drag = _listScrollController.position.drag(details, _disposeDrag); return; } ///此时处于 ListView 的顶部 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

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

void _handleDragUpdate(DragUpdateDetails details) { var scrollDirection = _activeScrollController.position.userScrollDirection; ///判断此时响应的如果还是 _pageController,是不是到了最后一页 if (_activeScrollController == _pageController && scrollDirection == ScrollDirection.reverse && ///是不是到最后一页了,到最后一页就切换回 pageController (_pageController.page != null && _pageController.page! >= (itemCount - 1))) { ///切换回 ListView

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

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

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

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

最后

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

///listView 联动 listViewclass ListViewLinkListView extends StatefulWidget{ @override _ListViewLinkListViewState createState() => _ListViewLinkListViewState();}class _ListViewLinkListViewState extends State { ScrollController _primaryScrollController = ScrollController(); ScrollController _subScrollController = ScrollController(); Drag? _primaryDrag; Drag? _subDrag; @override void initState() { super.initState(); } @override void dispose() { _primaryScrollController.dispose(); _subScrollController.dispose(); super.dispose(); } void _handleDragStart(DragStartDetails details) { _primaryDrag = _primaryScrollController.position.drag(details, _disposePrimaryDrag); _subDrag = _subScrollController.position.drag(details, _disposeSubDrag); } void _handleDragUpdate(DragUpdateDetails details) { _primaryDrag?.update(details); ///除以10实现差量效果 _subDrag?.update(DragUpdateDetails( sourceTimeStamp: details.sourceTimeStamp, delta: details.delta / 30, primaryDelta: (details.primaryDelta ?? 0) / 30, globalPosition: details.globalPosition, localPosition: details.localPosition)); } void _handleDragEnd(DragEndDetails details) { _primaryDrag?.end(details); _subDrag?.end(details); } void _handleDragCancel() { _primaryDrag?.cancel(); _subDrag?.cancel(); } void _disposePrimaryDrag() { _primaryDrag = null; } void _disposeSubDrag() { _subDrag = null; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("ListViewLinkListView"), ), body: RawGestureDetector( gestures: { VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers< VerticalDragGestureRecognizer>( () => VerticalDragGestureRecognizer(), (VerticalDragGestureRecognizer instance) { instance ..onStart = _handleDragStart ..onUpdate = _handleDragUpdate ..onEnd = _handleDragEnd ..onCancel = _handleDragCancel; }) }, behavior: HitTestBehavior.opaque, child: ScrollConfiguration( ///去掉 Android 上默认的边缘拖拽效果 behavior: ScrollConfiguration.of(context).copyWith(overscroll: false), child: Row( children: [ new Expanded( child: ListView.builder( ///屏蔽默认的滑动响应 physics: NeverScrollableScrollPhysics(), controller: _primaryScrollController, itemCount: 55, itemBuilder: (context, index) { return Container( height: 300, color: Colors.greenAccent, child: Center( child: Text( "Item $index", style: TextStyle( fontSize: 40, color: Colors.blue), ), )); })), new SizedBox( width: 5, ), new Expanded( child: ListView.builder( ///屏蔽默认的滑动响应 physics: NeverScrollableScrollPhysics(), controller: _subScrollController, itemCount: 55, itemBuilder: (context, index) { return Container( height: 300, color: Colors.deepOrange, child: Center( child: Text( "Item $index", style: TextStyle(fontSize: 40, color: Colors.white), ), ), ); }), ), ], ), ), )); }}

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:新邦:微信重磅更新,视频号狂放大招!直播连麦打赏美颜齐上线,新增巨大流量入口!博主高呼“给力”!
下一篇:Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密
相关文章

 发表评论

暂时没有评论,来抢沙发吧~