Flutter 鎖定行列的 FlexGrid

法的空間發表於2021-08-09

前言

之前在 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小糖果QQ群:181398081

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

相關文章