Flutter TabBar 的两个丝滑细节:点击翻页不经过中间页、选中 tab 始终居中

拆解 TabBarView 的 warp 机制与可滚动 TabBar 的居中跟随:点选相隔多页的 tab 时动画为何只有一页宽、中间页为何不构建;选中 tab 如何逐帧趋向视口中央。并给出在普通 PageView 上等价复刻两种行为的伪代码。

Flutter&DartFlutterTabBarViewTabBarPageViewKey元素复用

从两个”理所当然”说起

Flutter 的 TabBar + TabBarView 有两个细节顺滑到让人意识不到它们的存在:

  1. 频道有十几个时,从第 1 个 tab 直接点第 10 个,页面动画只滑了一页的距离就到了——中间八页既没有闪过,也没有被构建;
  2. isScrollable: true 的 TabBar 里,无论点击还是左右滑动页面,选中的 tab 总是自己往视口中央凑,滑到两端时又恰到好处地停在边缘,不会硬凑出空白。

这两个行为都不是白来的。这篇文章把它们的实现原理拆开讲清楚,并给出在普通 PageView 上等价复刻的思路——当你因为选中状态由数据驱动、tab 视觉高度定制等原因用不了 TabController 全家桶时,可以让 PageView 自己长出这两个能力。

第一问:点击翻页为什么不经过中间页

朴素做法的问题

PageView 自带的跨页动画是”逐页滑过”:

pageController.animateToPage(9, duration: ..., curve: ...);
// 从第 0 页出发:第 1、2、3……8 页会依次滑过视口

两个问题:

  • 中间每一页都会被真实构建 + 布局 + 绘制(哪怕只在视口里闪半帧)。页面重一点,这就是一段肉眼可见的卡顿;
  • 距离越远,动画要么越长、要么越快,观感和”点相邻 tab”完全不一致。

warp 的核心思路:让当前页”搬家”

TabBarView 的解法可以概括成三步,官方源码里这个过程叫 warp(跃迁)。以从第 0 页点到第 9 页为例:

第①步 搬家   把"第 0 页"的内容(连 widget 带 State)搬到第 8 页的槽位上
第②步 跳跃   jumpToPage(8) —— 无动画瞬跳。第 8 槽位上渲染的正是刚搬来的
             老内容,所以画面纹丝不动,用户毫无感知
第③步 滑动   animateToPage(9) —— 正常滑一页宽,视觉上就是"当前页 → 目标页"
落定后清理   把借来的槽位还回去(此时它已滑出视口,重建无感)

妙处全在①②两步的配合:跳跃前后渲染的内容没变,变的只是它挂在第几页。用户看到的整个过程,就是一次普通的相邻翻页。

画面为什么纹丝不动:key 与元素复用

第①步”搬家”搬的不是像素,是 Element(连着它的 State)。Flutter 重建时靠 key 判断”新 children 里的谁对应旧 children 里的谁”:key 对上了,framework 就把既有 element 整体挪到新位置(底层走 RenderSliverMultiBoxAdaptor.move),而不是销毁重建。State 原样保留、图片不闪、页内滚动位置不丢——这正是”画面纹丝不动”的全部秘密。

TabBarView 给每个 child 都包了唯一 key(KeyedSubtree.ensureUniqueKeysForList),然后在 warp 时交换 children 列表里的两项

// TabBarView 内部(简化伪代码)
Future<void> _warpToNonAdjacentTab() async {
  final previousIndex = controller.previousIndex;   // 0
  final initialPage = currentIndex > previousIndex
      ? currentIndex - 1        // 往后跳:借目标页左边的槽位(8)
      : currentIndex + 1;       // 往前跳:借目标页右边的槽位

  setState(() {
    // 关键:交换 keyed children —— 第 0 页的 child 带着它的 key 被放到第 8 个槽位
    final temp = _childrenWithKey[initialPage];
    _childrenWithKey[initialPage] = _childrenWithKey[previousIndex];
    _childrenWithKey[previousIndex] = temp;
  });

  _jumpToPage(initialPage);              // 瞬跳到第 8 页,画面不动
  await _animateToPage(currentIndex);    // 滑一页宽,到达第 9 页

  setState(() => _updateChildren());     // 落定后把 children 还原
}

在普通 PageView 上等价复刻

TabBarView 能交换 children,是因为它持有一份现成的 List<Widget>。普通 PageView 走 builder 惰性构建,手里没有列表可换——但 PageView.customSliverChildBuilderDelegate 留了一个钩子 findChildIndexCallback,专门回答”拿着这个 key 的元素,现在应该在第几个槽位”。交换列表和重定向 key 的查询结果,是同一件事的两种写法:

// 复刻思路(简化伪代码):不换列表,改"查号台"
int? findChildIndex(Key key) {
  if (warp进行中) {
    if (key == 起始页的key) return 借位槽位;   // 起始页元素 → 搬到目标页旁
    if (key == 借位槽位的key) return null;     // 槽位原内容无处安放,视作移除
  }
  return key对应的原下标;                      // 其余恒等
}

Widget buildPage(context, index) {
  // 借位槽位在 warp 期间渲染"起始页"的内容,key 也用起始页的
  final contentIndex = (index == 借位槽位) ? 起始页下标 : index;
  return KeyedSubtree(key: ValueKey(contentIndex), child: 真正的页面(contentIndex));
}

Future<void> warpToPage(int target) async {
  if ((current - target).abs() <= 1) {          // 相邻页:直接动画,无需借位
    await controller.animateToPage(target);
    return;
  }
  借位槽位 = target > current ? target - 1 : target + 1;
  setState(启用上面的借位映射);
  controller.jumpToPage(借位槽位);              // 画面纹丝不动
  await controller.animateToPage(target);       // 滑一页宽直达
  setState(清除借位映射);                        // 槽位已滑出视口,还原无感
}

中间页从头到尾没有被构建过——builder 只被要求构建”借位槽位”和”目标页”两个下标。

收尾的两个坑

假事件jumpToPage(借位槽位) 会触发一次 onPageChanged(8)——可用户根本没去过第 8 页。若宿主在 onPageChanged 里回写选中状态,这次假事件必须吞掉:

onPageChanged: (index) {
  if (warp进行中 && index != warp目标页) return;  // 借位跳页产生的假事件,不上报
  更新选中状态(index);
}

打断。动画途中用户又点了别的 tab、或直接上手拖拽怎么办?给每次 warp 发一个递增的”代次”(epoch):动画 await 回来后发现代次变了,说明借位状态已归新一次调用管,旧调用直接放弃收尾清理。被拖拽打断时动画 future 照常完成,走正常清理即可。

第二问:选中的 tab 为什么总在中间

目标偏移怎么算

对可横向滚动的 tab 行来说,“把第 i 个 tab 摆到视口中央”等价于一个滚动偏移:

居中偏移(i) = tab_i 的中心位置 - 视口宽度 / 2

但两端要收边界(这正是"不会硬凑出空白"的来源):
target = clamp(居中偏移(i), minScrollExtent, maxScrollExtent)
  ─ 第一个 tab 的居中偏移往往是负数   → 收到下界,贴左即可
  ─ 最后几个 tab 的居中偏移超出上限   → 收到上界,贴右即可

官方 TabBar 在自定义 RenderFlex 的 performLayout 里顺手记下了每个 tab 的 x 偏移,所以它随时知道每个 tab 的中心在哪。自己实现时不必记这份账,framework 有现成的视口反算工具:

// alignment 0.5 = "让这个渲染对象对齐到视口 50% 的位置",即居中
viewport.getOffsetToReveal(tab的renderObject, 0.5).offset
// 经视口反算得出的就是滚动偏移,RTL 布局下也无需特判

逐帧跟随:插值 + jumpTo

真正让它”丝滑”的是跟随策略。页面滑到一半(page = 3.4)时,tab 行不能干等落定,而是每一帧都停在两个目标偏移的插值点上:

// 翻页容器每滚动一像素都会回调(简化伪代码)
void onPageTick() {
  final page = pageController.page;         // 例:3.4
  final from = 居中偏移(page.floor());       // tab 3 的居中偏移
  final to   = 居中偏移(page.ceil());        // tab 4 的居中偏移
  final target = clamp(lerp(from, to, page - page.floor()),   // 按 0.4 插值
                       minScrollExtent, maxScrollExtent);
  tabRow位置.jumpTo(target);                // 逐帧直接落位
}

为什么是逐帧 jumpTo 而不是发现页变了就来一段 animateTo?因为驱动源太多:手指拖页面、点 tab 触发动画、数据刷新跳页……tab 行如果自己再开一段动画,两段动画的时长和曲线永远对不齐,一定会出现”页面停了 tab 行还在飘”或者互相打架。而”每帧算出该在哪、直接落位”是纯从动件思路——页面动它就动、页面停它就停,天然与所有驱动源同步。官方 TabBar 用的是同一套策略(它跟随的是 TabController.animation 的每一次 tick)。

这个写法还免费送一个特性:tab 行自身仍可独立横滑(用户想先看看后面还有哪些 tab),下一次翻页 tick 会自然把它接管回来。

两套机制的配合:warp 期间的”视觉页值”

两个机制拼在一起会打一次架。warp 直达时,pageController.page 的真实轨迹是 0 → 瞬跳到 8 → 滑到 9;tab 行若老老实实跟着 page 值走,就会先瞬移到 tab 8 附近再滑一格——穿帮了。

解法是把”原始页值”映射成”视觉页值”再喂给跟随逻辑:

double visualPage(double rawPage) {
  if (!warp进行中) return rawPage;
  // 页面实际只在 [借位槽位 8, 目标页 9] 之间滑一页宽
  final progress = (rawPage - 借位槽位) / (目标页 - 借位槽位);   // 0 → 1
  return lerp(起始页, 目标页, progress);   // 视觉上从 0 平滑扫到 9
}

页面滑一页宽的进度被等比放大成”起始页 → 目标页”的整段进度,tab 行于是从 tab 0 一路平滑扫到 tab 9,与页面动画同始同终。

(官方 TabBar 为什么没这个问题?因为它压根不看 PageController——它跟随的 TabController.animation 是一段独立的 index 0 → 9 动画,天然就是”视觉页值”。)

结论

  • 点击直达的本质是”元素搬家 + 无感瞬跳 + 一页宽动画”,核心机制是 Flutter 按 key 复用 element:TabBarView 靠交换 keyed children 触发搬家,普通 PageView 靠 findChildIndexCallback 重定向查询结果实现同一效果——中间页从头到尾不构建。
  • 选中居中的本质是”逐帧插值 + clamp 收边界 + jumpTo 落位”。不开自己的动画、只做别人动画的从动件,是它跟手不打架的原因。
  • 两者组合时注意三件事:借位跳页产生的 onPageChanged 假事件要过滤;warp 可能被打断,用代次兜底收尾;tab 行要跟随视觉页值而不是原始 page 值。