前言
之前在 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小糖果
最最后放上 Flutter Candies 全家桶,真香。