Flutter 同轴嵌套滚动的边界翻页:手势移交方案与命中测试方案对比
横向列表嵌在 TabBarView 里,滑到边界后继续拖拽要像原生一样跟手翻页。对比两种思路:绕过手势竞技场的 Drag 移交方案,和在命中测试层做手脚的 HitTestBlocker 方案。
需求背景
假设 Home 页有不同频道,每个频道是一个 TabBarView,每个频道页里又有若干横向滚动的推荐列表(BouncingScrollPhysics)。产品需求:
横向列表滑到边界后,如果手指继续往外拖,就把手势”接力”给外层 TabBarView——页面跟随手指移动,可以停在任意位置、可以反向拖回,松手按原生规则吸附或甩动翻页,和直接拖 TabBarView 的手感完全一样。
难点在于内外两层滚动方向同轴(都是横向)。Flutter 里同轴嵌套时,手势永远被内层抢走,外层连参与的机会都没有。要实现”边界接力”,得先弄明白手势是怎么分配的。
预备知识:一根手指的一生
手指落在屏幕上之后,Flutter 内部经历四个阶段:
手指落下
│
▼
① 命中测试(hit test) → 沿渲染树收集"这一点上都有谁",形成一条命中路径
│ (只在落下瞬间做一次!)
▼
② 加入竞技场(gesture arena) → 路径上所有手势识别器为这根手指报名参赛
│
▼
③ 竞技场裁决 → 手指移动几像素后,识别器们抢答;
│ 同类识别器竞争时,"最里层的先抢到"
▼
④ 赢家独占 → 赢家拿到手指的全部后续事件,直到抬起;
输家出局,本次手势内不能复活
赢家(滚动组件里的 HorizontalDragGestureRecognizer)拿到手指后干的事,用伪代码写就是:
// Scrollable 内部(简化伪代码)
onDragStart(details) {
drag = position.drag(details, onCancel); // 向滚动位置要一根"缰绳"(Drag 对象)
}
onDragUpdate(details) {
drag.update(details); // 手指动多少,缰绳拉多少
}
onDragEnd(details) {
drag.end(details); // 把松手速度交给物理引擎,决定惯性/回弹/吸附
}
记住两条铁律,两个方案的成败都由它们决定:
- 铁律 A:命中测试只在手指落下时做一次,之后不能重做;
- 铁律 B:竞技场裁决后不能中途换赢家,输家对这根手指永久出局。
绕过竞技场,在”缰绳”层移交
思路
既然竞技场不许换人(铁律 B),那就不跟它玩——赢家最终也不过是调 position.drag() 拿缰绳。我们等内层列表拖到边界时:
- 剪断内层的缰绳:让内层列表对本次手势”失聪”;
- 以外层的名义拿一根新缰绳:对 TabBarView 内部 PageView 的
position调drag(); - 把旁听到的手指移动原样喂给新缰绳:外层以为是用户在正常拖它。
因为新缰绳和真手势用的是同一个对象、同一套滚动物理,所以跟手、反向、停半路、松手吸附、指示器联动,全部原生。
关键代码一:怎么”旁听”手指(不参赛也能听)
Listener 不是手势识别器,它不进竞技场,只要命中路径经过就能收到所有原始指针事件——谁赢了都拦不住它:
Listener(
onPointerDown: _onPointerDown, // 记下手指、启动测速器
onPointerMove: _onPointerMove, // 移交后:把 dx 喂给外层缰绳
onPointerUp: _onPointerUp, // 移交后:把速度交给外层做吸附/甩动
child: NotificationListener<ScrollNotification>(
onNotification: _onNotification, // 监听内层滚动汇报,判断触发时机
child: widget.builder(context, _innerController),
),
)
关键代码二:触发时机(区分”手指拖出边界”和”松手回弹”)
bool _onNotification(ScrollNotification notification) {
// 只看被包裹列表自己的横向滚动(item 里如果还有 tag 小滚动条会冒泡上来,depth > 0,排除)
if (notification.depth != 0 || notification.metrics.axis != Axis.horizontal) return false;
// dragDetails == null 说明是松手后的回弹阶段,不是手指在拖 —— 不触发
if (notification is! ScrollUpdateNotification || notification.dragDetails == null) return false;
final m = notification.metrics;
if (!m.outOfRange) return false; // bouncing 物理下,越界距离直接体现在 pixels 上
final overscroll = m.pixels > m.maxScrollExtent
? m.pixels - m.maxScrollExtent // > 0:尾部越界,往下一页方向
: m.pixels - m.minScrollExtent; // < 0:头部越界,往上一页方向
if (overscroll.abs() >= threshold) _startHandoff(overscroll, notification.dragDetails!);
return false;
}
关键代码三:剪断内层缰绳——一行 jumpTo 的妙用
这是整个方案最”巧”的一步。framework 源码里,jumpTo 会走 goIdle() → beginActivity(...),而 beginActivity 有个副作用:
// flutter/lib/src/widgets/scroll_position_with_single_context.dart(节选)
@override
void beginActivity(ScrollActivity? newActivity) {
...
super.beginActivity(newActivity);
_currentDrag?.dispose(); // ← 作废进行中的拖拽缰绳!
_currentDrag = null;
...
}
缰绳被 dispose 时会回调 onDragCanceled,Scrollable 随即把自己手里的引用置空——识别器虽然还”名义上”持有手指,但之后的汇报全部落空。列表被钉在边界,一动不动:
void _startHandoff(double overscroll, DragUpdateDetails details) {
// 找外层横向 Scrollable:跳过中间垂直方向的 CustomScrollView,命中 TabBarView 内部的 PageView
final outer = Scrollable.maybeOf(context, axis: Axis.horizontal);
if (outer == null) return;
// 外层已在第一/最后一个频道则不移交,保留内层原生回弹手感
...
// 钉回边界 + 剪断内层缰绳(beginActivity 副作用)
final innerPos = _innerController.position;
innerPos.jumpTo(overscroll > 0 ? innerPos.maxScrollExtent : innerPos.minScrollExtent);
// 以外层的名义拿一根新缰绳
_outerDrag = outerPos.drag(DragStartDetails(globalPosition: details.globalPosition),
() => _outerDrag = null);
}
关键代码四:移交后跟手 + 松手裁决
void _onPointerMove(PointerMoveEvent event) {
_velocityTracker?.addPosition(event.timeStamp, event.position); // 一路记录速度
_outerDrag?.update(DragUpdateDetails( // 手指动多少,外层页面跟多少
delta: Offset(event.delta.dx, 0),
primaryDelta: event.delta.dx,
globalPosition: event.position,
));
}
void _onPointerUp(PointerUpEvent event) {
final vx = (_velocityTracker?.getVelocity().pixelsPerSecond.dx ?? 0)
.clamp(-kMaxFlingVelocity, kMaxFlingVelocity).toDouble();
// 松手:把速度交给 PageView 自己的物理引擎 —— 过半/甩得快则翻页,否则弹回
_outerDrag?.end(DragEndDetails(velocity: Velocity(pixelsPerSecond: Offset(vx, 0)), primaryVelocity: vx));
}
指示器为什么会自动联动?因为 TabBarView 内部本来就在监听自己 PageView 的滚动通知来同步 TabController.offset——我们的编程式拖拽产生一模一样的通知流,它分不出区别。
在命中测试层做手脚(HitTestBlocker)
思路与代码
另一个直觉的想法:内层到边界后,屏蔽内层的手势竞争资格,让外层赢。现成的工具长这样:
class RenderHitTestBlocker extends RenderProxyBox {
bool up; // true:hitTest() 恒返回 false —— 子树的命中不上报给祖先
bool down; // true:不调 hitTestChildren() —— 孩子收不到任何事件
bool self; // hitTestSelf 的返回值
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
bool hitTestDownResult = false;
if (!down) hitTestDownResult = hitTestChildren(result, position: position);
bool pass = hitTestSelf(position) || (hitTestDownResult && size.contains(position));
if (pass) result.add(BoxHitTestEntry(this, position));
return !up && pass;
}
}
它是个渲染层开关,适合”浮层不挡点击""整块区域屏蔽事件”这类静态规则。但用到本需求上,三个问题全是死结。
死结一:时机不对(铁律 A)
命中测试只在手指落下的一瞬间做一次。而核心场景是:手指在列表中间按下(此时列表理应赢)→ 拖到边界 → 同一根手指继续拖 → 此时才想让外层接管。这个”此时”发生在命中测试结束很久之后:
手指落下 ──► 命中测试(一次性)──► 竞技场裁决 ──► 内层赢,独占到抬起
▲
拖到边界发生在这里 ——
hit test 早就结束了,够不着
再加上铁律 B(输家不能复活),“半路变道”在命中/竞技场层面无解。
死结二:方向未知
就算只做”落下时已在边界”的子场景:列表停在右边界,手指落下——这一瞬间不知道用户要往哪拖:
列表已停在右边界,手指落下:
往左拖 → 想把列表内容滚回来 → 手势必须归【内层】
往右拖 → 越界 → 手势应该归【外层】
方向要等手指移动几像素后才知道,而屏蔽与否必须在落下瞬间定死。一刀切屏蔽内层,会把”从边界往回滚”这个合法操作弄坏。
死结三:两个开关各有各的坏
down: true(屏蔽子树)→ 列表里的内容点击也一起死了,用户点不了里面的内容;up: true(包住内层)→ 恰好反效果。已加入命中路径的节点照常收事件,所以内层照常参赛;而返回 false 让祖先以为”这里没命中”,外层 TabBarView 的手势检测器反而不参赛——屏蔽的是外层,不是内层。
那”自定义识别器”呢?
真想在竞争层面做文章,正解不是 hit test,而是自己写一个 HorizontalDragGestureRecognizer 完全接管手势,每次移动时自己决定喂内层还是外层。但看它的伪代码:
onDragUpdate(details) {
if (该喂内层) innerDrag.update(details); // ← 还是 position.drag() 拿的缰绳
else outerDrag.update(details); // ← 同上
}
最终驱动滚动的原语还是 position.drag(),和方案一殊途同归,却多背了一堆负担:要禁掉内层列表自带的手势、要自己处理和竖直滚动/点击的竞技场竞争、要接好 hold/cancel 细节。手感上没有任何额外收益。
两个方案对比
| 缰绳层移交 | 命中测试屏蔽 | |
|---|---|---|
| 动手的层 | 滚动活动层(ScrollActivity / Drag) | 命中测试层(hitTest) |
| 决策时机 | 手势进行中任意时刻 | 只能在手指落下瞬间 |
| 支持”半路变道” | ✅ 核心能力 | ❌ 铁律 A+B 双重封死 |
| 方向感知 | ✅ 越界方向天然已知 | ❌ 落下时方向未知 |
| 对点击的影响 | 无 | down:true 连点击一起屏蔽 |
| 滚动物理保真 | ✅ 与原生同一根缰绳、同一套物理 | —(走不到这一步) |
| 侵入性 | 包一层 widget + 列表挂个 controller | 看似一层 widget,实则改不出正确行为 |
结论
- 这个需求的本质是”赢家中途换人”,而 Flutter 的命中测试和手势竞技场都明确不支持中途换人。唯一的口子在更下面一层:滚动位置的
drag()接口——竞技场赢家自己也是靠它驱动滚动的,我们跳过比赛、直接顶替赢家上岗,用的是完全相同的通道,所以手感与原生无异。 HitTestBlocker不是坏工具,它适合静态的穿透/屏蔽场景;只是和本需求不在同一层,方向上解决的是”谁参赛”,而我们要的是”换赢家”。