Flutter SizeTransition 实战:打造流畅的展开收起动画
SizeTransition 这个组件说实话在 Flutter 动画家族里算是个冷门选手。大多数人在做展开收起、高度变化这些效果时第一反应就是 AnimatedContainer、AnimatedSize或者直接上手 AnimationController AnimatedBuilder 一顿手写。我见过不少项目里为了做一个展开更多的详情面板硬生生写了两百多行代码性能还拉胯掉帧掉得肉眼可见。直到后面系统研究 Flutter 内置的过渡组件才发现 SizeTransition 这东西能省掉一大半的体力活而且动画的流畅度上限比我们自己用 setState 去改尺寸高得多。这篇就好好聊聊 SizeTransition从构造函数每个参数的真正含义到完整实现一个不抖动的展开收起动画再到让它真正丝滑的优化细节和实战里容易踩的坑。适合已经会用 AnimationController 基础动画、但想在 UI 动效上做得更专业的人也适合刚学 Flutter 动画、想知道除了 ScaleTransition 和 FadeTransition 之外还有什么好用组件的朋友。1. 先搞清楚 SizeTransition 到底解决什么问题1.1 动画需求从哪来尺寸变化是最常见的交互反馈你回想一下自己做过的 UI有多少场景离得开尺寸变化列表项展开显示更多内容、手风琴菜单撑开、底部面板上滑变高、查看全文把折叠的文本区块撑开、购物车侧边栏滑出时宽度从窄变宽……这些交互的本质都是同一个某个 Widget 的尺寸在用户操作后发生了变化而我们需要让这个变化过程有过渡感、有反馈而不是啪地一下就变。直接改尺寸的问题在于Flutter 的布局是完完全全的决定式declarative——build 方法里写成什么尺寸RenderObject 这帧就是什么尺寸。如果只是 setState 改了数值新尺寸会在下一帧立刻生效视觉上就是一次硬切。没有中间态就没有动画。所以思路其实很简单我们把尺寸从一个值过渡到另一个值中间每一帧都给出一个新的尺寸让 UI 在这帧和上一帧之间只变化一点点。人眼把连续的小变化串起来就成了动画。SizeTransition 就是这个思路的官方封装配件。1.2 和其他过渡动画的分工Flutter 的 animate_ 系列过渡组件AnimatedBuilder 家族之外还挺多FadeTransition 管透明度、ScaleTransition 管缩放、PositionedTransition 管位置、RotationTransition 管角度、SizeTransition 管尺寸。它们有一个共同的底层逻辑都依赖一个Animationdouble作为驱动源然后把它映射成不同的视觉属性。SizeTransition 和 ScaleTransition 的区别值得单独说。ScaleTransition 做的是整个 Widget 的缩放包括内容都被拉伸或压缩SizeTransition 做的则是撑开封套——它的尺寸实际变化了但内部的子 Widget 是按真实布局来摆放的不缩放、不扭曲。什么场景适合这个差异呢举个例子展开一个卡片卡片里的文字和图片要保持原样清晰那就必须用 SizeTransition 或者类似思路的组件让可视区域长高而内容本身按新区域的 layout 重新排布。如果用 ScaleTransition 去缩放文字糊了、图片比例也变了体验会非常奇怪。2. 从构造函数开始理解每个参数都是干嘛的2.1 四个核心参数直接看构造函数const SizeTransition({ super.key, required this.sizeFactor, this.axis Axis.vertical, this.axisAlignment 0.0, this.child, })就这么简洁。四个参数里child好理解——你要动画的那个组件剩下三个才是需要认真对待的。sizeFactor是一个Animationdouble取值范围按规定应该在 0.0 到 1.0 之间。它的作用就是告诉 SizeTransition现在你应该把子组件显示到多大比例。0表示完全不显示尺寸为 01表示完整显示正常尺寸0.5就是只显示一半的高度/宽度。注意是严格的线性映射。axis决定尺寸在哪个方向变化Axis.vertical是高度Axis.horizontal是宽度。默认是垂直方向也就是长高/变矮。axisAlignment控制对齐方式取值在 -1.0 到 1.0 之间。这个比较抽象下面展开讲它也是实际编码时最容易搞反的地方。2.2 唯一需要认真对待的sizeFactor很多人会问我sizeFactor 和 AnimationController 的关系是什么其实它们是这样的协作方式AnimationController本身是一个Animationdouble值从 0 到 1每一步它自己累加变化。SizeTransition 拿到这个动画对象后按它的值决定自己要占多大空间。最典型的写法是直接传 controllerSizeTransition( sizeFactor: _controller, axis: Axis.vertical, child: _cardContent, )_controller.forward()时尺寸从 0 增大到 1组件撑开_controller.reverse()时尺寸从 1 缩到 0组件收起。但如果你想让动画更聪明一点比如不是从 0 开始而是从某个比例开始怎么办呢这时候可以给 controller 包一层TweenSizeTransition( sizeFactor: Tween(begin: 0.2, end: 1.0).animate(_controller), child: _cardContent, )注意一个细节Tween.animate()返回的是一个Animationdouble它的值把底层 controller 的 0~1 映射到了 0.2~1.0。也就是说controller 在 0 的时候动画值已经是 0.2 了组件会直接显示 20% 的高度而不是完全消失。这种不完全收完的效果在筛选面板里非常实用能保证收起状态下还露出一点面板头部提示用户这里还能拉出来。2.3 axis 和 axisAlignment 的坑axisAlignment是默认的 0.0表示对齐到交叉轴的中间。听起来简单但真正用起来很容易把方向搞反。想象一个垂直方向尺寸变化axis Axis.vertical的组件它占据的矩形是什么样子高度在动宽度根据父容器约束来。axisAlignment控制的就是在动画过程中这个矩形相对于最终完整区域的对齐位置axisAlignment 0.0中间对齐。动画过程中组件始终居中上下两端同时向中间收缩/张开。axisAlignment -1.0顶部对齐。组件从顶部向下张开收起时是下面先缩进去顶部固定不动。axisAlignment 1.0底部不对正好反了。我们来想清楚在 Flutter 的坐标系统里Y 轴正方向是向下。如果你希望顶部固定不动、底部向上收起/向下展开那么这个固定点应该在 Y 轴的最小端也就是 -1.0。我贴一个自己平时写的从底部弹出面板的配置SizeTransition( sizeFactor: _controller, axis: Axis.vertical, axisAlignment: 1.0, // 底部固定面板向上展开 child: _dragHandle, )等等这里我用的是axisAlignment: 1.0。为什么因为底部固定意味着组件在交叉轴上的锚点应该靠近正向最大一侧。Flutter 里 alignment 用的是 FractionalOffset 类似的约定0.0 是开始端1.0 是结束端。以垂直方向动画来说交叉轴是水平方向还是垂直方向这里容易懵。注意axis 决定的是变化方向而 axisAlignment 是对非变化方向的对齐吗我一开始也这么误解后来看源码才明白SizeTransition 内部是把自己的轴对齐axial alignment用到 alignment along the cross axis但这个 cross axis 是相对于 SizeTransition 自身的方向定义的——文档原文是 The alignment of the size transitions child, along the axis that is perpendicular to the size transitions axis。但实际效果里它说的是 how the child is aligned within the constraints of the transition as it changes size。好吧用大白话讲axisAlignment实际上控制的是动画变化时子组件被固定住的一端在哪里。垂直方向变化时1.0固定底部-1.0固定顶部。水平方向变化时1.0固定右端-1.0固定左端。这里强烈建议你写个小测试页面把 axisAlignment 从 -1 循环切到 1肉眼看到底哪端在动。当时我在这上面花的调试时间比写整个动画逻辑还长。3. 完整实现一个可折叠卡片动画的逐步搭建3.1 基础骨架AnimationController 怎么配动画的驱动源还是那套标准流程。单次展开/收起的场景我会用SingleTickerProviderStateMixin配合一个有固定时长的 controllerclass ExpandableCard extends StatefulWidget { const ExpandableCard({super.key}); override StateExpandableCard createState() _ExpandableCardState(); } class _ExpandableCardState extends StateExpandableCard with SingleTickerProviderStateMixin { late final AnimationController _controller; bool _isExpanded false; override void initState() { super.initState(); _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 220), ); } override void dispose() { _controller.dispose(); super.dispose(); } void _toggle() { if (_isExpanded) { _controller.reverse(); } else { _controller.forward(); } setState(() _isExpanded !_isExpanded); } }这里有两个值得注意的决策。一是时长 220ms。Flutter 官方 Material 规范里大多数 motion 推荐 200ms~300ms。我实际体验下来220ms~260ms 是展开收起最舒服的范围太快了看不清过渡过程太慢了用户会觉得卡。收起reverse的时候由于用户心里可能已经在期待快点关上我偶尔会把 reverse 时长缩短但用同一个 controller 的话就没法分开设置。所以这种场景我会启用一个_controller加两个 Duration 的变体写法或者直接接受同一个时长——大多数情况下 220ms 完全够用。二是维护了一个_isExpanded状态而不是在点击时直接判断_controller.status。原因是我在按钮/手势回调里拿到的 controller 状态有时会是AnimationStatus.dismissed和AnimationStatus.completed之外的中间态——用户快速连点的时候动画还在进行中直接用status completed判断就会出错。用一个布尔值来记录目标状态配合forward/reverse调用行为就非常明确了。3.2 从 0 到 1让卡片高度动起来然后就是 SizeTransition 的本体了。展开收起的内容我通常会包一层透明度渐变因为收起的最后阶段——高度还剩 20% 但内容几乎没有——直接消失会非常突兀Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: const [ BoxShadow(color: Color(0x1A000000), blurRadius: 12), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(), ClipRect( child: FadeTransition( opacity: Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation( parent: _controller, curve: Curves.easeIn, ), ), child: SizeTransition( sizeFactor: _controller, axis: Axis.vertical, axisAlignment: -1.0, // 头部固定向上撑开 child: _buildBody(), ), ), ), ], ), ); }我需要解释几个自己加的优化点。ClipRect。SizeTransition 在尺寸小的时候子组件还是会被完整布局溢出的部分直接绘制上去了。如果 body 里有圆角、阴影、GridView 之类的内容缩到一半时会很难看地露出边界。ClipRect 把尺寸裁剪到当前的实际区域视觉上非常干净。FadeTransition 套了一层。SizeTransition 收起时无论高度怎么变子组件都不会淡出只会被裁剪。这种硬裁在动画尾部特别明显。整体加一层透明度动画让内容在收起的同时淡出观感柔和很多。axisAlignment: -1.0。这里我让卡片从头部往下撑开顶部固定。如果做成从底部弹出的面板用1.0如果是列表里每个 item 展开根据自己的习惯选择。没有一个万金油配置必须看 UI 设计想要什么效果。3.3 处理展开/收起时的布局抖动这是实战中遇到的最常见的问题比动画本身难缠多了。现象SizeTransition 外层如果直接放在 ListView 的 item 里收起后 item 高度变成 0列表会瞬间跳一下或者外层有 padding 和 margin展开收起的视觉节奏就乱了。根因SizeTransition 会真实参与布局。它把自身的尺寸告诉父级父级就会把可滚动区域的预定范围动态调整。没有稳定的包裹结构时列表重排一帧一帧变化视觉上就会抖。我通常的解决方案是给 SizeTransition 的内容设置一个明确的最小高度或固定外层约束。比如希望收起后 item 至少保留头部的高度就不要让 SizeTransition 把头部也包进去而是把它放在 SizeTransition 外面Column( children: [ headerWidget, // 固定的头部不参与动画 ClipRect( child: SizeTransition( sizeFactor: _controller, child: bodyWidget, ), ), SizedBox(height: 16), // 底部间距也不参与动画 ], )这样收起后的 item 高度 头部 空隙视觉上不会完全消失列表也不会因为 item 高度归零而剧烈跳动。另一个比较隐蔽的抖动来源是子组件内部的高度断言。如果你的 body 里有一个高度依赖父级 Viewport 的 Widget比如ListView、SingleChildScrollView在 SizeTransition 的动画过程中父级高度一直在变这些滚动组件每一帧都会重新计算滚动范围表现就是内容跳来跳去。这种情况我建议直接用Column或Wrap这种按内容高度自适应的布局别在动画区域里放独立滚动组件。4. 真正丝滑的关键流畅度优化细节4.1 动画曲线默认 Curves.linear 就是灾难把 controller 直接传给 sizeFactor 的那一刻动画是按线性曲线跑的每一帧尺寸变化量完全一样。听起来没什么不好视觉上其实非常死。真实世界里没有任何一个机械过程是全程匀速的。物理上你推开一扇窗必然是开始一下加速、中间比较稳定、最后慢慢停在极限位。UI 动画不遵循这个直觉用户就会觉得这不是一个真实的东西。所以给我自己定的规矩是凡是用到 AnimationController 的动画必须配 CurvedAnimation。展开场景常用Curves.easeOutCubic或Curves.easeOutQuart——开头快、结尾慢符合内容弹出来然后轻轻落定的物理直觉。收起场景常用Curves.easeInCubic——开头慢、逐渐加速直到合上符合关门时最后一下带点惯性的感觉。写法_controller AnimationController( vsync: this, duration: const Duration(milliseconds: 240), ); late final Animationdouble _sizeFactor CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ).drive(Tween(begin: 0.0, end: 1.0));这里我用了drive(Tween(...))而不是直接Tween.animate(...)效果一样不过是 Flutter 提供的链式 API可读性更好。注意reverseCurve我单独指定了正向和反向曲线可以不同。4.2 减少重建避免在 build 里重复创建 Tween有一个高频出现的性能坑我之前也犯过override Widget build(BuildContext context) { return SizeTransition( sizeFactor: CurvedAnimation( parent: _controller, curve: Curves.easeOut, ).drive(Tween(begin: 0.0, end: 1.0)), child: _body, ); }这段代码的问题在于每次 build 都会创建一个新的 CurvedAnimation 和一个新的 Tween 实例。虽然这些不是重型对象但它们在 widget 树重建时频繁创建会让动画的内部状态没有归属感CurvedAnimation 是连接 controller 和 curve 的中间节点它应该和 controller 一样有明确的生命周期。正确做法是把动画对象缓存起来。可以在initState里定义或者用late final或者直接在字段初始化late final Animationdouble _sizeFactor CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, ).drive(Tween(begin: 0.0, end: 1.0));这样 build 里只是把这个已经创建好的动画对象传给 SizeTransitionFlutter 内部可以在动画的每一帧直接复用同一份状态。我自己实测下来对 CPU 渲染的影响在低端机上是可以感知的——动画期间流畅度稳定卡顿掉的帧明显减少。4.3 用 LayoutBuilder 还是 FractionallySizedBox这是另一个很多人问的问题如果子内容高度不确定SizeTransition 内部显示出来的子 Widget 高度到底怎么算答案是SizeTransition 的尺寸完全由 sizeFactor 决定子 Widget 的布局尺寸不受影响。它会拿一个如果完整展开时的大小来布局子组件然后按 sizeFactor 裁剪显示区域。所以 SizeTransition 不需要提前知道你内容的高度——它内部用了一个类似 clip 的 mechanism把子组件按当前比例裁剪。这里要注意如果你在 SizeTransition 外层加了ConstrainedBox或者SizedBox限制固定高度那 SizeTransition 的灵活性就没了比例会按固定高度来算动画视觉表现可能会不对。如果你确实需要撑到父容器的一定比例正确的做法是用FractionallySizedBox作为外部约束而不是在 SizeTransition 外层写死高度。这样展开时虽然高度受父容器限制但动画过程仍然平滑。4.4 避免同时修改父级约束最后这个优化点最隐晦我是在做一个底部面板的时候发现的。如果动画过程中不仅仅是 SizeTransition 在变化它的父级也在同步改变约束条件比如父级同时响应一个 drag 手势动态改变面板容器的最大高度那么每一帧布局时 SizeTransition 拿到的外部约束都在变化而这个变化和 sizeFactor 的变化是两条独立的时间线。结果就是动画期间 Widget 的预期最终高度一直在变视觉上会发飘、抖动、甚至来回跳。解决办法有两个思路让外部约束在动画开始前就固定。比如展开面板时先用一个固定的最大高度包住动画区域动画只在内部做 sizeFactor 的过渡。如果外部约束必须跟着动比如 drag 到一半触发动画那就把外部约束 内部 SizeTransition的层级拆开用独立的 controller 控制外部约束等它稳定后再触发内部动画。实战项目里我大多是方案 1简单可靠。5. 进阶组合玩法5.1 结合 AnimatedBuilder 实现两层不同步动画有时候我希望尺寸动画和内部的某个属性动画不同步。比如展开一个卡片时尺寸要慢慢撑开但里面的文字要比尺寸先到位——让文字先显形尺寸再跟上这样看起来更像一个信息层级揭示的效果。这个单独用 SizeTransition 做不了因为多个动画需要不同的时间线。标准做法是同一个 controller 通过不同的 Curve 区间去驱动不同属性AnimatedBuilder( animation: _controller, builder: (context, child) { return SizeTransition( sizeFactor: _sizeFactor, // 全区间 axis: Axis.vertical, child: Opacity( opacity: _introOpacity, // 前20%区间就完成 child: child, ), ); }, child: _cardContent, )_introOpacity可以用Interval(0.0, 0.3, curve: Curves.easeOut)卷一个late final Animationdouble _introOpacity CurvedAnimation( parent: _controller, curve: const Interval(0.0, 0.3, curve: Curves.easeOut), );这样前 30% 的动画时间里透明度先从 0 到 1后面 70% 的时间尺寸持续撑开。视觉效果就是内容先亮出来再展开空间——非常适合做消息列表、通知面板的展开。5.2 SizeTransition FadeTransition 的组合这条前面提到过了但我单独拿出来说是因为它们的组合几乎是展开收起动画的标配。FadeTransition做透明度过渡SizeTransition做尺寸过渡。两者共享同一个 controller整体的动效就是变大 变明显。它们两个在 Flutter 里都被设计成轻量过渡组件叠加基本上无感。但要注意一点透明度动画一定要包在 SizeTransition 外面还是里面我的推荐是FadeTransition 在外面SizeTransition 在里面。为什么因为如果把 SizeTransition 放在外面透明度整体作用于整个区域收起过程中即使区域还在变小透明度已经先归零了视觉上会提前消失。而放在外面的话透明度跟着 controller 的任意曲线走不会和尺寸裁剪互相干扰。5.3 动态列表项的尺寸渐入这个玩法在消息流、动态 feed 里很常用新 item 插入列表时让它尺寸从 0 长到真实高度同时透明度从 0 变 1。用AnimatedList或者手动触发 insert 时给 item 包一个 SizeTransition 就能实现。注意AnimatedList里插入动画的默认行为是完全无动画或者简单的 FadeTransition想要尺寸生长效果需要自己包一层override Widget buildItem(BuildContext context, int index, Animationdouble animation) { return SizeTransition( sizeFactor: animation, axis: Axis.vertical, child: FadeTransition( opacity: animation, child: _itemView(index), ), ); }这里animation是 AnimatedList 在插入时提供的本身就设计用来驱动这种入场效果的。SizeTransition 接受Animationdouble恰好完美适配。6. 实际项目中我踩过的几个坑6.1 axisAlignment 方向写反了前面原理说了但这里给一个真实的排查过程。当时我要做一个从底部弹出的筛选面板预期效果是面板底部固定内容从底部向上撑开。我写的是SizeTransition( axis: Axis.vertical, axisAlignment: -1.0, // 我以为是向上 child: _panel, )运行起来发现面板是从顶部开始往下展开的——顶部固定下面弹出来和设计稿完全相反。我花了半小时才确认问题axisAlignment不是视觉方向的上下而是交叉轴上锚点位置的坐标系值。垂直方向动画时-1.0在 Flutter 坐标系里是 Y 轴的负向也就是屏幕上方所以锚定在 -1.0 意味着 top 固定动画向下展开。要让 bottom 固定锚点应该在底部对应1.0。记法建议不需要背方向直接在真机/模拟器上写个AxisAlignment来回切换的调试按钮看一下就明白了比分析坐标系快得多。6.2 在无界约束容器里动画直接崩有一次我把一个 SizeTransition 放在ListView.builder的某个 item 里并且对这个 item 的高度没有做任何约束动画跑起来后很快就报错BoxConstraints forces an infinite height.原因SizeTransition 在动画过程中需要知道自己被允许占用的空间。它内部的布局逻辑是先算出完整子组件大小再按 factor 裁剪。但如果在垂直滚动的 ListView 里垂直方向的约束是无限大的——没有 maxHeight子组件想多高就多高。SizeTransition 在这种情况下没法确定完整大小到底是多少就崩了。解决方案很粗暴给 SizeTransition 的内容一个有限的最大高度比如ConstrainedBox( constraints: const BoxConstraints( maxHeight: 400, // 内容展开后的极限高度 ), child: SizeTransition( sizeFactor: _controller, child: _cardBody, ), )这样 SizeTransition 拿到的是有限约束能算出比例动画正常。你还可以顺手把maxHeight替换成实际布局里更有意义的参数比如MediaQuery.of(context).size.height * 0.6。6.3 控制器生命周期没处理好这个坑不只在 SizeTransition 里但我在做列表项复用的时候踩过一次印象特别深。SingleTickerProviderStateMixin提供的 vsync 要求动画的执行者必须持有对 Ticker 的引用。如果 controller 在 State 销毁时没有 dispose就会泄漏 Ticker如果 State 被重新创建而 controller 是同一个比如列表 item 复用了旧 State动画会报AnimationController.dispose() called more than once或者奇怪的Ticker was active警告。我的经验法则私有的 controller 一律在dispose()里调dispose。不要在 didUpdateWidget 里重新初始化 controller除非你明确知道旧 controller 已经没用了。如果 controller 会被多个页面或组件共享比如一个全局展开状态的控制器用ValueNotifier 监听器做状态同步比在 State 里创建独立 controller 更稳妥。除了这些还有一个容易忽略的反向动画还没有跑完就再点一次同方向的按钮。连续点击 forward 两次第二次 forward 会从头开始但动画没有重置——SizeTransition 的值会跳回 0 再重新涨视觉上很突兀。所以我在_toggle里加了状态保护void _toggle() { if (_controller.isAnimating) return; // 动画进行中忽略重复点击 if (_isExpanded) { _controller.reverse(); } else { _controller.forward(); } setState(() _isExpanded !_isExpanded); }做产品的都知道用户就是要疯狂连点你永远要想到用户会有多手速快。7. 把 SizeTransition 用进真实组件库的个人体会说实话SizeTransition 的 API 简单到一眼能看完文档翻来翻去就是那四行参数。但真正把它用得顺手靠的不是记住构造函数而是理解了它和 Flutter 布局系统之间的关系它始终参与布局却只按比例裁剪内容它需要一个 Animation 驱动却不需要你手动算任何数值。在我自己的项目里SizeTransition 已经成了展开收起类动画的标配。配合 I 提到的那几个优化点——曲线、裁剪、透明度、约束固定、防止连点它基本能做到不掉帧、不抖动、不突兀。对于想要系统学习 Flutter 动画的人来说我的建议是先别急着去堆 AnimationController 和矩阵变换把 FadeTransition、SizeTransition、SlideTransition 这几个官方封装的理解到位、用熟练你会发现大部分日常的动效需求都能覆盖而且维护成本低得多。最后分享一个我自己实践后的习惯所有动画时长统一用一个常量类管理配合开发工具的 hot reload可以随时切换全局动画速度。Flutter 里debugAnimationsScale这个时间缩放因子也可以达到类似效果但线上版本千万别打开了它动画会慢到让人怀疑人生。