赞
踩
这次的 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<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
响应的灵敏度。
介绍完常规使用,接着来点不一样的,在垂直切换的 PageView
里嵌套垂直滚动的 ListView
, 你第一感觉是不是觉得不靠谱,为什么会有这样的场景?
对于产品来说,他们不会考虑你如何实现的问题,他们只会拍着脑袋说淘宝可以,为什么你不行,所以如果是你,你会怎么做?
而关于这个需求,社区目前讨论的结果是:把 PageView
和 ListView
的滑动禁用,然后通过 RawGestureDetector
自己管理。
如果对实现逻辑分析没兴趣,可以直接看本小节末尾的 源码链接 。
看到自己管理先不要慌,虽然要自己实现 PageView
和 ListView
的手势分发,但是其实并不需要重写 PageView
和 ListView
,我们可以复用它们的 Darg
响应逻辑,如下代码所示:
NeverScrollableScrollPhysics
禁止了 PageView
和 ListView
的滚动效果RawGestureDetector
的 VerticalDragGestureRecognizer
自己管理手势事件PageController
和 ScrollController
用于获取状态- 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) {
- ///先判断 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 需要滑动
- _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
的边缘溢出效果 所以我们需要对 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<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 。
本小节源码可见: github.com/CarGuo/gsy_…
那还有没有更非常规的?答案是肯定的,毕竟产品的小脑袋,怎么会想不到在垂直滑动的 ListView
里嵌套垂直切换的 PageView
这种需求。
有了前面的思路,其实实现这个逻辑也是异曲同工:把 PageView
和 ListView
的滑动禁用,然后通过 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) {
- ///只要不是顶部,就不响应 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
- _activeScrollController = _listScrollController;
- _drag = _listScrollController.position.drag(details, _disposeDrag);
- }
接着在 _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
- _activeScrollController = _listScrollController;
- _drag?.cancel();
- _drag = _listScrollController.position.drag(
- DragStartDetails(
- globalPosition: details.globalPosition,
- localPosition: details.localPosition),
- _disposeDrag);
- }
- _drag?.update(details);
- }
当然,同样还有 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 联动 listView
- class ListViewLinkListView extends StatefulWidget {
- @override
- _ListViewLinkListViewState createState() => _ListViewLinkListViewState();
- }
-
- class _ListViewLinkListViewState extends State<ListViewLinkListView> {
- 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: <Type, GestureRecognizerFactory>{
- 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),
- ),
- ),
- );
- }),
- ),
- ],
- ),
- ),
- ));
- }
- }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。