前言
之前在 GrapeCity/ComponentOne 做微軟 Xaml 系列的控制元件,包括 Silverlight, WPF, Windows Phone, UWP,一套程式碼多端共用,是真香。對於建立一個水平垂直方向都可以滾動的列表,是非常方便的。但是在 Flutter 平臺,似乎沒有看到一個開箱即食元件。
經常聽到大家講 Flutter 辣雞,什麼什麼不支援。其實 Flutter 有夠開源和擴充套件,大部分東西只要用心,都能創造出來的,只是你願意不願意花時間去嘗試。
雖然說也叫 FlexGrid, 但功能遠遠沒有 C# FlexGrid 的多,一些功能對於我來說,不是必須,所以便未做。
在設計理念方面,Xaml 和 Flutter 大大的不一樣。Xaml 模板,雙向繫結用的飛起,而 Flutter 更愛 immutable
,主張 simple is fast
。所以對於 Flutter 版本的 FlexGrid,更傾向設計成 Flutter 形式的輕量級的元件。
現在主要支援以下功能:
- 鎖定行列
- 在
TabBarView
/PageView
中水平滾動連貫 - 大量資料的高效能
- 重新整理動畫和增量載入
原理
有一說一,對於設計這個元件,幾乎沒有任何難點,Sliver 已經足夠優秀。
結構
以下為虛擬碼,只是提供一個現實的思路。可以看到,只需要使用到 Flutter 提供的 Sliver 相關的元件,就能構造出來這樣一個結構。最終程式碼 flex_grid.dart
CustomScrollView(
scrollDirection: Axis.vertical,
slivers: <Widget>[
// 表頭
SliverPinnedToBoxAdapter(
child: CustomScrollView(
scrollDirection: Axis.horizontal,
slivers: <Widget>[
// 鎖定的列,如果有
SliverPinnedToBoxAdapter(),
SliverList(),
],
),
),
// 鎖定的行,如果有
SliverPinnedToBoxAdapter(
child: CustomScrollView(
scrollDirection: Axis.horizontal,
slivers: <Widget>[
// 鎖定的列,如果有
SliverPinnedToBoxAdapter(),
SliverList(),
],
),
),
// 滾動部分
SliverList(
CustomScrollView(
scrollDirection: Axis.horizontal,
slivers: <Widget>[
// 鎖定的列,如果有
SliverPinnedToBoxAdapter(),
SliverList(),
],
))
],
);
複製程式碼
水平同步滾動
如果你看過 Flutter Tab巢狀滑動如絲 (juejin.cn) 的文章,這個問題應該也不難解決。
ScrollableState
首先帶大家再次認識下 ScrollableState,只要你熟悉了這個類,你就大概能瞭解到 Flutter 中的滾動體系。
手勢從何而來
在 setCanDrag方法中,我們根據 Axis
設定水平或者垂直的
Drag 監聽,分別註冊了以下的事件。
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel
複製程式碼
_handleDragDown
初始化一個 ScrollHoldController
物件, 在 _handleDragStart
和 _handleDragCancel
的時候會觸發 _disposeHold
回撥。
Drag? _drag;
ScrollHoldController? _hold;
void _handleDragDown(DragDownDetails details) {
assert(_drag == null);
assert(_hold == null);
_hold = position.hold(_disposeHold);
}
複製程式碼
_handleDragStart
初始化一個 Drag
物件,並且註冊 _disposeDrag
回撥。
void _handleDragStart(DragStartDetails details) {
// It's possible for _hold to become null between _handleDragDown and
// _handleDragStart, for example if some user code calls jumpTo or otherwise
// triggers a new activity to begin.
assert(_drag == null);
_drag = position.drag(details, _disposeDrag);
assert(_drag != null);
assert(_hold == null);
}
複製程式碼
_handleDragUpdate
更新狀態,這裡就是你看到列表開始滾動了。
void _handleDragUpdate(DragUpdateDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.update(details);
}
複製程式碼
_handleDragEnd
這裡就是手勢的慣性處理
void _handleDragEnd(DragEndDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.end(details);
assert(_drag == null);
}
複製程式碼
_handleDragCancel
呼叫 cancel
方法,觸發 _disposeHold
和 _disposeDrag
void _handleDragCancel() {
// _hold might be null if the drag started.
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_hold?.cancel();
_drag?.cancel();
assert(_hold == null);
assert(_drag == null);
}
複製程式碼
_disposeHold 和 _disposeDrag
void _disposeHold() {
_hold = null;
}
void _disposeDrag() {
_drag = null;
}
複製程式碼
通過以上我們知道了 Flutter 是怎麼獲取手勢並且反饋到滾動元件上面的。篇幅有限,其實這裡還有很多有趣的相關知識,我會在下一篇中講解。
DragHoldController
接下來,我們把這幾個方法封裝到一起,供 ScrollController
統一操作。
class DragHoldController {
DragHoldController(this.position);
final ScrollPosition position;
Drag? _drag;
ScrollHoldController? _hold;
void handleDragDown(DragDownDetails? details) {
assert(_drag == null);
assert(_hold == null);
_hold = position.hold(_disposeHold);
}
void handleDragStart(DragStartDetails details) {
// It's possible for _hold to become null between _handleDragDown and
// _handleDragStart, for example if some user code calls jumpTo or otherwise
// triggers a new activity to begin.
assert(_drag == null);
_drag = position.drag(details, _disposeDrag);
assert(_drag != null);
assert(_hold == null);
}
void handleDragUpdate(DragUpdateDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.update(details);
}
void handleDragEnd(DragEndDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.end(details);
assert(_drag == null);
}
void handleDragCancel() {
// _hold might be null if the drag started.
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_hold?.cancel();
_drag?.cancel();
assert(_hold == null);
assert(_drag == null);
}
void _disposeHold() {
_hold = null;
}
void _disposeDrag() {
_drag = null;
}
void forceCancel() {
_hold = null;
_drag = null;
}
bool get hasDrag => _drag != null;
bool get hasHold => _hold != null;
double get extentAfter => position.extentAfter;
double get extentBefore => position.extentBefore;
}
複製程式碼
ScrollController
我們可以看到不管是 ScrollHoldController
還是 Drag
,都是由 ScrollPosition
建立出來的,單個 ScrollPosition
控制單個列表,那麼我們是不是直接利用 ScrollController
控制多個 ScrollPosition
呢?
為此我建立了一個用於同步 ScrollPosition
的 SyncControllerMixin.
- 在
attach
的時候建立對應的DragHoldController
並且同步position
的pixels
- 在
hold
和drag
相關方法中去同步滾動 detach
的時候移除
mixin SyncControllerMixin on ScrollController {
final Map<ScrollPosition, DragHoldController> _positionToListener =
<ScrollPosition, DragHoldController>{};
@override
void attach(ScrollPosition position) {
super.attach(position);
assert(!_positionToListener.containsKey(position));
// 列表回收元素之後,再次建立,需要去將當前的滾動同步
if (_positionToListener.isNotEmpty) {
final double pixels = _positionToListener.keys.first.pixels;
if (position.pixels != pixels) {
position.correctPixels(pixels);
}
}
_positionToListener[position] = DragHoldController(position);
}
@override
void detach(ScrollPosition position) {
super.detach(position);
assert(_positionToListener.containsKey(position));
_positionToListener[position]!.forceCancel();
_positionToListener.remove(position);
}
@override
void dispose() {
forceCancel();
super.dispose();
}
void handleDragDown(DragDownDetails? details) {
for (final DragHoldController item in _positionToListener.values) {
item.handleDragDown(details);
}
}
void handleDragStart(DragStartDetails details) {
for (final DragHoldController item in _positionToListener.values) {
item.handleDragStart(details);
}
}
void handleDragUpdate(DragUpdateDetails details) {
for (final DragHoldController item in _positionToListener.values) {
item.handleDragUpdate(details);
}
}
void handleDragEnd(DragEndDetails details) {
for (final DragHoldController item in _positionToListener.values) {
item.handleDragEnd(details);
}
}
void handleDragCancel() {
for (final DragHoldController item in _positionToListener.values) {
item.handleDragCancel();
}
}
void forceCancel() {
for (final DragHoldController item in _positionToListener.values) {
item.forceCancel();
}
}
}
複製程式碼
HorizontalSyncScrollMinxin
接下來我們要把前面的東西都組合在一起,放進horizontal_sync_scroll_minxin.dart。
- 註冊手勢監聽
- 傳遞到 SyncControllerMixin 中控制水平的同步滾動。如果達到滾動邊界,
外部有 TabbarView
和 PageView
的話,將讓外部傳入 outerHorizontalSyncController
接管手勢
mixin HorizontalSyncScrollMinxin {
Map<Type, GestureRecognizerFactory>? _gestureRecognizers;
Map<Type, GestureRecognizerFactory>? get gestureRecognizers =>
_gestureRecognizers;
SyncControllerMixin? get horizontalController;
SyncControllerMixin? get outerHorizontalSyncController;
ScrollPhysics? get physics;
void initGestureRecognizers() {
_gestureRecognizers = <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(),
(HorizontalDragGestureRecognizer instance) {
instance
..onDown = (DragDownDetails details) {
_handleDragDown(
details,
);
}
..onStart = (DragStartDetails details) {
_handleDragStart(
details,
);
}
..onUpdate = (DragUpdateDetails details) {
_handleDragUpdate(
details,
);
}
..onEnd = (DragEndDetails details) {
_handleDragEnd(
details,
);
}
..onCancel = () {
_handleDragCancel();
}
..minFlingDistance = physics?.minFlingDistance
..minFlingVelocity = physics?.minFlingVelocity
..maxFlingVelocity = physics?.maxFlingVelocity;
},
),
};
}
void _handleDragDown(
DragDownDetails details,
) {
outerHorizontalSyncController?.forceCancel();
horizontalController?.forceCancel();
horizontalController?.handleDragDown(details);
}
void _handleDragStart(DragStartDetails details) {
horizontalController?.handleDragStart(details);
}
void _handleDragUpdate(DragUpdateDetails details) {
_handleTabView(details);
if (outerHorizontalSyncController?.hasDrag ?? false) {
outerHorizontalSyncController!.handleDragUpdate(details);
} else {
horizontalController!.handleDragUpdate(details);
}
}
void _handleDragEnd(DragEndDetails details) {
if (outerHorizontalSyncController?.hasDrag ?? false) {
outerHorizontalSyncController!.handleDragEnd(details);
} else {
horizontalController!.handleDragEnd(details);
}
}
void _handleDragCancel() {
horizontalController?.handleDragCancel();
outerHorizontalSyncController?.handleDragCancel();
}
bool _handleTabView(DragUpdateDetails details) {
if (outerHorizontalSyncController != null) {
final double delta = details.delta.dx;
// 如果有外面的 controller,比如 TabbarView 和 PageView,
// 我們需要在表格滾動到邊界的時候,讓外部的 controller 接管手勢。
if ((delta < 0 &&
horizontalController!.extentAfter == 0 &&
outerHorizontalSyncController!.extentAfter != 0) ||
(delta > 0 &&
horizontalController!.extentBefore == 0 &&
outerHorizontalSyncController!.extentBefore != 0)) {
if (!outerHorizontalSyncController!.hasHold &&
!outerHorizontalSyncController!.hasDrag) {
outerHorizontalSyncController!.handleDragDown(null);
outerHorizontalSyncController!.handleDragStart(DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition,
sourceTimeStamp: details.sourceTimeStamp,
));
}
return true;
}
}
return false;
}
RawGestureDetector buildGestureDetector({required Widget child}) {
return RawGestureDetector(
gestures: gestureRecognizers!,
child: child,
);
}
}
複製程式碼
使用
引數 | 描述 | 預設 |
---|---|---|
frozenedColumnsCount | 鎖定列的個數 | 0 |
frozenedRowsCount | 鎖定行的個數 | 0 |
cellBuilder | 用於建立表格的回撥 | required |
headerBuilder | 用於建立表頭的回撥 | required |
columnsCount | 列的個數,必須大於0 | required |
source | FlexGrid 的資料來源 | required |
rowWrapper | 在這個回撥裡面用於裝飾 row Widget | null |
rebuildCustomScrollView | 當資料來源改變的時候是否重新 build , 它來自 [LoadingMoreCustomScrollView] | false |
controller | 垂直方向的 [ScrollController] | null |
horizontalController | 水平方向的 [SyncControllerMixin] | null |
outerHorizontalSyncController | 外部的 SyncControllerMixin , 用在 ExtendedTabBarView 或者 ExtendedPageView 上面,讓水平方法的滾動更連續 | null |
physics | 水平和垂直方法的 ScrollPhysics | null |
highPerformance | 如果為true的話, 將強制水平和垂直元素的大小,以提高滾動的效能 | false |
headerStyle | 樣式用於來描述表頭 | CellStyle.header() |
cellStyle | 樣式用於來描述表格 | CellStyle.cell() |
indicatorBuilder | 用於建立不同載入狀態的回撥, 它來自 LoadingMoreCustomScrollView | null |
extendedListDelegate | 用於設定一些擴充套件功能的設定, 它來自 LoadingMoreCustomScrollView | null |
headersBuilder | 用於建立自定義的表頭 | null |
結語
總的來說,這個元件實現不是很困難,主要是再次介紹了一下 ScrollableState
,而滾動相關的一些東西沒有展開講,留在下一篇介紹。是的,又要再水一篇關於 extended_nested_scroll_view,三年了,issue
依然都還在,而我為啥又要重構這個元件,請聽下回分解。
愛 Flutter
,愛糖果
,歡迎加入Flutter Candies,一起生產可愛的Flutter小糖果QQ群:181398081
最最後放上 Flutter Candies 全家桶,真香。