📖 背景简介
手势冲突,一个让人头疼的问题,尤其是在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