這次的 Flutter 小技巧是
ListView
和
PageView
的花式嵌套,不同
Scrollable
的嵌套沖突問題相信大家不會陌生,今天就通過
ListView
和
PageView
的三種嵌套模式帶大家收獲一些不一樣的小技巧。
正常嵌套
最常見的嵌套應該就是橫向
PageView
加縱向
ListView
的組合,一般情況下這個組合不會有什麼問題,除非你硬是要斜着滑。
最近剛好遇到好幾個人同時在問:“斜滑
ListView
容易切換到
PageView
滑動” 的問題,如下 GIF 所示,當使用者在滑動
ListView
時,滑動角度帶上傾斜之後,可能就會導緻滑動的是
PageView
而不是
ListView
。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICN4ETMfdHLkVGepZ2XtxSZ6l2clJ3LcBnYldHL0FWby9mZvwVPrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdsAjMfd3bkFGazxCMx8VesATMfhHLlN3XnxCMz8FdsYkRGZkRG9lcvx2bjxSa2EWNhJTW1AlUxEFeVRUUfRHelRHL2EzXlpXazxyayFWbyVGdhd3LcV2Zh1Wa9M3clN2byBXLzN3btg3PwJWZ35SM3EjNyQmMkhjMwUWN1gzYyYzXyUTNwADM5AzLcdDMyIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.webp)
雖然從我個人體驗上并不覺得這是個問題,但是如果産品硬是要你修改,難道要自己重寫
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
, 你第一感覺是不是覺得不靠譜,為什麼會有這樣的場景?
對于産品來說,他們不會考慮你如何實作的問題,他們隻會拍着腦袋說淘寶可以,為什麼你不行,是以如果是你,你會怎麼做?
而關于這個需求,社群目前讨論的結果是:把
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 需要滑動
前面我們主要在觸摸開始時,判斷需要響應的對象時
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
- 通過
快速去除 Scrollable 的邊緣 Material 效果ScrollConfiguration.of(context).copyWith(overscroll: false)
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
那還有沒有更非正常的?答案是肯定的,畢竟産品的小腦袋,怎麼會想不到在垂直滑動的
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
接着在
_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());
}
最後
///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),
),
),
);
}),
),
],
),
),
));
}
}