Flutter 锁定行列的 FlexGrid

3,163 阅读6分钟

前言

之前在 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 中水平滚动连贯
  • 大量数据的高性能
  • 刷新动画和增量加载
FrozenedRowColumn.gifTabView.gifHugeData.gif
Excel.gifStockList.gif

原理

有一说一,对于设计这个组件,几乎没有任何难点,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 呢?

为此我创建了一个用于同步 ScrollPositionSyncControllerMixin.

  • attach 的时候创建对应的 DragHoldController 并且同步 positionpixels
  • holddrag 相关方法中去同步滚动
  • 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 中控制水平的同步滚动。如果达到滚动边界, 外部有 TabbarViewPageView 的话,将让外部传入 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列的个数,必须大于0required
sourceFlexGrid 的数据源required
rowWrapper在这个回调里面用于装饰 row Widgetnull
rebuildCustomScrollView当数据源改变的时候是否重新 build , 它来自 [LoadingMoreCustomScrollView]false
controller垂直方向的 [ScrollController]null
horizontalController水平方向的 [SyncControllerMixin]null
outerHorizontalSyncController外部的 SyncControllerMixin, 用在 ExtendedTabBarView 或者 ExtendedPageView 上面,让水平方法的滚动更连续null
physics水平和垂直方法的 ScrollPhysicsnull
highPerformance如果为true的话, 将强制水平和垂直元素的大小,以提高滚动的性能false
headerStyle样式用于来描述表头CellStyle.header()
cellStyle样式用于来描述表格CellStyle.cell()
indicatorBuilder用于创建不同加载状态的回调, 它来自 LoadingMoreCustomScrollViewnull
extendedListDelegate用于设置一些扩展功能的设置, 它来自 LoadingMoreCustomScrollViewnull
headersBuilder用于创建自定义的表头null

结语

总的来说,这个组件实现不是很困难,主要是再次介绍了一下 ScrollableState ,而滚动相关的一些东西没有展开讲,留在下一篇介绍。是的,又要再水一篇关于 extended_nested_scroll_view,三年了,issue 依然都还在,而我为啥又要重构这个组件,请听下回分解。

Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果

最最后放上 Flutter Candies 全家桶,真香。