Kodokon kodokon.com

动画:隐式与显式的底层原理

理解隐式动画的内部机制,并精细地驱动你自己的 AnimationController。

10 分钟 · 3 题

在 Kodokon 中打开本课

AnimatedContainer 属于隐式动画这一大家族:你改变一个目标值,框架就为这个过渡制作动画。在底层,每个 ImplicitlyAnimatedWidget 都在它的 state 里藏着一个 AnimationController。当一份新的配置到来时,didUpdateWidget 会通过 forEachTween 遍历各个属性,并给每个 tween 重新设定目标begin 边界变成当前插值出的值,end 边界变成新的目标,然后控制器从零重新开始。一个宝贵的结果是:在动画中途改变目标不会产生视觉上的跳变,动画会精确地从它所在的位置继续。

DART
import 'package:flutter/material.dart';

class SelectableCard extends StatefulWidget {
  const SelectableCard({super.key});

  @override
  State<SelectableCard> createState() =>
      _SelectableCardState();
}

class _SelectableCardState extends State<SelectableCard> {
  bool _selected = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () =>
          setState(() => _selected = !_selected),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOutCubic,
        width: _selected ? 220 : 160,
        height: 96,
        decoration: BoxDecoration(
          color: _selected
              ? Colors.indigo
              : Colors.indigo.shade200,
          borderRadius: BorderRadius.circular(
            _selected ? 28 : 12,
          ),
        ),
      ),
    );
  }
}
尺寸、颜色和圆角,全都由一次 setState 制作动画。

隐式动画覆盖了框架能预料到的那些属性,而 TweenAnimationBuilder 则把这个过程推广到任何可插值的值。一旦你需要驱动动画,比如暂停、反向、重复、对状态做出反应,就切换到显式AnimationControllervsync 参数期待一个 TickerProvider:mixin 会创建一个 Ticker,也就是由 SchedulerBinding 在每一帧调用的回调。一个鲜为人知的微妙之处:TickerMode静默隐藏子树(例如被覆盖的路由之下)的 ticker。时间照常流逝,但再也没有回调运行了,不会在看不见的动画上浪费一丁点 CPU。

DART
import 'package:flutter/material.dart';

class RevealPanel extends StatefulWidget {
  const RevealPanel({super.key, required this.child});

  final Widget child;

  @override
  State<RevealPanel> createState() => _RevealPanelState();
}

class _RevealPanelState extends State<RevealPanel>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller =
      AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 400),
    reverseDuration: const Duration(milliseconds: 200),
  );

  late final Animation<double> _opacity = _controller
      .drive(CurveTween(curve: Curves.easeOutCubic));

  void toggle() {
    switch (_controller.status) {
      case AnimationStatus.forward:
      case AnimationStatus.completed:
        _controller.reverse();
      case AnimationStatus.reverse:
      case AnimationStatus.dismissed:
        _controller.forward();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: toggle,
      child: FadeTransition(
        opacity: _opacity,
        child: widget.child,
      ),
    );
  }
}
reverseDuration:进场和退场各有各的节奏。

一个控制器不过是一个从 0 跑到 1 的 Animation<double>:要组合它,而不是直接生用。controller.drive(CurveTween(...)) 会产生一个不带任何额外状态的派生动画。要把这个值应用到树上,优先使用 Transition 系列 widget(FadeTransitionRotationTransition):它们直接在渲染对象层面监听动画,不会每帧重建哪怕一个 widget。当变换是任意的时候,AnimatedBuilder 能胜任这份工作,前提是你要通过 child 传入静态子树:它只会构建一次,然后在每一帧被重新注入到 builder 中。这是显式动画中回报最高的优化。

DART
import 'dart:math' as math;

import 'package:flutter/material.dart';

class Orbit extends StatefulWidget {
  const Orbit({super.key});

  @override
  State<Orbit> createState() => _OrbitState();
}

class _OrbitState extends State<Orbit>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller =
      AnimationController(
    vsync: this,
    duration: const Duration(seconds: 4),
  )..repeat();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      child: const Icon(Icons.satellite_alt, size: 32),
      builder: (context, child) {
        final angle = _controller.value * 2 * math.pi;
        return Transform.translate(
          offset: Offset(
            math.cos(angle) * 80,
            math.sin(angle) * 80,
          ),
          child: child,
        );
      },
    );
  }
}
作为 child 传入的图标只会构建一次。

知识检测

确认你已牢记本课的重点内容。

  1. 一个 AnimatedContainer 在上一段动画进行到一半时收到了一个新的目标颜色。会发生什么?
    • tween 被重新设定目标:它从当前值出发,朝着新目标重新开始,没有视觉跳变
    • 正在进行的动画先跑完,然后新的动画才开始
    • widget 立即跳到新颜色
  2. AnimationController 的 vsync 参数究竟是做什么用的?
    • 它提供一个与帧同步的 Ticker,当子树被隐藏时 TickerMode 可以将其静默
    • 它让控制器与应用中的其他动画保持同步
    • 它把动画限制在每秒 30 帧
    • 它启用 GPU 硬件加速
  3. 为什么要通过 AnimatedBuilder 的 child 参数来传入静态子树?
    • 它只构建一次,然后被重新注入到 builder 中,而不是每帧都被重建
    • 这是强制的:builder 不能返回新的 widget
    • 它会在结束时自动释放控制器