ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter动画进阶:深入理解SizeTransition实现展开折叠效果

Flutter动画进阶:深入理解SizeTransition实现展开折叠效果 Flutter 的动画体系是很多人入门的门槛也是面试里最容易暴露基本功的话题。我见过不少项目里做“展开/折叠”效果直接拿 AnimatedContainer 把高度一改就完事动画是有了UI动画的细节却经不起看——要么宽度也跟着变要么高度变化时没有裁剪导致子内容直接溢出要么动画节奏僵硬像机器人在开关门。后来我自己重写组件时换了 SizeTransition这个问题才算彻底解决。SizeTransition 是 Flutter 提供的显式动画组件专门用来做“沿某个方向按比例展开/收起”的 UI 动画适合做手风琴列表、详情展开、自定义下拉刷新头部这类场景。它和 ScaleTransition 那种二维缩放不是一回事也不像 AnimatedContainer 只能在两种状态之间切换。这篇文章不会只贴一个 demo而是从原理、参数、封装、性能、踩坑几个维度把 SizeTransition 讲透。无论你是刚学 Flutter 动画的新手还是已经在业务里做了几个动画但想优化细节的进阶开发者都能在这里找到能直接抄作业的代码和思路。1. 为什么选择 SizeTransition显式动画里的尺寸控制专家1.1 先分清 Flutter 的两类动画机制在没搞清楚隐式动画和显式动画之前直接上手 SizeTransition 很容易懵。Flutter 里动画大体分两类隐式动画Implicit Animation和显式动画Explicit Animation。隐式动画的代表是 AnimatedContainer、AnimatedOpacity、AnimatedPositioned 这类组件。你只要改一个目标值比如把高度从 100 改成 200Flutter 会自动插值补间过程由内部 Ticker 驱动开发者几乎不用管 AnimationController 是什么。它的优点是代码少、容易上手缺点是控制力弱——动画时长、曲线、起止时间都只能在构造参数里预先设好想中途暂停、反向播放、串成一段复杂的动画序列就非常别扭。很多从 React Native 或者小程序转过来的同学会觉得这种动画模型“有点奇怪”其实习惯了之后会发现它把“值的变化”和“UI 的变化”拆得很干净比前端那些直接操作 style 的方式更接近动画本身。显式动画则要自己创建 AnimationController、vsync、Tween再配合 AnimatedWidget 或 AnimatedBuilder 来监听 Animation 的值逐帧驱动 UI。好处是控制器在你手里你可以精确控制动画的每个瞬间什么时候开始、什么时候结束、中间怎么变化、要不要反转、要不要延迟。代价是要多写不少样板代码。SizeTransition 就是显式动画体系里的一个现成组件它本身接收一个 Animation 作为 sizeFactor由这个动画值驱动尺寸变化。很多新手会有一个认知误区以为 SizeTransition 只能和 AnimationController 配合自己没办法控制。其实反过来SizeTransition 的核心价值恰恰就是“把动画值交给你”你怎么定义这个 Animation 它就怎么动。理解了这一层后面所有的高阶玩法Interval 分段、TweenSequence 串联、自定义 Curve都变得顺理成章。1.2 和其他动画组件的区别组件动画类型控制维度典型场景SizeTransition显式主轴方向一个维度高度展开、手风琴、下拉刷新头部ScaleTransition显式X/Y 二维缩放弹窗弹出、按钮按压反馈AnimatedContainer隐式任意属性高度、颜色、边距等简单状态切换ScaleTransition 会把 child 在 X 和 Y 两个方向等比缩放也就是说如果你只想让高度变化用 ScaleTransition 会让宽度也跟着缩放表现成“整个卡片被压扁/拉长”。SizeTransition 只在 axisDirection 指定的方向变化尺寸交叉轴方向的宽度保持原始布局这也是它适合做列表项展开的根本原因。AnimatedContainer 虽然也能做高度变化但它每次高度变化都会触发整个容器重新布局。做轻量级的展开/折叠还行一旦你需要在动画过程中叠加透明度渐变、旋转或想在不同时间段控制不同部位的动画AnimatedContainer 就不够用了。SizeTransition 直接通过 sizeFactor 控制主轴方向的比例同时你还可以在这个尺寸动画上再叠加 FadeTransition、RotationTransition 等组件自由度完全不一样。1.3 什么时候用 SizeTransition什么时候别用我的经验是如果你的需求是“某个区域沿固定方向展开/收起并且还要和透明度、旋转等动画组合”SizeTransition 是最合适的如果只是简单地切换两种高度且不在乎动画细节用 AnimatedContainer 就够了别过度设计。另外要提醒一点SizeTransition 不裁剪 child。动画过程中 child 的内容可能超出可视区域通常要配合 ClipRect 或 ClipPath 使用尤其是在做展开/折叠的列表项时不裁剪会出现“动画结束了内容才突然露出来”的怪异效果。后面实战章节会用具体代码说明。2. SizeTransition 核心参数与动画驱动原理2.1 参数逐个拆解SizeTransition 的构造签名大致如下SizeTransition({ required Animationdouble sizeFactor, AxisDirection axis AxisDirection.down, double axisAlignment 0.0, Widget? child, })这个组件本身就是 AnimatedWidget 的子类它监听传入的 sizeFactor 动画每帧变化时重新计算主轴方向上的尺寸系数。sizeFactor这是核心一个 0.0 到 1.0理论上也可以超过 1.0的动画值。0.0 表示主轴方向尺寸为 01.0 表示完全展开。注意这里的“尺寸”是视觉比例不是改变 child 的固有尺寸。axisAxisDirection.down、up、left、right 四种。down 表示从上往下长出来高度从 0 变到完整高度up 表示从下往上长出来left 表示从右向左变宽right 表示从左向右变宽。很多人误以为 axis 只有横向/纵向两个选项实际上它带方向语义选错方向动画就翻过来了。axisAlignment在交叉轴方向的对齐方式取值范围 -1.0 到 1.0。这个参数决定了 child 在交叉轴上的对齐位置默认 0.0 居中。比如你做一个横向展开的标签想让内容从左边展开就把 axisAlignment 设为 -1.0。child被动画的子组件。2.2 尺寸换算与对齐的机制SizeTransition 的布局逻辑有点反直觉它并不是直接改 child 的 RenderObject 尺寸而是调整自身这个 RenderObject 对外汇报的尺寸同时对 child 做视觉上的变换。也就是说动画过程中 child 内部的排版不会每帧重排文字不会反复换行只是整体被缩放/拉伸到当前比例。具体表现是在动画值为 0 时主轴尺寸是 0动画值 0.5 时主轴尺寸是完整尺寸的一半到 1.0 就是原始尺寸。由于走的是渲染层的尺寸变化它不会触发 child 内部的文字重排这对展开/折叠场景非常宝贵。如果每帧都让 child 重新 layout文字排版就会反复重排产生明显的卡顿和闪烁尤其是子组件里还有图片、TextField 的时候体验会非常差。还有一点要注意SizeTransition 的尺寸变化是“尺寸从 0 到完整大小”的线性映射如果你想做“高度从 100 到 200”这样非从零开始的动画要么用 Tween 把动画值映射过去要么干脆在 SizeTransition 内部放一个固定高度的容器外面控制父级的约束。一般业务场景里从 0 展开是最常见的。2.3 AnimationController 与 Ticker 的绑定要让 SizeTransition 动起来你需要一个 AnimationController。在 StatefulWidget 里通常这样创建class ExpansionDemo extends StatefulWidget { const ExpansionDemo({super.key}); override StateExpansionDemo createState() _ExpansionDemoState(); } class _ExpansionDemoState extends StateExpansionDemo with SingleTickerProviderStateMixin { late final AnimationController _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); override void dispose() { _controller.dispose(); super.dispose(); } }vsync 参数必须传一个 TickerProviderSingleTickerProviderStateMixin 就是让 State 成为 TickerProvider为 controller 提供帧回调的 ticker。如果你漏掉这个 mixin运行时会直接报错。这是所有显式动画的新手第一关。Flutter 的动画和浏览器里的 requestAnimationFrame 类似但更严格Ticker 只有在 UI 需要刷新的时候才会调用回调默认跟随屏幕刷新率。动画过程中每一帧都会触发 listener你可以在动画值变化时 setState 重建外观或者在 AnimatedBuilder 里直接读取动画值。SizeTransition 内部本身已经在 build 方法里读取了 sizeFactor所以你在使用的时候不需要在外部手动 setState只需要保证传入的 Animation 对象在驱动即可。为了让动画更丝滑通常还需要包装一层曲线final curvedAnim CurvedAnimation(parent: _controller, curve: Curves.easeInOut);把 curvedAnim 传给 sizeFactor动画就不再是匀速而是有缓入缓出效果。实际体验差异巨大Linear 的动画像开关门加了 easeInOut 才像自然物体的运动。这个细节看着小对 UI 动画的质感影响却非常大。注意controller.dispose() 一定要在 State.dispose() 里调用否则会出现 “A Ticker was active but was not disposed” 的警告严重时还会造成内存泄漏。这个话题后面排查章节会再展开。3. 实操从零封装一个可复用的展开折叠动画组件3.1 适用场景与组件设计先定义需求做一个手风琴/FAQ 的展开折叠组件点击头部区域内容区沿垂直方向展开再次点击折叠展开过程中透明度跟着变化看起来更自然父组件能知道当前展开状态。这个需求在很多应用里都会出现设置页、详情页、帮助中心、列表筛选区。如果公司有组件库这类组件基本是必备的。设计上我把它拆成两个部分外部一个 StatefulWidget 管理 AnimationController 和点击事件内部用 ClipRect SizeTransition FadeTransition 组合。组件对外暴露一个 ValueChanged 回调用来通知父组件当前是展开还是收起——这就是 Flutter 组件通信里最常用的回调方式父传子用参数子传父用回调复杂场景再用状态管理把状态往上抛。3.2 完整代码与关键逻辑class ExpandableItem extends StatefulWidget { const ExpandableItem({ super.key, required this.title, required this.child, this.initiallyExpanded false, this.onExpansionChanged, }); final Widget title; final Widget child; final bool initiallyExpanded; final ValueChangedbool? onExpansionChanged; override StateExpandableItem createState() _ExpandableItemState(); } class _ExpandableItemState extends StateExpandableItem with SingleTickerProviderStateMixin { late final AnimationController _controller; late final CurvedAnimation _easeInOut; override void initState() { super.initState(); _controller AnimationController( vsync: this, duration: const Duration(milliseconds: 320), value: widget.initiallyExpanded ? 1.0 : 0.0, ); _easeInOut CurvedAnimation(parent: _controller, curve: Curves.easeInOut); _controller.addStatusListener((status) { if (status AnimationStatus.completed) { widget.onExpansionChanged?.call(true); } else if (status AnimationStatus.dismissed) { widget.onExpansionChanged?.call(false); } }); } void _toggle() { if (_controller.isAnimating) { return; } if (_controller.value 1.0) { _controller.reverse(); } else { _controller.forward(); } } override void dispose() { _easeInOut.dispose(); _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ InkWell( onTap: _toggle, child: widget.title, ), ClipRect( child: SizeTransition( sizeFactor: _easeInOut, axisAlignment: -1.0, child: FadeTransition( opacity: _easeInOut, child: widget.child, ), ), ), ], ); } }代码看上去不长但里面有三个细节值得展开第一初始状态的 controller.value 我直接设成了 0.0 或 1.0这样组件在首次 build 时就不会有一个从旧状态闪到新状态的“跳变”。如果你在 initState 里不设置 value默认从 0 开始那么即使 initiallyExpanded 传了 true第一帧也会是先折叠再展开观感很怪。第二status listener 里我监听的是 completed 和 dismissed而不是 forward 和 reverse。因为 AnimationStatus.forward 在动画过程中只是一个状态并不代表“已经展开完成”如果在 forward 状态就回调 true父组件收到的语义会不准确。completed 和 dismissed 才是终态回调的布尔值和实际视觉状态严格对齐。第三_toggle 里加了if (_controller.isAnimating) return;。这个是为了防止用户连续快速点击时两个动画指令互相打架。如果你希望“中途点击可以反向”可以用_controller.reverse(from: _controller.value)或forward(from: _controller.value)来接管当前进度这属于更高级的交互设计一般组件库默认不做但值得知道。3.3 为什么用 ClipRect 和 axisAlignment很多人看到这段代码会问为什么外面包一个 ClipRect因为 SizeTransition 本身不裁剪。动画进行到一半时child 的内容被按比例缩放但绘制区域可能超出组件边界如果没有裁剪文字或图片就会溢出来观感就像“内容被提前露出来了”。ClipRect 负责限制绘制区域让动画过程真正像“抽屉拉开/关上”。axisAlignment 这里我设 -1.0让内容在水平方向靠左对齐。如果你做的是居中卡片展开可以设为 0.0如果是右侧弹出的操作栏就设 1.0。我建议不要把值写死而是作为组件的构造参数暴露出来方便不同场景复用。这个参数虽然没有 sizeFactor 那么核心但它直接决定了展开时内容从哪一侧“长”出来会影响整个画面的视觉重心。我实际用下来还有一个偏好如果 child 比较大比如含图片或长文本我会在 ClipRect 外面再包一层 Material 或 DecoratedBox给展开区域加圆角和背景色。这样动画过程中边缘会有明确的视觉边界不会出现内容从背景里“穿帮”的情况。4. 进阶玩法让动画不再“死板”4.1 叠加透明度渐变上面代码里已经用了 FadeTransition。为什么要同时加因为纯尺寸展开虽然方向对了但内容出现得比较“硬”尤其在折叠时内容一下子缩没了视觉上会有突兀感。叠加透明度渐变后内容在展开时逐渐清晰折叠时逐渐淡出感知上更丝滑。这里一个小细节SizeTransition 的 sizeFactor 和 FadeTransition 的 opacity 用同一个 CurvedAnimation会保持节奏一致不会出现“尺寸已经展开但透明度还没跟上”的割裂感。你当然也可以用不同曲线分别控制但一般场景下共用同一个动画值就够了代码也更简洁。4.2 用 Interval 控制时间段有时候我们希望动画不是全程一个速度而是“先撑开再淡出”或者“尺寸先到位内容再缓慢浮现”。这时候可以用 Intervalfinal sizeAnim CurvedAnimation( parent: _controller, curve: const Interval(0.0, 0.7, curve: Curves.easeOut), ); final opacityAnim CurvedAnimation( parent: _controller, curve: const Interval(0.3, 1.0, curve: Curves.easeIn), );这样实现的展开效果是前 70% 的时间完成尺寸展开后 30% 的时间透明度才跟着变化。视觉上很像很多 App 里展开面板的“先撑开、再渲染内容”的效果。注意 Interval 的 begin/end 都在 0.0 到 1.0 之间表示的是整个动画生命周期的时间占比。如果想做更复杂的多段动画可以用 TweenSequence把一个 Animation 拆成几段不同曲线的映射。这个在实现“下拉刷新头部展开 提示文字切换”这类需求时很有用。Flutter 显式动画的可控性强本质上就体现在这些组合能力上。4.3 在列表、下拉刷新等场景中的应用SizeTransition 在列表场景也很常用。比如自定义下拉刷新头部用户下拉时头部高度随着手势偏移量变大到达临界点后播放“展开成功”动画。这里 SizeTransition 的优势在于可以把动画值和手势 offset 联动而不是简单地在两种状态之间切换void _onScrollUpdate(double offset, double threshold) { _controller.value (offset / threshold).clamp(0.0, 1.0).toDouble(); }因为 SizeTransition 的 sizeFactor 接受任意 Animation 你既可以用 AnimationController 驱动也可以用手势位移直接设置 controller.value 来驱动。这种做法在交互上比固定时长的动画更跟手用户拖到哪头部就展开到哪松手后再决定继续展开还是回弹。如果是手风琴互斥展开常见做法是父组件维护一个当前展开项的 id点击某一行时父组件 setState 更新 id并给每个 ExpandableItem 传入 “expanded (id this.id)”。子组件在 didUpdateWidget 里比较 oldWidget.expanded 和 widget.expanded据此决定 forward 还是 reverse。如果把展开 id 放在 Provider 或 Riverpod 的状态里多个 item 会自动响应状态变化这基本就是 Flutter 组件通信在复杂列表场景里的标准解法。5. 性能优化与渲染引擎踩坑5.1 RepaintBoundary 与局部重绘动画组件在构建树里尽量放在 RepaintBoundary 内部这样动画每帧变化时不会引发整个页面重绘。比如一个列表页里有多个可展开的卡片如果每个卡片都被 RepaintBoundary 包裹那么只有正在展开的那张卡片会进入重绘渲染其他卡片不会被影响。Flutter 的渲染管线在没有 RepaintBoundary 时绘制命令可能被合并到同一个 layer导致动画一跑整棵树都要重绘。你可以在布局树里显式写 RepaintBoundary 包住动画区域也可以利用 ListView 的分隔机制。自己心里有数最重要否则动画简单时看不出问题页面复杂一点就掉帧。另一个原则避免在动画过程中 setState 重建动画区域之外的大组件。如果 parent build 因为别的原因频繁执行即使你用了 SizeTransition也一样会掉帧。SizeTransition 本身已经是一个 AnimatedWidget动画值驱动的 build 只发生在动画节点内部这就是我推荐直接用组件而不是在外层包监听器的原因。5.2 controller 生命周期与常见内存泄漏Flutter 显式动画最常见的问题就是忘记释放 controller。表现是运行日志里出现e/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: A Ticker was active but was not disposed...原因很简单State 被销毁时controller 还活着它的 Ticker 还在等待下一帧调度但 vsync 已经失效了。解决方法是正确实现 disposeoverride void dispose() { _controller.removeStatusListener(_handleStatus); _controller.dispose(); super.dispose(); }如果你在 initState 里给 controller 添加了监听器dispose 时最好也把它 remove 掉。虽然 controller.dispose() 会清掉底层 listener但如果你 add 的监听器是引用 State 实例方法的旧的 State 可能仍被 controller 的监听器列表持有造成短暂的内存泄漏。常规做法就是上面这样成对清理。还有一个典型坑在 didUpdateWidget 里调 forward但没有先 stop导致重复调用报错。使用前一定要判断 mounted否则在 dispose 完成后异步回调里操作 controller 会直接抛异常。这个坑我在实际项目里踩过不止一次尤其是组件被快速移除又重新插入时特别容易复现。5.3 Skia 与 Impeller 渲染引擎差异这个话题在 Flutter 近几个版本里特别热。Impeller 是 Flutter 新一代渲染引擎苹果平台已经默认启用Android 也在逐步切换。Impeller 的着色器编译策略和 Skia 不同过去那种“动画跑几秒后突然卡一下”的着色器编译卡顿会减少但另一方面一些自定义 shader、复杂裁剪在 Impeller 下的表现会有差异。做动画时如果你用了大量 ClipRect、ClipPath、BackdropFilter要注意真机测试渲染开销。我的建议是常规动画优先用组件自带的裁剪少用 shader 特效如果发现动画在 Impeller 下有异常比如透明锯齿、颜色不对先用同一台设备对比 Flutter 不同引擎模式的差异再判断是不是引擎层问题。这种问题通常没有万能解只能靠经验积累。5.4 动画与 PlatformView、Hero 叠加时的遮挡问题如果用 Flutter 的 PlatformView比如 WebView、地图而 SizeTransition 动画的区域正好和 PlatformView 重叠动画过程中可能出现遮挡错乱或黑块。原因在于 PlatformView 的渲染层是单独的原生视图不在 Flutter 的 layer 树里Flutter 侧的裁剪和变换无法影响它。解决思路不复杂临时隐藏 PlatformView动画结束后再显示或者用 Flutter 侧绘制一个简单的占位图避免原生视图和 Flutter 动画层冲突。这个坑不常见但一旦碰上非常头疼提前了解能省不少排查时间。5.5 页面切换时动画状态恢复在一个页面用 SizeTransition 做展开动画切到后台再回来controller 可能停在切换前的某个中间值。一般可以在 AppLifecycleState.resumed 时把 controller 恢复到目标状态或者调用 controller.stop() 让它保持当前值避免异常跳动。这个细节很多人忽略但做长时间运行的 App 时会明显感知到状态“错位”。6. 常见问题排查与调试技巧6.1 动画启动但没有任何视觉变化这是 SizeTransition 最常被吐槽的问题。排查顺序看 sizeFactor 是否从 0 开始。如果 controller.value 初始为 1那动画确实不会变化。看 axis 方向是否符合预期。想要高度展开却用了 AxisDirection.left看起来当然像啥都没发生。看父布局是否限制了 child 的空间。比如放在 Stack 里外层没有约束高度SizeTransition 的主轴尺寸变化可能不会体现。用 debugPrint 打印 controller.value确认动画数值确实在变化。如果数值变化但视觉无变化大概率是外层布局约束了。6.2 动画结束后 child 溢出或被截断如果溢出检查是否加了 ClipRect如果被截断可能是外层还有 ClipRect 限制。SizeTransition 不会自动裁剪也不会自动布局所以这个锅不能都甩给组件要把裁剪的责任明确交给哪一层。我通常在组件内部默认包一层 ClipRect外部调用方就不用关心了。6.3 多节点展开折叠互相“打架”常见于手风琴一个 item 展开另一个 item 也同时展开视觉混乱。原因是状态没提升。解决把展开的 id 放到父组件子组件只在 didUpdateWidget 里根据 expanded 变化执行 forward/reverse。同时注意如果父组件重建时直接给每个 item 传新的 bool但 controller 动画还没完成需要先 stop 再 forward否则会互相打断。6.4 动画卡顿定位是 layout 还是 paint打开 Flutter 的 Performance 面板如果卡顿帧的 CPU 时间集中在 layout说明动画过程中 child 在反复布局如果集中在 paint说明裁剪或 shader 太重。SizeTransition 理论上不触发 child 内部重新 layout但如果你把 child 放在不可控的容器里每帧都在重排文字那就会出现 layout 密集。优化方式是把不需要重排的内容抽出去或用固定的 key / const 减少重建。6.5 调试工具推荐Flutter Inspector 的 Layout Explorer 可以直观查看 SizeTransition 动画前后的尺寸变化。debugProfileBuildsEnabled 可以看 build 频率。在 profile 模式下用 Performance Overlay 检查帧耗时别在 debug 模式测性能debug 模式的性能数据没有参考意义。6.6 面试高频SizeTransition 和 AnimatedContainer 的区别怎么说很多面试官会拿 “Flutter 动画怎么实现” 当开场题其实考察的是对隐式/显式动画的理解。简洁的回答思路先说 Flutter 动画分为隐式与显式两类然后提 AnimatedContainer 属于隐式适合简单状态切换而 SizeTransition 属于显式动画需要 AnimationController通过 sizeFactor 控制主轴方向的尺寸比例可以精确控制动画时长、曲线和启停还能和其他显式动画组件自由组合。这样回答既体现原理又落到实际场景还能展示你用过组件。最后给一张速查表方便以后排查问题原因解法动画没启动sizeFactor 未从 0 开始 / axis 方向错检查 controller.value 与 axis内容溢出未加 ClipRect外层包 ClipRect报 Ticker 未释放dispose 缺失dispose 里 controller.dispose()动画重排卡顿child 布局频繁减少 layout 触发、用 RepaintBoundary遮挡问题叠加了 PlatformView临时隐藏 PlatformView快速点击动画打架未判断 isAnimatingtoggle 里先判断动画状态最后说点个人经验。我一开始用 SizeTransition 也踩了不少坑尤其是“为什么动画不动”和“为什么内容露出来”后来才意识到它的核心是 sizeFactor 和裁剪而不是一种通用的容器动画。现在我做展开折叠动画第一反应就是动画值驱动谁、裁剪放哪层、透明度要不要跟着走。把这三个问题想清楚代码基本一次就过。如果你手头正有一个“展开更多/收起”的需求建议直接拿上面的组件跑一遍跑完你就明白为什么 Flutter 社区都说 SizeTransition 是显式动画里被低估的好东西了。
返回列表