Vibe Coding 实战:Flutter 滑动列表上的花式动效
折叠面板收起时,除了透明度变一下,还能做什么?试了三种:沿 Y 轴翻牌、逐条手风琴折叠、右下角翻书。掌控原理,你便能随心所欲。

一、布局原理:自定义 Sliver 怎么工作
三个动效(翻牌、手风琴、翻书)看似完全不同,但都建立在同一个东西上:一个 0 到 1 之间的数值 expandRatio(展开比例)。这个值从哪来?来自自定义 RenderSliver 的布局计算。讲清楚这个计算过程,后面三个动效就是在这个基础上换不同的画法。
Sliver 布局和 Box 布局的区别
Flutter 的布局协议有两种。Box 布局(Row、Column、Stack)是"父组件给我多大的空间我填多大",不关心滚动。Sliver 布局天生为滚动场景设计------它不是收到一个固定约束,而是收到一个 SliverConstraints,里面最关键的信息是 scrollOffset(用户已经滚了多远)。
Sliver 在 performLayout() 里自己算出一组值,通过 SliverGeometry 告诉框架:
scrollExtent:我这个 Sliver 总共有多少可滚动距离。这个值不一定是固定的------SliverList的 scrollExtent 是所有 item 的总高度,item 数量变了它就变了。折叠面板这边面板高度不会变,所以用了固定值2 * headerHeightpaintExtent:我当前在视口里可见多高。每帧根据scrollOffset动态计算paintOrigin:我相对于视口顶部,从哪个位置开始画。每帧根据scrollOffset动态计算
scrollExtent 是一次性声明,告诉框架"在总滚动距离里,前 400px 是我的事"。paintExtent 和 paintOrigin 是每帧动态计算的------用户每滑一下,框架重新调 performLayout(),传入当前的 scrollOffset,Sliver 据此算出"我现在可见多高、从哪开始画"。框架拿到新值后重新布局,列表的位置跟着变,界面就更新了。
框架拿到这三个值,把多个 Sliver 按顺序拼在一起,自动协调滚动。不是"先滚完一个再滚下一个"------列表和面板是同时协调的:面板的 paintExtent 变了,列表就跟着往上顶。
下面用具体数值走一遍,假设 headerHeight = 200(Demo 页面实际用 250,这里为了计算方便假设 200)。
切换阶段:面板和第一项做过渡
用户刚开始滑,scrollOffset 从 0 逐渐增大,还没超过 200:
ini
scrollOffset = 0 → expandRatio = 1.0 - 0/200 = 1.0 (全展开)
scrollOffset = 50 → expandRatio = 1.0 - 50/200 = 0.75
scrollOffset = 100 → expandRatio = 1.0 - 100/200 = 0.5 (一半一半)
scrollOffset = 150 → expandRatio = 1.0 - 150/200 = 0.25
scrollOffset = 200 → expandRatio = 0.0 (全收起)
这个阶段的三个关键值:
paintExtent = 200(固定):面板始终占满 200px 高度,不变。paintExtent 等于 headerHeight,说明面板在视口里的可见高度一直是 200pxpaintOrigin = 0(固定):从视口顶部开始画scrollExtent = 2 * 200 = 400:总可滚动距离
框架看到 paintExtent = 200,就知道视口里 200px 被这个 Sliver 占了,下面的列表从 200px 处开始。虽然面板可见高度没变(始终 200px),但 expandRatio 在悄悄变化------paint 阶段根据这个值决定画面板还是画第一项,或者做过渡。
切换阶段的 paintExtent 不变是刻意的设计:面板在视口里始终占 200px,给过渡动效一个稳定的绘制空间。等过渡结束了(expandRatio = 0),进入滚走阶段,paintExtent 才开始从 200 缩小到 0,面板逐渐消失。
滚走阶段:整个面板向上推出视口
scrollOffset 超过 200 之后,expandRatio 已经是 0.0,过渡结束了。接下来要让整个 Sliver 滚走,给列表腾空间:
scss
scrollOffset = 200 → paintOrigin = -(200-200) = 0 paintExtent = max(200+0, 0) = 200
scrollOffset = 250 → paintOrigin = -(250-200) = -50 paintExtent = max(200-50, 0) = 150
scrollOffset = 350 → paintOrigin = -(350-200) = -150 paintExtent = max(200-150, 0) = 50
scrollOffset = 400 → paintOrigin = -(400-200) = -200 paintExtent = max(200-200, 0) = 0
paintOrigin 变成负数------负数意味着"画到视口上面去了",看不见。paintExtent 从 200 逐渐减到 0------框架看到这个值变小,就知道这个 Sliver 在缩小,列表自然往上顶。到 scrollOffset = 400 时 paintExtent = 0,Sliver 完全滚出视口,列表占满。
全过程一览
makefile
scrollOffset: 0 50 100 150 200 250 350 400
│←──────────── 切换阶段 ────────────→│←──── 滚走阶段 ────→│
expandRatio: 1.0 0.75 0.5 0.25 0.0 0.0 0.0 0.0
paintOrigin: 0 0 0 0 0 -50 -150 -200
paintExtent: 200 200 200 200 200 150 50 0
面板状态: 全展开 → → → 过渡中 → → → 全收起 → 逐渐滚出视口 → 完全消失
expandRatio 是桥梁。 layout 阶段算出它,paint 阶段根据它决定怎么画。expandRatio = 1.0 时画面板,= 0.0 时画第一项,中间值就是过渡动效的发挥空间------翻牌用它算旋转角度,手风琴用它算压缩比例,翻书用它算折叠点位置。三个动效共享同一个布局引擎,只是 paint 策略不同。
二、Widget 层:把 RenderSliver 装配起来
布局原理清楚了。上一章讲的是 RenderSliver 怎么算 expandRatio、怎么布局、怎么画,那是"引擎"。这一章讲 Widget 层------怎么把引擎装到 Flutter 的组件树里,让用户的手指操作能驱动它。
CollapsiblePanel 是一个 StatefulWidget,它做三件事:组装 CustomScrollView、监听滚动位置驱动吸附动画、把 expandRatio 传给面板内容。
组装:CustomScrollView + 两个 Sliver
dart
CustomScrollView(
controller: _controller,
slivers: [
CollapsibleHeaderSliver( // 自定义 Sliver:面板 + 第一项的过渡区域
headerHeight: widget.headerHeight,
transition: widget.transition,
panelWidget: ...,
firstItemWidget: ...,
),
SliverList(...) // 标准列表:第二项起
],
)
CollapsibleHeaderSliver 是上一章讲的那个自定义 Sliver(Element + RenderSliver),负责布局和绘制。SliverList 是 Flutter 原生的。框架拿到两个 Sliver 的 SliverGeometry,自动协调滚动------面板的 paintExtent 变了,列表跟着往上顶。
滚动监听:expandRatio 怎么传出去
RenderSliver 内部算了 expandRatio,但面板内容也需要知道这个值(比如根据展开比例调整文字大小)。Widget 层用 ScrollController 监听滚动位置,自己算一份 expandRatio:
dart
double get _expandRatio {
final offset = _controller.offset;
if (offset <= 0.0) return 1.0; // 没滚 → 全展开
if (offset >= widget.headerHeight) return 0.0; // 滚过了 → 全收起
return 1.0 - (offset / widget.headerHeight); // 中间值
}
面板内容通过 AnimatedBuilder 拿到每帧的 expandRatio:
dart
panelWidget: AnimatedBuilder(
animation: _controller, // ScrollController 是 Listenable
builder: (context, child) {
return widget.panelBuilder(context, _expandRatio);
},
),
ScrollController 本身是 Listenable------每帧滚动时通知 AnimatedBuilder 重建,重建时重新读 _expandRatio,面板内容就跟着更新了。
Snap 吸附:松手后自动归位
用户松手时,面板可能停在中间位置。需要判断吸附方向,用动画平滑过渡到终点。
监听松手事件用 NotificationListener<ScrollEndNotification>,包在 CustomScrollView 外面。松手时拿到当前 offset,计算 expandRatio,决定吸附方向:
dart
// expandRatio >= 0.5 → 吸附到展开(offset = 0)
// expandRatio < 0.5 → 吸附到收起(offset = headerHeight)
static double calculateSnapOffset(double expandRatio, double headerHeight) {
return expandRatio >= 0.5 ? 0.0 : headerHeight;
}
动画用 AnimationController(300ms, easeOut)+ Tween(从当前 offset 到目标 offset)驱动。每帧回调里调用 _controller.jumpTo() 更新滚动位置------这会触发 RenderSliver 重新 performLayout,重新算 expandRatio,界面就跟着动了。
一句话总结
Widget 层是"传动系统":用户的手指 → ScrollController → RenderSliver performLayout → expandRatio → AnimatedBuilder → 面板内容更新。Snap 动画也是同一套链路,只是驱动源从手指换成了 AnimationController。
三、过渡动效的架构设计
折叠面板跑通了,但过渡动效只做了淡入淡出。下一个问题:怎么让过渡动效可扩展,以后加新效果不用改 RenderSliver?
面板收起时,面板内容(panel)逐渐消失,列表第一项(item0)逐渐出现。怎么控制这两个组件的绘制?
策略模式:两种抽象,一个入口
有些动效只控制单个组件(比如淡入淡出),有些需要同时控制两个组件(比如翻牌------面板是正面,第一项是背面)。用 sealed class 把两种模式统一在一个入口里:
dart
sealed class PanelTransition {
const PanelTransition();
}
// 独立策略:面板和 item0 各用各的,自由组合
class IndependentPanelTransition extends PanelTransition {
final ChildTransition panel;
final ChildTransition item0;
const IndependentPanelTransition({this.panel, this.item0});
}
// 联合策略:同时控制两个组件
class CompoundPanelTransition extends PanelTransition {
final CompoundTransition transition;
const CompoundPanelTransition(this.transition);
}
IndependentPanelTransition 里,面板和 item0 各自独立选策略------比如面板用手风琴折叠、item0 用淡入,直接组合就行。组合数是 M × N,不用为每种搭配写一个新类。
CompoundPanelTransition 用于需要联合控制的场景------翻牌时面板是正面、item0 是背面,旋转角度必须统一,不可能各自独立旋转。
RenderSliver 的 paint 方法用 switch 匹配,类型系统保证不会写出自相矛盾的组合:
dart
void paint(PaintingContext context, Offset offset) {
if (expandRatio >= 1.0) { context.paintChild(panel!, offset); return; }
if (expandRatio <= 0.0) { context.paintChild(firstItem!, offset); return; }
switch (_transition) {
case IndependentPanelTransition(panel: final ps, item0: final i0):
i0.paint(context, offset, firstItem!, 1 - expandRatio, headerHeight);
ps.paint(context, offset, panel!, expandRatio, headerHeight);
case CompoundPanelTransition(transition: final ct):
ct.paint(context, offset, panel!, firstItem!, expandRatio, headerHeight);
}
}
expandRatio 为 0 或 1 时直接画单个子组件,跳过策略计算------终态优化,避免不必要的开销。
每个 Transition 实现都是 const 构造,无状态,全局复用。
四、翻牌------面板是正面,第一项是背面
架构搭好了,开始试第一种动效。翻牌是最直觉的想法:面板和第一项是同一张纸的正反面,沿 Y 轴翻转切换。
一开始用的是 ChildTransition(单子策略),面板和第一项各自独立翻转。问题很明显------看起来像两张纸各转各的,不像一张纸翻过来。
| 方案 | 行为 | 效果 |
|---|---|---|
| ChildTransition(各自翻转) | 面板和第一项分别沿 Y 轴旋转 | 两张纸各转各的 |
| CompoundTransition(联合翻转) | 统一旋转角,正反面同时绘制 | 一张纸翻过来 |
所以新增了 FlipCompoundTransition(双子联合策略),核心逻辑:
dart
// 统一旋转角:ratio=1.0 → 0°(正面),ratio=0.0 → 180°(背面)
double angle = (1.0 - ratio) * pi;
// 正面透明度 = cos(angle).clamp(0, 1),混合最低 15% 可见度
// 背面透明度 = -cos(angle).clamp(0, 1),混合最低 15% 可见度
// cos(0°) = 1(正面完全可见),cos(90°) = 0(侧面,仍有 15%)
// cos(180°) = -1 → -cos = 1(背面完全可见)
两面始终同时绘制,不是在 90° 时切换。正面透明度用 cos(angle).clamp(0, 1),背面用 -cos(angle).clamp(0, 1),再加 15% 的最低可见度,避免侧面时完全消失。
通过 pushTransform 设置 3D 矩阵(setEntry(3, 2, perspective) 加入透视近大远小),让翻转有立体感。

五、手风琴折叠------纸片扇形展开收拢
翻牌效果不错,但有个问题:面板内容如果有文字,翻到背面时文字被镜像了,可读性差。想试试一种不涉及镜像的折叠效果------纸片沿折痕交替正折反折,像手风琴的风箱一样展开收拢。
手风琴走的是 ChildTransition(单子策略)而非 CompoundTransition------只需要对面板做折叠,item0 用简单的 fade 渐入就行。
怎么实现逐条折叠
核心问题是:怎么把一个 Widget 按垂直条带拆开,分别压缩绘制?
Flutter 的 Canvas 没有办法直接裁剪 Widget 内容。需要先把 Widget 捕获为图片,再用 drawImageRect 逐条绘制。
具体做法(简化后大概是这样):
dart
// 离屏绘制 child,捕获为图片
final layer = OffsetLayer();
final context = PaintingContext(layer, Rect.empty);
context.paintChild(child, Offset.zero);
final image = layer.toImageSync(child.size);
// 逐条绘制
for (int i = 0; i < numFolds; i++) {
final srcLeft = i * srcFoldWidth;
final dstLeft = i * dstFoldWidth;
// 源条带 → 压缩后目标条带
canvas.drawImageRect(image,
Rect.fromLTWH(srcLeft, 0, srcFoldWidth, height),
Rect.fromLTWH(dstLeft, 0, dstFoldWidth, height),
paint,
);
}
progress 控制压缩程度:dstFoldWidth = (width * progress) / numFolds。这里的 progress 就是 expandRatio(面板展开比例)------progress=1 时每条等宽(完全展开),progress→0 时每条越来越窄(折叠状态)。交替阴影让折叠有立体感:偶数条叠加深色,奇数条加从左到右的渐变。
两种方案对比
| 方案 | 做法 | 限制 |
|---|---|---|
| Canvas scaleX | canvas.scale(scaleX, 1.0) 直接缩放 |
只能缩放整个 Canvas,无法逐条加不同阴影 |
| toImageSync + drawImageRect | 捕获图片后逐条映射 | 需要离屏绘制,有捕获失败 fallback |
最终选了 toImageSync 方案,因为需要逐条加不同的阴影效果。捕获失败时 fallback 到直接绘制。

六、翻书------面板绕右边翻起
手风琴做完了,最后试一种翻书效果:面板绕右边缘翻起,像翻书页。用的是 BookPageCompoundTransition(CompoundTransition 双子联合)------面板翻起的同时 item0 在底下露出来。
和翻牌的区别
翻牌(第四章)也是 rotateY,但翻书有两个不同:
- 锚点不同 :翻牌绕面板中心旋转,翻书绕右边缘旋转。锚点从
(width/2, height/2)改成(width, height/2) - 阴影:翻书在翻起的过程中叠加一层动态阴影,模拟翻页时纸面遮挡光线的效果
dart
// 翻书:绕右边缘旋转
m.translate(size.width, size.height / 2); // 锚点在右边
m.rotateY(-angle);
m.translate(-size.width, -size.height / 2);
阴影透明度随角度变化:sin(angle).abs() * 0.26,翻到 90° 时阴影最深,完全展开或完全翻过去时没有阴影。
绘制顺序
先画 item0(底层),再画翻起的面板(盖在上面)。这和翻牌不同------翻牌是两面交替可见,翻书是面板盖在 item0 上面逐渐翻开:
dart
void paint(...) {
context.paintChild(item0, offset); // 先画底层
if (ratio <= 0.0) return; // 全收起 → 只看 item0
if (ratio >= 1.0) { // 全展开 → 只看面板
context.paintChild(panel, offset);
return;
}
// 过渡中:面板带旋转 + 阴影画在 item0 上面
final matrix = buildPageMatrix(panel.size, angle);
context.pushTransform(false, offset, matrix, (ctx, off) {
ctx.paintChild(panel, off);
ctx.canvas.drawRect(off & panel.size,
Paint()..color = Colors.black.withOpacity(shadowAlpha));
});
}

七、代码结构
bash
lib/
├── main.dart # 入口 + 路由
├── playground/
│ └── playground_home_page.dart # 功能列表首页
├── collapsible_panel/
│ ├── widgets/
│ │ ├── collapsible_panel.dart # Widget 层(snap 动画、滚动监听)
│ │ └── collapsible_header_sliver.dart # RenderSliver + Element(布局 + 绘制调度)
│ ├── panel_transition.dart # PanelTransition sealed class(统一入口)
│ ├── child_transition.dart # ChildTransition 基类 + 6 种实现
│ ├── compound_transition.dart # CompoundTransition 基类 + 2 种实现
│ ├── collapsible_panel_demo_page.dart # 折叠面板 Demo(抽屉切换动效)
│ ├── accordion_fold_demo.dart # 手风琴验证 Demo
│ └── book_page_demo_page.dart # 翻书 Demo
三层架构:Widget 层声明 API → RenderObject 层做布局 → Transition 策略层做绘制。每个动效是一个独立的 const 类,可以自由组合。
71 个测试覆盖了布局计算、过渡动画和几何计算,全部通过。
几个关键决策做对了:策略模式用 sealed class 把动效和布局完全解耦,加新动效只加一个 const 类,switch 模式匹配保证不会写出自相矛盾的组合;终态优化跳过策略计算,避免了不必要的开销。
Vibe Coding 小结
以前想实现这类特殊效果,要先读 Flutter 框架源码,理清 Sliver 布局协议、RenderObject 生命周期,再分步验证测试,一个效果可能要磨好几天。现在通过和 AI 沟通交流,能快速把想法落地------翻牌、手风琴、翻书,每个效果从想法到可运行只用了几轮对话。遇到不懂的问题(比如 Householder 反射矩阵、toImageSync 的离屏捕获),也不再需要啃源码,直接问 AI 就能快速理解原理。AI 的角色不是替你写代码,而是加速了"想 → 问 → 做"的循环。