Flutter 纵享丝滑的 TabView 嵌套滚动
之前做过 flex_grid | Flutter Package (flutter-io.cn) 支持锁定行列,并且可以在 extended_tabs | Flutter Package (flutter-io.cn) 中连续滚动。但是当时只做了一层,没有去考虑做多层 extended_tabs中的情况。
这次将 flex_grid 和 extended_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
都是一个 ScrollPositionWithSingleContext
,hold
和 drag
方法的实现为下:
@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;
}
ScrollHoldController
和 ScrollDragController
负责处理后续的操作。
整个大概流程为:
graph TD
_handleDragDown --> _handleDragStart并且_disposeHold --> _handleDragUpdate --> _handleDragEnd --> _disposeDrag
_handleDragDown --> _handleDragCancel --> _disposeHold
_handleDragCancel --> _disposeDrag
知道原理之后,我们其实就可以自己做一套这个东西,然后屏蔽掉底层的逻辑(比如给 Scrollable
的 physics
设置为 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
来存放当前 attach
的 position
。
当水平滚动 position
attach
的时候,我们将它和其他已经attach
的 position
进行同步, 并且让它与 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
是通过计算,当前滚动的父级TabView
的SyncControllerMixin
-
临界点判断,我们需要在
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,
);
}
}
- 在
SyncControllerMixin
的linkActivedParent
方法中(这里需要注意的是要考虑到阿拉伯语种,delta= -delta;
)
-
delta < 0 && _extentAfter == 0
, 一层层向上找parent._extentAfter
是否满足不为 0 -
delta > 0 && _extentBefore == 0
, 一层层向上找parent._extentBefore
是否满足不为 0 -
如果找到之后,将
_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
的实现,我们需要注意是
-
build
方法中使用buildGestureDetector
包裹原本的返回的 widget,来注册手势监听。
return buildGestureDetector(child: child);
- 在有需要的情况下,调用来自动 link 上层。
void _updateAncestor() {
_pageController.unlinkParent();
if (widget.link) {
_pageController.linkParent<ExtendedTabBarView, _ExtendedTabBarViewState>(context);
}
}
- 对于
SyncControllerMixin
的实现,按照自己情况来实现即可,下面是分别给 flex_grid 和 extended_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
在滚动过程中会将 child
的 hittest
禁止掉。你感觉滚动已经停止了,但是依然操作不了其中的 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个人呢? 希望大家不要吝啬自己好的开源项目,记得分享。
最后放上这三个库的地址
- extended_tabs | Flutter Package (flutter-io.cn)
- flex_grid | Flutter Package (flutter-io.cn)
- sync_scroll_library | Flutter Package (flutter-io.cn)
你可以通过 sync_scroll_library 来实现相似的功能。