? 背景簡介
手勢衝突,一個讓人頭疼的問題,尤其是在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,作用在列表上主要表現在兩個部分:
-
applyUserOffset,使用者手指接觸螢幕時的滑動
-
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;
}
複製程式碼
解決方法
通過上面的層層分析可知,我們只需要:
-
重寫_NestedScrollCoordinator的applyUserOffset方法,允許_outerPosition的頂部過度滑動。
-
重寫_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;
}
}
複製程式碼
? 專案地址
更多細節請戳 ? 網頁連結
? 線上預覽
開啟網頁檢視效果 ? 網頁連結
❤️ 鳴謝
非常感謝fluttercandies的extended_tabs和extended_nested_scroll_view