聊聊Flutter中的常見滑動手勢衝突

乂乂又又發表於2021-08-31

? 背景簡介

手勢衝突,一個讓人頭疼的問題,尤其是在Flutter上。

最近我也遇到了兩個巢狀列表滑動手勢衝突的場景,搞得我有些懷疑人生~

下面讓我們一起來看下吧 ?

? 已知問題

場景 1: 帶有pinned且stretch的SliverAppBar的NestedScrollView

問題: NestedScrollView不支援外部列表過度滑動, 所以SliverAppBar的stretch效果無法被觸發

相關issue: github.com/flutter/flu…

場景 2: 帶有水平滑動ListView的TabBarView

問題: 當ListView過度滑動(滑到底部或頂部)時沒有帶動外部的TabBarView滑動

? 解決思路

對於場景 1:

首先,我們需要搞清楚NestedScrollView的內部運作原理,先從它的原始碼入手吧。

Tips:不要被NestedScrollView的2000多行原始碼嚇壞,其實關鍵的地方就幾處

NestedScrollView原始碼

NestedScrollView
class NestedScrollViewState extends State<NestedScrollView> {

  ScrollController get innerController => _coordinator!._innerController;

  ScrollController get outerController => _coordinator!._outerController;

  _NestedScrollCoordinator? _coordinator;

  @override
  void initState() {
    super.initState();
    _coordinator = _NestedScrollCoordinator(
      this,
      widget.controller,
      _handleHasScrolledBodyChanged,
      widget.floatHeaderSlivers,
    );
  }

  ...

}
複製程式碼

可以看到NestedScrollView在initState的時候初始化了一個_NestedScrollCoordinator,

然後我們可以從這個_NestedScrollCoordinator拿到innerController和outerController,分別對應內外部列表的滑動控制器。

OK,我們接著進_NestedScrollCoordinator看下他是什麼東西。

_NestedScrollCoordinator
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController {
  _NestedScrollCoordinator(
    this._state,
    this._parent,
    this._floatHeaderSlivers,
  ) {
    final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0;
    _outerController = _NestedScrollController(
      this,
      initialScrollOffset: initialScrollOffset,
      debugLabel: 'outer',
    );
    _innerController = _NestedScrollController(
      this,
      initialScrollOffset: 0.0,
      debugLabel: 'inner',
    );
  }

  late _NestedScrollController _outerController;
  late _NestedScrollController _innerController;

  _NestedScrollPosition? get _outerPosition {
    ...
  }

  Iterable<_NestedScrollPosition> get _innerPositions {
    ...
  }


  ScrollActivity createOuterBallisticScrollActivity(double velocity) {
    ...
  }

  @protected
  ScrollActivity createInnerBallisticScrollActivity(_NestedScrollPosition position, double velocity) {
    ...
  }

  @override
  void applyUserOffset(double delta) {
    ...
  }
}
複製程式碼

可以看到_NestedScrollCoordinator在初始化的時候建立了_innerController和_outerController,

它們都是_NestedScrollController,讓我們繼續跟下看看 ?

_NestedScrollController
class _NestedScrollController extends ScrollController {

  ...

  @override
  ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition? oldPosition,
  ) {
    return _NestedScrollPosition(
      coordinator: coordinator,
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }

  Iterable<_NestedScrollPosition> get nestedPositions sync* {
    yield* Iterable.castFrom<ScrollPosition, _NestedScrollPosition>(positions);
  }
}
複製程式碼

這裡的_NestedScrollController重寫了createScrollPosition方法,生成了_NestedScrollPosition,

並通過nestedPositions將附加到當前ScrollController上的ScrollPosition轉換為_NestedScrollPosition,

所以我們繼續跟下_NestedScrollPosition,看看它又是什麼東西。

_NestedScrollPosition
// The _NestedScrollPosition is used by both the inner and outer viewports of a
// NestedScrollView. It tracks the offset to use for those viewports, and knows
// about the _NestedScrollCoordinator, so that when activities are triggered on
// this class, they can defer, or be influenced by, the coordinator.
class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDelegate {

  ...

  final _NestedScrollCoordinator coordinator;

  @override
  double applyUserOffset(double delta) {
    ...
  }

  // This is called by activities when they finish their work.
  @override
  void goIdle() {
    ...
  }

  // This is called by activities when they finish their work and want to go
  // ballistic.
  @override
  void goBallistic(double velocity) {
    ...
  }

  ScrollActivity createBallisticScrollActivity(
    Simulation? simulation, {
    required _NestedBallisticScrollActivityMode mode,
    _NestedScrollMetrics? metrics,
  }) {
    ...
    switch (mode) {
      case _NestedBallisticScrollActivityMode.outer:
        return _NestedOuterBallisticScrollActivity(
          coordinator,
          this,
          metrics,
          simulation,
          context.vsync,
        );
      case _NestedBallisticScrollActivityMode.inner:
        return _NestedInnerBallisticScrollActivity(
          coordinator,
          this,
          simulation,
          context.vsync,
        );
      case _NestedBallisticScrollActivityMode.independent:
        return BallisticScrollActivity(this, simulation, context.vsync);
    }
  }

  ...

  @override
  void jumpTo(double value) {
    return coordinator.jumpTo(coordinator.unnestOffset(value, this));
  }

  @override
  ScrollHoldController hold(VoidCallback holdCancelCallback) {
    return coordinator.hold(holdCancelCallback);
  }

  @override
  Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
    return coordinator.drag(details, dragCancelCallback);
  }
}
複製程式碼

_NestedScrollPosition實現了ScrollActivityDelegate,並把相關的滑動事件轉發到_NestedScrollCoordinator處理,可見_NestedScrollCoordinator實際上是內外滑動列表的手勢協調器。

這裡的createBallisticScrollActivity方法,對內外滑動列表分別返回了_NestedInnerBallisticScrollActivity、_NestedOuterBallisticScrollActivity。

讓我們繼續跟下看看。

_NestedBallisticScrollActivity
class _NestedInnerBallisticScrollActivity extends BallisticScrollActivity {

  ...

  final _NestedScrollCoordinator coordinator;

  @override
  bool applyMoveTo(double value) {
    return super.applyMoveTo(coordinator.nestOffset(value, delegate));
  }
}

class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity {

  ...

  final _NestedScrollCoordinator coordinator;
  final _NestedScrollMetrics metrics;

  @override
  bool applyMoveTo(double value) {
    ...
  }
}
複製程式碼

這裡的_NestedInnerBallisticScrollActivity和_NestedOuterBallisticScrollActivity主要重寫了BallisticScrollActivity的applyMoveTo方法,

將內外部滑動列表上的彈道模擬值交由_NestedScrollCoordinator協調器處理。

連在一起

OK,我們已經知道了NestedScrollView內部的幾個重要類,以及它們的建立流程。

總結下就是,NestedScrollView在initState時建立了一個_NestedScrollCoordinator,

並從coordinator中取出_innerController和_outerController分配給內部和外部滑動列表,

內外列表發生滑動事件時會通過_NestedScrollPosition和_NestedBallisticScrollActivity等把相應事件轉發給coordinator處理,

所以coordinator才能協調內外列表的滑動過程,讓它們無縫銜接起來。

現在我們回過頭來看下_NestedScrollCoordinator是怎麼協調outer跟inner二者之間的滑動過程的。

滑動過程分析

對於ScrollActivity,作用在列表上主要表現在兩個部分:

  1. applyUserOffset,使用者手指接觸螢幕時的滑動

  2. goBallistic,使用者手指離開螢幕後的慣性滑動

Tips:這部分比較枯燥,讀不下去的可以直接看最後的解決方法

applyUserOffset

首先分析下_NestedScrollCoordinator的applyUserOffset方法

  @override
  void applyUserOffset(double delta) {
    //更新滑動方向
    updateUserScrollDirection(
      delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse,
    );
    if (_innerPositions.isEmpty) {
      //內部列表尚未附加,由外部列表消耗全部滑動量
      _outerPosition!.applyFullDragUpdate(delta);
    } else if (delta < 0.0) {
      // 手指上滑
      double outerDelta = delta;
      for (final _NestedScrollPosition position in _innerPositions) {
        //內部列表頂部overscroll
        if (position.pixels < 0.0) {
          // 內部列表消耗上滑量,直到不再overscroll
          final double potentialOuterDelta = position.applyClampedDragUpdate(delta);
          outerDelta = math.max(outerDelta, potentialOuterDelta);
        }
      }
      if (outerDelta != 0.0) {
        //外部列表消耗剩餘下滑量,不允許overscroll
        final double innerDelta = _outerPosition!.applyClampedDragUpdate(
          outerDelta,
        );
        if (innerDelta != 0.0) {
          //內部列表全量消耗剩餘下滑量
          for (final _NestedScrollPosition position in _innerPositions)
            position.applyFullDragUpdate(innerDelta);
        }
      }
    } else {
      // 手指下滑
      double innerDelta = delta;
      // 如果外部列表的頭部是float的,則由外部列表先消耗下滑量,不允許overscroll
      if (_floatHeaderSlivers)
        innerDelta = _outerPosition!.applyClampedDragUpdate(delta);
      if (innerDelta != 0.0) {
        double outerDelta = 0.0;
        final List<double> overscrolls = <double>[];
        final List<_NestedScrollPosition> innerPositions =  _innerPositions.toList();
        //內部列表先消耗下滑量,不允許overscroll
        for (final _NestedScrollPosition position in innerPositions) {
          final double overscroll = position.applyClampedDragUpdate(innerDelta);
          outerDelta = math.max(outerDelta, overscroll);
          overscrolls.add(overscroll);
        }
        //外部列表消耗剩餘下滑量,不允許overscroll
        if (outerDelta != 0.0)
          outerDelta -= _outerPosition!.applyClampedDragUpdate(outerDelta);
        //內部列表全量消耗剩餘下滑量
        for (int i = 0; i < innerPositions.length; ++i) {
          final double remainingDelta = overscrolls[i] - outerDelta;
          if (remainingDelta > 0.0)
            innerPositions[i].applyFullDragUpdate(remainingDelta);
        }
      }
    }
  }
複製程式碼

符合NestedScrollView當前的行為:

外部列表不允許overscroll,內部列表可以overscroll,外部列表不可滑後,繼續滾動內部列表。

goBallistic
  @override
  void goBallistic(double velocity) {
    beginActivity(
      createOuterBallisticScrollActivity(velocity),
      (_NestedScrollPosition position) {
        return createInnerBallisticScrollActivity(
          position,
          velocity,
        );
      },
    );
  }
複製程式碼

在goBallistic階段_NestedScrollCoordinator分別通過createInnerBallisticScrollActivity和createOuterBallisticScrollActivity方法,

在內外列表上建立了慣性滑動活動,下面讓我們一起來看下這兩個方法。

  ScrollActivity createOuterBallisticScrollActivity(double velocity) {

    ...

    final _NestedScrollMetrics metrics = _getMetrics(innerPosition, velocity);

    return _outerPosition!.createBallisticScrollActivity(
      _outerPosition!.physics.createBallisticSimulation(metrics, velocity),
      mode: _NestedBallisticScrollActivityMode.outer,
      metrics: metrics,
    );
  }

  @protected
  ScrollActivity createInnerBallisticScrollActivity(_NestedScrollPosition position, double velocity) {
    return position.createBallisticScrollActivity(
      position.physics.createBallisticSimulation(
        _getMetrics(position, velocity),
        velocity,
      ),
      mode: _NestedBallisticScrollActivityMode.inner,
    );
  }

  _NestedScrollMetrics _getMetrics(_NestedScrollPosition innerPosition, double velocity) {

    ...

    return _NestedScrollMetrics(
      minScrollExtent: _outerPosition!.minScrollExtent,
      maxScrollExtent: _outerPosition!.maxScrollExtent + innerPosition.maxScrollExtent - innerPosition.minScrollExtent + extra,
      pixels: pixels,
      viewportDimension: _outerPosition!.viewportDimension,
      axisDirection: _outerPosition!.axisDirection,
      minRange: minRange,
      maxRange: maxRange,
      correctionOffset: correctionOffset,
    );
  }
複製程式碼

這兩個方法的核心是讓_innerPosition和_outerPosition以_innerPosition為基準,在內外列表的聯合軌道上建立慣性滑動。

這裡的_getMetrics方法是用來根據_innerPosition建立內外列表的聯合軌道的。

通俗一點講就是,把內外列表可滑動空間連線起來看成一個整體的可滑動空間。

現在讓我們把目光收回到_NestedBallisticScrollActivity的applyMoveTo方法上,它是內外列表慣性滑動的最終執行者。

class _NestedInnerBallisticScrollActivity extends BallisticScrollActivity {

  ...

  final _NestedScrollCoordinator coordinator;

  @override
  bool applyMoveTo(double value) {
    return super.applyMoveTo(coordinator.nestOffset(value, delegate));
  }
}
複製程式碼

注意這裡的coordinator.nestOffset,它的作用是把coordinator中的聯合軌道上的位置對映到對應的inner、outer列表中的位置。

  double nestOffset(double value, _NestedScrollPosition target) {
    if (target == _outerPosition)
      //外部列表不允許overscroll
      return value.clamp(
        _outerPosition!.minScrollExtent,
        _outerPosition!.maxScrollExtent,
      );
    if (value < _outerPosition!.minScrollExtent)
      return value - _outerPosition!.minScrollExtent + target.minScrollExtent;
    if (value > _outerPosition!.maxScrollExtent)
      return value - _outerPosition!.maxScrollExtent + target.minScrollExtent;
    return target.minScrollExtent;
  }
複製程式碼

既然有從coordinator到inner、outer中位置的對映,自然也有從inner、outer中位置到coordinator的對映。

  double unnestOffset(double value, _NestedScrollPosition source) {
    if (source == _outerPosition)
      //外部列表不允許overscroll
      return value.clamp(
        _outerPosition!.minScrollExtent,
        _outerPosition!.maxScrollExtent,
      );
    if (value < source.minScrollExtent)
      return value - source.minScrollExtent + _outerPosition!.minScrollExtent;
    return value - source.minScrollExtent + _outerPosition!.maxScrollExtent;
  }
複製程式碼

解決方法

通過上面的層層分析可知,我們只需要:

  1. 重寫_NestedScrollCoordinator的applyUserOffset方法,允許_outerPosition的頂部過度滑動。

  2. 重寫_NestedScrollCoordinator的unnestOffset、nestOffset、_getMetrics方法,

修正_innerPosition與_outerPosition到_NestedScrollPosition(Coordinator)之間的對映關係。

即可讓NestedScrollView支援帶有stretch的SliverAppBar的NestedScrollView。

對於場景 2:

首先,這個問題的解決方法有很多種,比較容易實現的是ExtendedTabBarView那種:

當內部的列表開始overscroll時,如果外部Tab還沒有overscroll,則將使用者的過度滑動量通過外部Tab的drag方法作用到外部。

不過這種方法並沒有像NestedScrollView那樣在內外Tab滑動列表之間建立聯合軌道,完美協調內外列表的滑動過程。

而僅僅只是通過drag方法勾通內外列表的滑動過程,必然會存在各種各樣的小問題,不過時間有限,我們這裡不再深究。

解決方法

參考ExtendedTabBarView,新增TabScrollView,繫結ScrollController,

當內部列表過度滑動時,將過度滑動量作用到外部可滾動ExtendedTabBarView上。

? 元件封裝

限於篇幅,這裡我只貼出關鍵程式碼,完整程式碼可以檢視文章底部的專案地址。

對於場景 1:

class _NestedScrollCoordinatorX extends _NestedScrollCoordinator {

  ...

  @override
  _NestedScrollMetrics _getMetrics(
      _NestedScrollPosition innerPosition, double velocity) {
    return _NestedScrollMetrics(
      minScrollExtent: _outerPosition!.minScrollExtent, 
      maxScrollExtent: _outerPosition!.maxScrollExtent + (innerPosition.maxScrollExtent - innerPosition.minScrollExtent), 
      pixels: unnestOffset(innerPosition.pixels, innerPosition), 
      viewportDimension: _outerPosition!.viewportDimension, 
      axisDirection: _outerPosition!.axisDirection,
      minRange: 0,
      maxRange: 0,
      correctionOffset: 0,
    );
  }

  @override
  double unnestOffset(double value, _NestedScrollPosition source) {
    if (source == _outerPosition) {
      if (_innerPosition!.pixels > _innerPosition!.minScrollExtent) {
        //inner在滾動,以inner位置為基準
        return source.maxScrollExtent + _innerPosition!.pixels - _innerPosition!.minScrollExtent;
      }
      return value;
    } else {
      if (_outerPosition!.pixels < _outerPosition!.maxScrollExtent) {
        //outer在滾動,以outer位置為基準
        return _outerPosition!.pixels;
      }
      return _outerPosition!.maxScrollExtent + (value - source.minScrollExtent);
    }
  }

  @override
  double nestOffset(double value, _NestedScrollPosition target) {
    if (target == _outerPosition) {
      if (value > _outerPosition!.maxScrollExtent) {
        //不允許outer底部overscroll
        return _outerPosition!.maxScrollExtent;
      }
      return value;
    } else {
      if (value < _outerPosition!.maxScrollExtent) {
        //不允許innner頂部overscroll
        return target.minScrollExtent;
      }
      return (target.minScrollExtent +
          (value - _outerPosition!.maxScrollExtent));
    }
  }

  @override
  void applyUserOffset(double delta) {
    ...
    if (delta < 0.0) {
      ...
    } else {
      // 手指下滑
      double innerDelta = delta;
      // 如果外部列表的頭部是float的,則由外部列表先消耗下滑量,不允許overscroll
      if (_floatHeaderSlivers)
        innerDelta = _outerPosition!.applyClampedDragUpdate(delta);
      if (innerDelta != 0.0) {
        double outerDelta = 0.0;
        final List<double> overscrolls = <double>[];
        final List<_NestedScrollPosition> innerPositions =  _innerPositions.toList();
        //內部列表先消耗下滑量,不允許overscroll
        for (final _NestedScrollPosition position in innerPositions) {
          final double overscroll = position.applyClampedDragUpdate(innerDelta);
          outerDelta = math.max(outerDelta, overscroll);
          overscrolls.add(overscroll);
        }
        if (outerDelta != 0.0) {
          //外部列表全量消耗剩餘下滑量
          _outerPosition!.applyFullDragUpdate(outerDelta);
        }
      }
    }
  }
}

class _NestedBallisticScrollActivityX extends BallisticScrollActivity {

  ...

  @override
  bool applyMoveTo(double value) {
    return super.applyMoveTo(coordinator.nestOffset(value, delegate));
  }
}
複製程式碼

對於場景 2:

class _TabScrollViewState extends State<TabScrollView> {

  ...

  @override
  Widget build(BuildContext context) {
    return _canDrag
        ? RawGestureDetector(
            gestures: _gestureRecognizers!,
            behavior: HitTestBehavior.opaque,
            child: AbsorbPointer(
              child: widget.child, //遮蔽內部滑動列表的滑動手勢,交給RawGestureDetector去處理拖拽量
            ),
          )
        : widget.child;
  }

  void _handleDragUpdate(DragUpdateDetails details) {
    _handleAncestor(details, _ancestor);
    if (_ancestor?._drag != null) {
      _ancestor!._drag!.update(details);
    } else {
      _drag?.update(details);
    }
  }

  _ExtendedTabBarViewState? _ancestorCanDrag(DragUpdateDetails details, _ExtendedTabBarViewState? state) {
    var ancestor = state;
    final delta = widget.scrollDirection == Axis.horizontal
        ? details.delta.dx
        : details.delta.dy;
    if (delta < 0) {
      while (ancestor != null) {
        if (ancestor._position?.extentAfter != 0) {
          return ancestor;
        }
        ancestor = ancestor._ancestor;
      }
    }
    if (delta > 0) {
      while (ancestor != null) {
        if (ancestor._position?.extentBefore != 0) {
          return ancestor;
        }
        ancestor = ancestor._ancestor;
      }
    }
    return null;
  }

  bool _handleAncestor(DragUpdateDetails details, _ExtendedTabBarViewState? state) {
    if (state?._position != null) {
      final delta = widget.scrollDirection == Axis.horizontal
          ? details.delta.dx
          : details.delta.dy;
      //當前過滑
      if ((delta < 0 &&
              _position?.extentAfter == 0 &&
              _ancestorCanDrag(details, state) != null) ||
          (delta > 0 &&
              _position?.extentBefore == 0 &&
              _ancestorCanDrag(details, state) != null)) {
        state = _ancestorCanDrag(details, state)!;
        if (state.widget.scrollDirection == widget.scrollDirection) {
          if (state._drag == null && state._hold == null) {
            state._handleDragDown(null);
          }
          if (state._drag == null) {
            state._handleDragStart(DragStartDetails(
              globalPosition: details.globalPosition,
              localPosition: details.localPosition,
              sourceTimeStamp: details.sourceTimeStamp,
            ));
          }
          return true;
        }
      }
    }
    return false;
  }
}
複製程式碼

? 專案地址

更多細節請戳 ? 網頁連結

? 線上預覽

開啟網頁檢視效果 ? 網頁連結

❤️ 鳴謝

非常感謝fluttercandiesextended_tabsextended_nested_scroll_view

? 參考資料

相關文章