Flutter TabBar 的两个丝滑细节:点击翻页不经过中间页、选中 tab 始终居中
拆解 TabBarView 的 warp 机制与可滚动 TabBar 的居中跟随:点选相隔多页的 tab 时动画为何只有一页宽、中间页为何不构建;选中 tab 如何逐帧趋向视口中央。并给出在普通 PageView 上等价复刻两种行为的伪代码。
从两个”理所当然”说起
Flutter 的 TabBar + TabBarView 有两个细节顺滑到让人意识不到它们的存在:
- 频道有十几个时,从第 1 个 tab 直接点第 10 个,页面动画只滑了一页的距离就到了——中间八页既没有闪过,也没有被构建;
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.custom 的 SliverChildBuilderDelegate 留了一个钩子 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 值。