Flutter 纵享丝滑的 TabView 嵌套滚动

2022-08-19  本文已影响0人  法的空间

之前做过 flex_grid | Flutter Package (flutter-io.cn) 支持锁定行列,并且可以在 extended_tabs | Flutter Package (flutter-io.cn) 中连续滚动。但是当时只做了一层,没有去考虑做多层 extended_tabs中的情况。

这次将 flex_gridextended_tabs 关于同步滚动和嵌套滚动的代码进行了重构,封装成了新的库 sync_scroll_library | Flutter Package (flutter-io.cn),大家也可以方便的使用该库做出满足自身需求的同步滚动和嵌套滚动效果。

原理

ScrollableState

首先我们还是来复习下 ScrollableState,Flutter 是怎么通过手势去控制列表滚动的。

  @override
  @protected
  void setCanDrag(bool value) {
    if (value == _lastCanDrag && (!value || widget.axis == _lastAxisDirection))
      return;
    if (!value) {
      _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
      // Cancel the active hold/drag (if any) because the gesture recognizers
      // will soon be disposed by our RawGestureDetector, and we won't be
      // receiving pointer up events to cancel the hold/drag.
      _handleDragCancel();
    } else {
      switch (widget.axis) {
        case Axis.vertical:
          _gestureRecognizers = <Type, GestureRecognizerFactory>{
            VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
              () => VerticalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
              (VerticalDragGestureRecognizer instance) {
                instance
                  ..onDown = _handleDragDown
                  ..onStart = _handleDragStart
                  ..onUpdate = _handleDragUpdate
                  ..onEnd = _handleDragEnd
                  ..onCancel = _handleDragCancel
                  ..minFlingDistance = _physics?.minFlingDistance
                  ..minFlingVelocity = _physics?.minFlingVelocity
                  ..maxFlingVelocity = _physics?.maxFlingVelocity
                  ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
                  ..dragStartBehavior = widget.dragStartBehavior
                  ..gestureSettings = _mediaQueryData?.gestureSettings;
              },
            ),
          };
          break;
        case Axis.horizontal:
          _gestureRecognizers = <Type, GestureRecognizerFactory>{
            HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
              () => HorizontalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
              (HorizontalDragGestureRecognizer instance) {
                instance
                  ..onDown = _handleDragDown
                  ..onStart = _handleDragStart
                  ..onUpdate = _handleDragUpdate
                  ..onEnd = _handleDragEnd
                  ..onCancel = _handleDragCancel
                  ..minFlingDistance = _physics?.minFlingDistance
                  ..minFlingVelocity = _physics?.minFlingVelocity
                  ..maxFlingVelocity = _physics?.maxFlingVelocity
                  ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
                  ..dragStartBehavior = widget.dragStartBehavior
                  ..gestureSettings = _mediaQueryData?.gestureSettings;
              },
            ),
          };
          break;
      }
    }
    _lastCanDrag = value;
    _lastAxisDirection = widget.axis;
    if (_gestureDetectorKey.currentState != null)
      _gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
  }
  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;
  }

_handleDragDown_handleDragStart 的时候分别对当前 position 生成
_hold_drag

一般默认的 position 都是一个 ScrollPositionWithSingleContextholddrag 方法的实现为下:

  @override
  ScrollHoldController hold(VoidCallback holdCancelCallback) {
    final double previousVelocity = activity!.velocity;
    final HoldScrollActivity holdActivity = HoldScrollActivity(
      delegate: this,
      onHoldCanceled: holdCancelCallback,
    );
    beginActivity(holdActivity);
    _heldPreviousVelocity = previousVelocity;
    return holdActivity;
  }

  ScrollDragController? _currentDrag;

  @override
  Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
    final ScrollDragController drag = ScrollDragController(
      delegate: this,
      details: details,
      onDragCanceled: dragCancelCallback,
      carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
      motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold,
    );
    beginActivity(DragScrollActivity(this, drag));
    assert(_currentDrag == null);
    _currentDrag = drag;
    return drag;
  }

ScrollHoldControllerScrollDragController 负责处理后续的操作。

整个大概流程为:

graph TD
_handleDragDown --> _handleDragStart并且_disposeHold --> _handleDragUpdate --> _handleDragEnd --> _disposeDrag

_handleDragDown --> _handleDragCancel --> _disposeHold
_handleDragCancel --> _disposeDrag

知道原理之后,我们其实就可以自己做一套这个东西,然后屏蔽掉底层的逻辑(比如给 Scrollablephysics 设置为 NeverScrollableScrollPhysics),这样手势就被我们给拿捏了。

SyncScrollStateMinxin

因为注册事件这部分都是一样的东西,所以抽成一个 SyncScrollStateMinxin


mixin SyncScrollStateMinxin<T extends StatefulWidget> on State<T> {
  Map<Type, GestureRecognizerFactory>? _gestureRecognizers;
  Map<Type, GestureRecognizerFactory>? get gestureRecognizers =>
      _gestureRecognizers;
  SyncControllerMixin get syncController;
  // widget.physics
  ScrollPhysics? get physics;

  TextDirection? get textDirection => Directionality.maybeOf(context);

  Axis get scrollDirection;
  bool get canDrag => physics?.shouldAcceptUserOffset(_testPageMetrics) ?? true;
  ScrollPhysics? get usedScrollPhysics => _physics;
  ScrollPhysics? _physics;

  @override
  //@mustCallSuper
  void didChangeDependencies() {
    super.didChangeDependencies();
    updatePhysics();
    initGestureRecognizers();
  }

  // Only call this from places that will definitely trigger a rebuild.
  void updatePhysics() {
    _physics = getScrollPhysics();
  }

  ScrollPhysics? getScrollPhysics() {
    final ScrollBehavior configuration = ScrollConfiguration.of(context);
    ScrollPhysics temp = configuration.getScrollPhysics(context);
    if (physics != null) {
      temp = physics!.applyTo(temp);
    }
    return temp;
  }

  void initGestureRecognizers() {
    if (canDrag) {
      switch (scrollDirection) {
        case Axis.horizontal:
          _gestureRecognizers = <Type, GestureRecognizerFactory>{
            HorizontalDragGestureRecognizer:
                GestureRecognizerFactoryWithHandlers<
                    HorizontalDragGestureRecognizer>(
              () => HorizontalDragGestureRecognizer(),
              (HorizontalDragGestureRecognizer instance) {
                instance
                  ..onDown = _handleDragDown
                  ..onStart = _handleDragStart
                  ..onUpdate = _handleDragUpdate
                  ..onEnd = _handleDragEnd
                  ..onCancel = _handleDragCancel
                  ..minFlingDistance = _physics?.minFlingDistance
                  ..minFlingVelocity = _physics?.minFlingVelocity
                  ..maxFlingVelocity = _physics?.maxFlingVelocity;
              },
            ),
          };
          break;

        case Axis.vertical:
          _gestureRecognizers = <Type, GestureRecognizerFactory>{
            VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                VerticalDragGestureRecognizer>(
              () => VerticalDragGestureRecognizer(),
              (VerticalDragGestureRecognizer instance) {
                instance
                  ..onDown = _handleDragDown
                  ..onStart = _handleDragStart
                  ..onUpdate = _handleDragUpdate
                  ..onEnd = _handleDragEnd
                  ..onCancel = _handleDragCancel
                  ..minFlingDistance = _physics?.minFlingDistance
                  ..minFlingVelocity = _physics?.minFlingVelocity
                  ..maxFlingVelocity = _physics?.maxFlingVelocity;
              },
            ),
          };
          break;
        default:
      }
    } else {
      _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
      syncController.forceCancel();
    }
  }

  void _handleDragDown(DragDownDetails details) {

  }

  void _handleDragStart(DragStartDetails details) {

  }

  void _handleDragUpdate(DragUpdateDetails details) {
  
  }

  void _handleDragEnd(DragEndDetails details) {

  }

  void _handleDragCancel() {
   
  }

  Widget buildGestureDetector({required Widget child}) {
    if (_gestureRecognizers == null) {
      return child;
    }
    return RawGestureDetector(
      gestures: _gestureRecognizers!,
      behavior: HitTestBehavior.opaque,
      child: child,
    );
  }
}

同步滚动

flex_grid 的水平滚动,是有多个 CustomScrollView 共享同一个 ScrollController,然后同步多个 position 来实现的。

DragHoldController

我们先写一个 DragHoldController,对事件进行统一的处理,

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;
}

SyncControllerMixin

创建一个 SyncControllerMixin ,它继承于 ScrollController

利用一个 map 来存放当前 attachposition

当水平滚动 position attach 的时候,我们将它和其他已经attachposition 进行同步, 并且让它与 DragHoldController 关联起来。

/// The mixin for [ScrollController] to sync pixels for all of positions
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) {
    assert(_positionToListener.containsKey(position));
    _positionToListener[position]!.forceCancel();
    _positionToListener.remove(position);

    super.detach(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) {
        if (!item.hasDrag) {
          item.handleDragStart(
            DragStartDetails(
              globalPosition: details.globalPosition,
              localPosition: details.localPosition,
              sourceTimeStamp: details.sourceTimeStamp,
            ),
          );
        }
        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();
    }
  }

当接受到各种事件的时候,将这些事件,同步给各个激活的 DragHoldController,这样我们就达到了让同一个 ScrollController 里面的 position 同步滚动的效果。

嵌套滚动

嵌套滚动,这里是只当前一级的 TabView 不能滚动的情况下,去查看它父级的 TabView 是否能滚动,如果能滚动,继续滚动父级的 TabView。这里还是在 SyncControllerMixin 中,我们为它添加 parent 的属性。

这里说一下,

  void _handleParentController(DragUpdateDetails details) {
    if (syncController.parentIsNotNull) {
      final double delta = scrollDirection == Axis.horizontal
          ? details.delta.dx
          : details.delta.dy;

      syncController.linkActivedParent(
        delta,
        details,
        textDirection ?? TextDirection.ltr,
      );
    }
  }
  1. delta < 0 && _extentAfter == 0, 一层层向上找 parent._extentAfter是否满足不为 0

  2. delta > 0 && _extentBefore == 0, 一层层向上找 parent._extentBefore是否满足不为 0

  3. 如果找到之后,将 _activedLinkParent 设置成找到的 parent, 在 handleDragCancel 之前,全部的事件将都由 _activedLinkParent 来处理。

/// The mixin for [ScrollController] to sync pixels for all of positions
mixin SyncControllerMixin on ScrollController {
  final Map<ScrollPosition, DragHoldController> _positionToListener =
      <ScrollPosition, DragHoldController>{};

  // The parent from user
  SyncControllerMixin? get parent;
  // The parent from link
  SyncControllerMixin? _parent;

  // The actual used parent
  SyncControllerMixin? get _internalParent => parent ?? _parent;

  // The current actived controller
  SyncControllerMixin? _activedLinkParent;

  bool get parentIsNotNull => _internalParent != null;

  bool get isSelf => _activedLinkParent == null;

  void linkParent<S extends StatefulWidget, T extends SyncScrollStateMinxin<S>>(
      BuildContext context) {
    _parent = context.findAncestorStateOfType<T>()?.syncController;
  }

  void unlinkParent() {
    _parent = null;
  }

  @override
  void dispose() {
    forceCancel();
    super.dispose();
  }

  void handleDragUpdate(DragUpdateDetails details) {
    if (_activedLinkParent != null && _activedLinkParent!.hasDrag) {
      _activedLinkParent!.handleDragUpdate(details);
    } else {
      for (final DragHoldController item in _positionToListener.values) {
        if (!item.hasDrag) {
          item.handleDragStart(
            DragStartDetails(
              globalPosition: details.globalPosition,
              localPosition: details.localPosition,
              sourceTimeStamp: details.sourceTimeStamp,
            ),
          );
        }
        item.handleDragUpdate(details);
      }
    }
  }

  void handleDragEnd(DragEndDetails details) {
    _activedLinkParent?.handleDragEnd(details);

    for (final DragHoldController item in _positionToListener.values) {
      item.handleDragEnd(details);
    }
  }

  void handleDragCancel() {
    _activedLinkParent?.handleDragCancel();
    _activedLinkParent = null;

    for (final DragHoldController item in _positionToListener.values) {
      item.handleDragCancel();
    }
  }

  void forceCancel() {
    _activedLinkParent?.forceCancel();
    _activedLinkParent = null;

    for (final DragHoldController item in _positionToListener.values) {
      item.forceCancel();
    }
  }

  double get extentAfter => _activedLinkParent != null
      ? _activedLinkParent!.extentAfter
      : _extentAfter;

  double get extentBefore => _activedLinkParent != null
      ? _activedLinkParent!.extentBefore
      : _extentBefore;

  double get _extentAfter => _positionToListener.keys.isEmpty
      ? 0
      : _positionToListener.keys.first.extentAfter;

  double get _extentBefore => _positionToListener.keys.isEmpty
      ? 0
      : _positionToListener.keys.first.extentBefore;

  bool get hasDrag =>
      _activedLinkParent != null ? _activedLinkParent!.hasDrag : _hasDrag;
  bool get hasHold =>
      _activedLinkParent != null ? _activedLinkParent!.hasHold : _hasHold;

  bool get _hasDrag => _positionToListener.values
      .any((DragHoldController element) => element.hasDrag);
  bool get _hasHold => _positionToListener.values
      .any((DragHoldController element) => element.hasHold);

  SyncControllerMixin? _findParent(bool test(SyncControllerMixin parent)) {
    if (_internalParent == null) {
      return null;
    }
    if (test(_internalParent!)) {
      return _internalParent!;
    }

    return _internalParent!._findParent(test);
  }

  void linkActivedParent(
    double delta,
    DragUpdateDetails details,
    TextDirection textDirection,
  ) {
    if (_activedLinkParent != null) {
      return;
    }
    SyncControllerMixin? activedParent;
    if (textDirection == TextDirection.rtl) {
      delta = -delta;
    }

    if (delta < 0 && _extentAfter == 0) {
      activedParent =
          _findParent((SyncControllerMixin parent) => parent._extentAfter != 0);
    } else if (delta > 0 && _extentBefore == 0) {
      activedParent = _findParent(
          (SyncControllerMixin parent) => parent._extentBefore != 0);
    }

    if (activedParent != null) {
      _activedLinkParent = activedParent;
      activedParent.handleDragDown(null);
      activedParent.handleDragStart(
        DragStartDetails(
          globalPosition: details.globalPosition,
          localPosition: details.localPosition,
          sourceTimeStamp: details.sourceTimeStamp,
        ),
      );
    }
  }
}

实现 SyncScrollStateMinxin 和 SyncControllerMixin

  1. build 方法中使用 buildGestureDetector 包裹原本的返回的 widget,来注册手势监听。
return buildGestureDetector(child: child);
  1. 在有需要的情况下,调用来自动 link 上层。
  void _updateAncestor() {
    _pageController.unlinkParent();
    if (widget.link) {
       _pageController.linkParent<ExtendedTabBarView, _ExtendedTabBarViewState>(context);
    }
  }
/// The [SyncScrollController] to sync pixels for all of positions
class SyncScrollController extends ScrollController with SyncControllerMixin {
  /// Creates a scroll controller that continually updates its
  /// [initialScrollOffset] to match the last scroll notification it received.
  SyncScrollController({
    double initialScrollOffset = 0.0,
    bool keepScrollOffset = true,
    String? debugLabel,
    this.parent,
  }) : super(
          initialScrollOffset: initialScrollOffset,
          keepScrollOffset: keepScrollOffset,
          debugLabel: debugLabel,
        );

  /// The Outer SyncScrollController, for example [ExtendedTabBarView] or [ExtendedPageView]
  /// It make better experience when scroll on horizontal direction
  @override
  final SyncControllerMixin? parent;
}

/// The [SyncPageController] to scroll Pages(PageView or TabBarView) when [FlexGrid] is reach the horizontal boundary
class SyncPageController extends PageController with SyncControllerMixin {
  /// Creates a page controller.
  ///
  /// The [initialPage], [keepPage], and [viewportFraction] arguments must not be null.
  SyncPageController({
    int initialPage = 0,
    bool keepPage = true,
    double viewportFraction = 1.0,
    this.parent,
  }) : super(
          initialPage: initialPage,
          keepPage: keepPage,
          viewportFraction: viewportFraction,
        );

  /// The Outer SyncScrollController, for example [ExtendedTabBarView] or [ExtendedPageView]
  /// It make better experience when scroll on horizontal direction
  @override
  final SyncControllerMixin? parent;
}

滚动冲突

这种现象是在快速滚动 extended_tabs,即使当前 tab 里面有能滚动的内容,比如 flex_grid,它也不会优先滚动 flex_grid,而会直接跳过到下一个 tab。之前我们是通过加快滚动结束的动画来缓解这种现象。

mixin LessSpringScrollPhysics on ScrollPhysics {
  @override
  SpringDescription get spring => SpringDescription.withDampingRatio(
        mass: 0.5,
        stiffness: 1000.0, // Increase this value as you wish.
        ratio: 1.1,
      );
}

class LessSpringClampingScrollPhysics extends ClampingScrollPhysics
    with LessSpringScrollPhysics {
  const LessSpringClampingScrollPhysics()
      : super(parent: const ClampingScrollPhysics());
}

实际上,这个问题在 Flutter挑战之手势冲突 - 掘金 (juejin.cn) 中说的很清楚了。Flutter 中的 Scrollable 在滚动过程中会将 childhittest 禁止掉。你感觉滚动已经停止了,但是依然操作不了其中的 flex_grid

解决办法也很简单,extended_tabs 没有额外的其他需求,相比 extended_image | Flutter Package (flutter-io.cn) 不需要重写太多东西。

我们只需要重写掉 extended_tabs 中使用的 Scrollable,并且控制 setIgnorePointer 是否执行即可。

class ExtendedScrollable extends Scrollable {
  const ExtendedScrollable({
    Key? key,
    AxisDirection axisDirection = AxisDirection.down,
    ScrollController? controller,
    ScrollPhysics? physics,
    required ViewportBuilder viewportBuilder,
    ScrollIncrementCalculator? incrementCalculator,
    bool excludeFromSemantics = false,
    int? semanticChildCount,
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
    String? restorationId,
    ScrollBehavior? scrollBehavior,
    this.shouldIgnorePointerWhenScrolling = true,
  }) : super(
          key: key,
          axisDirection: axisDirection,
          controller: controller,
          physics: physics,
          viewportBuilder: viewportBuilder,
          incrementCalculator: incrementCalculator,
          excludeFromSemantics: excludeFromSemantics,
          semanticChildCount: semanticChildCount,
          dragStartBehavior: dragStartBehavior,
          restorationId: restorationId,
          scrollBehavior: scrollBehavior,
        );
  final bool shouldIgnorePointerWhenScrolling;
  @override
  _ExtendedScrollableState createState() => _ExtendedScrollableState();
}

class _ExtendedScrollableState extends ScrollableState {
  @override
  void setIgnorePointer(bool value) {
    final ExtendedScrollable scrollable = widget as ExtendedScrollable;
    if (scrollable.shouldIgnorePointerWhenScrolling) {
      super.setIgnorePointer(value);
    }
  }
}

对这个问题很敏感的同学,只需要将 extended_tabs
shouldIgnorePointerWhenScrolling 设置为 flase 即可。

结语

每过一段时间,回看自己的代码,就会发现会更好的方法来解决之前的问题,因为每一次都只能探索到 Flutter 的冰山一角。开源项目的乐趣就在于有很多人都能参与进来,一个人的24小时也许不够用,但是10,100个人呢? 希望大家不要吝啬自己好的开源项目,记得分享。

最后放上这三个库的地址

你可以通过 sync_scroll_library 来实现相似的功能。

上一篇下一篇

猜你喜欢

热点阅读