GSYFlutterBook/Flutter-N5.md

570 lines
22 KiB
Markdown
Raw Permalink Normal View History

2022-08-09 10:03:38 +08:00
# Flutter 小技巧之 ListView 和 PageView 的各种花式嵌套
这次的 Flutter 小技巧是 `ListView``PageView` 的花式嵌套,不同 `Scrollable` 的嵌套冲突问题相信大家不会陌生,今天就通过 `ListView``PageView` 的三种嵌套模式带大家收获一些不一样的小技巧。
# 正常嵌套
最常见的嵌套应该就是横向 `PageView` 加纵向 `ListView` 的组合,**一般情况下这个组合不会有什么问题,除非你硬是要斜着滑**。
最近刚好遇到好几个人同时在问:“斜滑 `ListView` 容易切换到 `PageView` 滑动” 的问题,如下 GIF 所示,当用户在滑动 `ListView` 时,滑动角度带上倾斜之后,可能就会导致滑动的是 `PageView` 而不是 `ListView`
![xiehuadong](http://img.cdn.guoshuyu.cn/20220703_N5/image1.gif)
虽然从我个人体验上并不觉得这是个问题,但是如果产品硬是要你修改,难道要自己重写 `PageView` 的手势响应吗?
我们简单看一下,不管是 `PageView` 还是 `ListView` 它们的滑动效果都来自于 `Scrollable` ,而 `Scrollable` 内部针对不同方向的响应,是通过 `RawGestureDetector` 完成:
- `VerticalDragGestureRecognizer` 处理垂直方向的手势
- `HorizontalDragGestureRecognizer` 处理水平方向的手势
所以简单看它们响应的判断逻辑,可以看到一个很有趣的方法 `computeHitSlop` **根据 pointer 的类型确定当然命中需要的最小像素,触摸默认是 kTouchSlop (18.0)**
![image-20220613103745974](http://img.cdn.guoshuyu.cn/20220703_N5/image2.png)
看到这你有没有灵光一闪:**如果我们把 `PageView` 的 touchSlop 修改了,是不是就可以调整它响应的灵敏度** 恰好在 `computeHitSlop` 方法里,它可以通过 `DeviceGestureSettings` 来配置,而 `DeviceGestureSettings` 来自于 `MediaQuery` ,所以如下代码所示:
```dart
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`
```dart
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](http://img.cdn.guoshuyu.cn/20220703_N5/image3.gif)
# 同方向 PageView 嵌套 ListView
介绍完常规使用,接着来点不一样的,**在垂直切换的 `PageView` 里嵌套垂直滚动的 ` ListView`** 你第一感觉是不是觉得不靠谱,为什么会有这样的场景?
> 对于产品来说,他们不会考虑你如何实现的问题,他们只会拍着脑袋说淘宝可以,为什么你不行,所以如果是你,你会怎么做?
而关于这个需求,社区目前讨论的结果是:**把 `PageView``ListView` 的滑动禁用,然后通过 `RawGestureDetector` 自己管理**。
> **如果对实现逻辑分析没兴趣,可以直接看本小节末尾的 [源码链接 ](https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L75)**。
看到自己管理先不要慌,虽然要自己实现 `PageView``ListView` 的手势分发,但是其实并不需要重写 `PageView``ListView` ,我们可以复用它们的 `Darg` 响应逻辑,如下代码所示:
- 通过 `NeverScrollableScrollPhysics` 禁止了 `PageView``ListView` 的滚动效果
- 通过顶部 `RawGestureDetector ``VerticalDragGestureRecognizer` 自己管理手势事件
- 配置 `PageController``ScrollController` 用于获取状态
```dart
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` 对象,用于响应后续的滑动事件
```dart
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` 对象用于响应
```dart
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](http://img.cdn.guoshuyu.cn/20220703_N5/image4.gif)
所以我们需要对 `ListView` 做一个 KeepAlive ,然后用简单的方法去除 Android 边缘滑动的 Material 效果:
- 通过 `with AutomaticKeepAliveClientMixin``ListView` 在切换之后也保持滑动位置
- 通过 `ScrollConfiguration.of(context).copyWith(overscroll: false)` 快速去除 Scrollable 的边缘 Material 效果
```dart
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](https://juejin.cn/post/7105869440985595912) 。
![000000000](http://img.cdn.guoshuyu.cn/20220703_N5/image5.gif)
> 本小节源码可见: https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L75
# 同方向 ListView 嵌套 PageView
那还有没有更非常规的?答案是肯定的,毕竟产品的小脑袋,怎么会想不到**在垂直滑动的 `ListView` 里嵌套垂直切换的 ` PageView`** 这种需求。
有了前面的思路,其实实现这个逻辑也是异曲同工:**把 `PageView``ListView` 的滑动禁用,然后通过 `RawGestureDetector` 自己管理**,不同的就是手势方法分发的差异。
```dart
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` 的事件
```dart
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`
```dart
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 所示。
![22222222222](http://img.cdn.guoshuyu.cn/20220703_N5/image6.gif)
> 本小节源码可见https://github.com/CarGuo/gsy_flutter_demo/blob/7838971cefbf19bb53a71041cd100c4c15eb6443/lib/widget/vp_list_demo_page.dart#L262
最后再补充一个小技巧:**如果你需要 Flutter 打印手势竞技的过程,可以配置 ` debugPrintGestureArenaDiagnostics = true; `来让 Flutter 输出手势竞技的处理过程**。
```dart
import 'package:flutter/gestures.dart';
void main() {
debugPrintGestureArenaDiagnostics = true;
runApp(MyApp());
}
```
![image-20220613115808538](http://img.cdn.guoshuyu.cn/20220703_N5/image7.png)
# 最后
最后总结一下,**本篇介绍了如何通过 `Darg` 解决各种因为嵌套而导致的手势冲突**,相信大家也知道了如何利用 `Controller``Darg` 来快速自定义一些滑动需求,例如 `ListView` 联动 `ListView` 的差量滑动效果:
```dart
///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),
),
),
);
}),
),
],
),
),
));
}
}
```
![44444444444444](http://img.cdn.guoshuyu.cn/20220703_N5/image8.gif)