# Vibe Coding 实战:Flutter 滑动列表上的花式动效

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 * headerHeight
  • paintExtent:我当前在视口里可见多高。每帧根据 scrollOffset 动态计算
  • paintOrigin:我相对于视口顶部,从哪个位置开始画。每帧根据 scrollOffset 动态计算

scrollExtent 是一次性声明,告诉框架"在总滚动距离里,前 400px 是我的事"。paintExtentpaintOrigin 是每帧动态计算的------用户每滑一下,框架重新调 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,说明面板在视口里的可见高度一直是 200px
  • paintOrigin = 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 逐条绘制。

flowchart TB A["Widget 内容"] --> B["toImageSync\n捕获为 ui.Image"] B --> C["分成 N 条垂直条带"] C --> D["逐条 drawImageRect\n源矩形 → 压缩后目标矩形"] D --> E["交替叠加阴影\n偶数条深色,奇数条渐变"]

具体做法(简化后大概是这样):

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,但翻书有两个不同:

  1. 锚点不同 :翻牌绕面板中心旋转,翻书绕右边缘旋转。锚点从 (width/2, height/2) 改成 (width, height/2)
  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 的角色不是替你写代码,而是加速了"想 → 问 → 做"的循环。

相关推荐
西西学代码1 小时前
Flutter---登录弹窗
flutter
G_dou_2 小时前
# Flutter+OpenHarmony 实战:ToDo待办清单
flutter·harmonyos
不爱吃糖的程序媛10 小时前
Flutter 三方库适配鸿蒙教程
flutter·华为·harmonyos
2501_9197490314 小时前
鸿蒙 Flutter 实战:video_compress 3.1.4 适配 3.27-ohos 全流程
flutter·华为·harmonyos
h64648564h16 小时前
Flutter 国际化(i18n)全指南:一键切换中/英/日多语言
前端·javascript·flutter
kTR2hD1qb21 小时前
Flutter 复杂拖拽排序实战:同源排序 + 跨容器拖拽完整落地
flutter
jingling5551 天前
Flutter | Dio网络请求实战
android·开发语言·前端·flutter
duanze1 天前
从零开始Android商业项目Vibe coding完全指南(四)
gemini·vibecoding
stringwu1 天前
Flutter 复杂拖拽排序实战:同源排序 + 跨容器拖拽完整落地
flutter