Flutter 同轴嵌套滚动的边界翻页:手势移交方案与命中测试方案对比

横向列表嵌在 TabBarView 里,滑到边界后继续拖拽要像原生一样跟手翻页。对比两种思路:绕过手势竞技场的 Drag 移交方案,和在命中测试层做手脚的 HitTestBlocker 方案。

Flutter&DartFlutter手势系统嵌套滚动TabBarViewScrollPositionHitTest

需求背景

假设 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() 拿缰绳。我们等内层列表拖到边界时:

  1. 剪断内层的缰绳:让内层列表对本次手势”失聪”;
  2. 以外层的名义拿一根新缰绳:对 TabBarView 内部 PageView 的 positiondrag()
  3. 把旁听到的手指移动原样喂给新缰绳:外层以为是用户在正常拖它。

因为新缰绳和真手势用的是同一个对象、同一套滚动物理,所以跟手、反向、停半路、松手吸附、指示器联动,全部原生。

关键代码一:怎么”旁听”手指(不参赛也能听)

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 不是坏工具,它适合静态的穿透/屏蔽场景;只是和本需求不在同一层,方向上解决的是”谁参赛”,而我们要的是”换赢家”。