Flutter 纵享丝滑的 TabView 嵌套滚动

之前做过 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 的属性。

这里说一下,

  • SyncControllerMixin? get parent 是用户手动设置的

  • SyncControllerMixin? _parent 是通过 findAncestorStateOfType 向上找父级获取到的

  • SyncControllerMixin? _activedLinkParent 是通过计算,当前滚动的父级 TabViewSyncControllerMixin

  • 临界点判断,我们需要在 SyncScrollStateMinxin_handleDragUpdate 中对临界点进行判断。

  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,
      );
    }
  }
  • SyncControllerMixinlinkActivedParent 方法中(这里需要注意的是要考虑到阿拉伯语种,delta= -delta;)
  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

  • 对于 SyncScrollStateMinxin 的实现,我们需要注意是
  1. build 方法中使用 buildGestureDetector 包裹原本的返回的 widget,来注册手势监听。
return buildGestureDetector(child: child);
  1. 在有需要的情况下,调用来自动 link 上层。
  void _updateAncestor() {
    _pageController.unlinkParent();
    if (widget.link) {
       _pageController.linkParent<ExtendedTabBarView, _ExtendedTabBarViewState>(context);
    }
  }
  • 对于 SyncControllerMixin 的实现,按照自己情况来实现即可,下面是分别给 flex_gridextended_tabs 使用的 ScrollController
/// 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 来实现相似的功能。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容