用脚步丈量人生宽度

Flutter 从零开始(十):动画体系,从 Implicit 到 AnimationController 的一条线

这周把「点开卡片展开详情」那个交互返工了一次。第一版全是 AnimatedContainer,高度、圆角、背景色各写一份过渡,看起来挺完整,直到我加了「按住往下拖」这条需求:手指停在中间的时候卡片完全不理人,它连「拖到一半」这个状态都不存在,只有开始和结束两个值在切换。

那次返工让我回头把动画这块重理了一遍。以前我把 Flutter 动画理解成「给属性加过渡」,理完才发现顺序是反的——它先有一个每帧都在变的数字,属性只是这个数字的投影。这一个视角差别决定了哪些需求一行代码就够、哪些必须自己拿 controller。下面记的是我重理之后的理解,以及每一层我付出去的代价。

一个数字的投影:动画的心智模型

Flutter 动画这一套只有三个角色,我把它们各自的事分开看:

  • Animation<double>:一个 0→1(或任何区间)的数字,外加「我变了,要不要通知一声」的能力。它自己不知道时间,也不知道要干什么。
  • AnimationController:唯一真正在「动」的东西。它拿一个 Ticker 的节拍,每帧按「已经过了多久 / 总时长」把 value 往前推一格,然后通知监听者。
  • Tween + Curve:把那个 0→1 换成像素、角度、颜色。Curves.easeOutCubic 本身不动任何东西,它只是一个 0.3 → 0.71 的映射函数。
// 一个 controller 就是一个会自己往前涨的数字
final controller = AnimationController(
  vsync: this,                                    // 节拍从哪儿来:谁给它造 Ticker
  duration: const Duration(milliseconds: 240),
);

// 宽度不是被「过渡」出来的,它是这个 0..1 数字乘出来的一个投影
final width = Tween<double>(begin: 0, end: 320).animate(
  CurvedAnimation(parent: controller, curve: Curves.easeOutCubic),
);
// controller.value 每帧由 Ticker 推进;width.value 只是查一次映射,
// 所以再加十个属性不会多要一次帧,只会多十个乘法

为什么要设计成「每帧重建」而不是「改属性」:build() 是个纯函数,同一个输入永远给同一棵树,框架里没有一处地方能保存「上一次的界面」然后往上抹一笔。于是变化只能表达成状态——动画就是「状态在一段时间里取了十几个值」这一串重建。理解到这里之后,下面三层能力的差别就只有一个问题:这一串取值的过程归谁管。

层次我给出去什么我换到什么我付出去什么
Implicit(AnimatedContainer / AnimatedOpacity / AnimatedAlign)目标值自动补间,一行 durationcontroller 在组件内部,中途改不了、暂停不了、读不到进度
Explicit(AnimationController + AnimatedBuilder)一个节拍和一个曲线可暂停、可反转、可监听、一份进度驱动一组属性生命周期归我管,initState / dispose 一处不漏
CustomPainter + RepaintBoundary每帧的一张画任意形态:粒子、图表线、弧线进度layout 的约束体系不再帮我,逐帧自己画,还要管 shouldRepaint

我自己现在划的线是:一个属性动,AnimatedContainer 那档;两个以上属性要同步、或者中途会改主意(跟手、可打断),拿 controller;画面内容本身每帧不一样,才轮到 CustomPainter。

Implicit 那层替我藏了什么

AnimatedContainer 不是编译器魔法,它内部就是一个 AnimationController 加几个 Tween。didUpdateWidget 里它发现新目标值和旧的不一样,就把当前值当起点、新值当终点重新 forward 一次。

class NowPlayingBar extends StatelessWidget {
  const NowPlayingBar({super.key, required this.expanded});

  final bool expanded;

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      // 只有一个东西要动的时候,implicit 是最短的路径
      duration: const Duration(milliseconds: 220),
      curve: Curves.easeOutCubic,
      height: expanded ? 96 : 56,
      padding: EdgeInsets.symmetric(horizontal: expanded ? 20 : 12),
      decoration: BoxDecoration(
        color: expanded ? const Color(0xFF1C1C22) : const Color(0xFF2A2A33),
        borderRadius: BorderRadius.circular(expanded ? 20 : 10),
      ),
      child: AnimatedOpacity(
        // 这是第二个 controller 了:每个 implicit 组件各藏一个,互相不知道对方在几帧
        duration: const Duration(milliseconds: 160),
        curve: Curves.easeIn,
        opacity: expanded ? 1 : 0,
        child: const TrackTitle(),
      ),
    );
  }
}

上面这两段时长(220 和 160)各跑各的,我要让它们「同时开始、内容先淡完再撑开」就只能靠手算偏移量去凑——这正是那三个 controller 不在我手上带来的后果。我卡住的「跟手拖拽」也是同一件事:expanded 是个 bool,它表达不了「现在开了 43%」。

在这两档之间还有个 TweenAnimationBuilder,我给「数字滚动」这类单值场景用它:

class StepCounter extends StatelessWidget {
  const StepCounter({super.key, required this.count});

  final int count;

  @override
  Widget build(BuildContext context) {
    return TweenAnimationBuilder<int>(
      // 起点是上一次 build 用到的那个值,所以这份「历史」由框架替我记着
      tween: IntTween(begin: 0, end: count),
      duration: const Duration(milliseconds: 400),
      curve: Curves.easeOut,
      builder: (context, value, child) => Text(
        '$value',
        style: Theme.of(context).textTheme.displayMedium,
      ),
    );
  }
}

它和 AnimatedContainer 一样没有对外接口,区别是它把「映射」交给了我(builder 里拿到的是插值后的值),所以任何属性都能动,不只是它认识的那几个。

抽屉式展开卡片:一个 controller 驱动一组属性

这是我这次返工最后落下来的形态。展开、淡入、图标转 180°,三件事共用一条进度。

class ExpandableCard extends StatefulWidget {
  const ExpandableCard({super.key, required this.header, required this.body});

  final Widget header;
  final Widget body;

  @override
  State<ExpandableCard> createState() => _ExpandableCardState();
}

/// 这里只有一个 controller,所以 Single 这一档够用。
/// 后面再加第二个动画(比如标题的独立淡入)时要换成 TickerProviderStateMixin。
class _ExpandableCardState extends State<ExpandableCard>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _curve;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 260),
      reverseDuration: const Duration(milliseconds: 200), // 收起比展开快,观感上不拖沓
    );
    _curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);

    // 状态回调只在动画换阶段时响一次,不是每帧——「到位」那一下的手感反馈挂这里正合适
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        HapticFeedback.selectionClick();
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose(); // 释放对节拍的订阅;State 活不过这一行就会漏
    super.dispose();
  }

  void _toggle() {
    // 用 status 而不是一个自己维护的 bool:动画中途被点时 bool 和画面会各说一套
    if (_controller.status == AnimationStatus.completed ||
        _controller.status == AnimationStatus.forward) {
      _controller.reverse(); // 从当前帧往回走,不跳回起点
    } else {
      _controller.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        InkWell(onTap: _toggle, child: widget.header),
        SizeTransition(
          // sizeFactor 只裁主轴方向:内容多高由子树自己量出来,
          // 我不需要预先知道它有几行
          sizeFactor: _curve,
          axis: Axis.vertical,
          child: widget.body,
        ),
      ],
    );
  }
}

reverse() 是这一档给的第一样东西:卡片展开到 60% 时用户改主意,动画从 60% 往回播,而不是先跳回 0 再重放。implicit 做不到这件事,因为它每次收到新目标都是「以当前画面为起点重新起一段」,而收起和展开在它那儿是两个组件的属性。

我第一版没走 SizeTransition,是 AnimatedContainer 里 tween 一个 height,从 0 到 180。列表里内容短的那几条正常,长的那条炸了:

A RenderFlex overflowed by 92 pixels on the bottom.

原因是我把内容高度当成了已知量。height: 180 是给子树的硬约束,内容实际需要 272,超出部分被 Column 报出来——动画只负责让这个错误持续 260 毫秒。SizeTransition 的做法不一样,它让子树按自己的自然尺寸完成布局,然后对已经画好的那一层做主轴裁切和缩放,所以约束冲突从根上不存在。要不要在意这点,取决于内容高度是不是我写死的:只要有一处是「估的」,它迟早撞上。

TickerProvider 是什么,为什么它和 State 绑在一起

AnimationController 构造函数里那个 vsync 是唯一必填项,它要的是一个 TickerProvider——一个能造出「节拍源」的对象。Ticker 本身做的事很少:向 SchedulerBinding 登记一个回调,然后在平台每次给 vsync 信号(也就是系统准备合成下一帧)时被叫一次,参数是从开始到现在过了多久。

为什么非要平台给节拍,自己 Timer 每 16 毫秒 tick 一次不行吗:不行,而且慢得不只是那点开销。系统的合成是有节拍的,我自排的时间器和它对不齐,就会出现「同一帧里我算了两格、另一帧一格没算」,画面表现为抖动;更糟的是页面被完全盖住的时候我的 timer 照跑不误,白干一整轮 build-layout-paint。挂到 vsync 上,节拍天然和这一帧的合成点同步;而且 Flutter 在路由被上层完全遮住时会把该子树的 TickerMode 关掉,Ticker 自动静音——「设置页开着,首页那个轮播还在转」这类白耗电,框架在这儿已经替我挡掉了。

节拍订阅是绑在「这个 State 实例」上的,这也是 with SingleTickerProviderStateMixin 必须写在 State 上的原因:它替我保证订阅的生命周期和 widget 在树上的存在期一致。代价是它只给一个:

Bad state: _ExpandableCardState#8f3c1 is a SingleTickerProviderStateMixin but multiple tickers were created.

它后面还跟一句「A SingleTickerProviderStateMixin can only be used as a TickerProvider once」。我撞上一次,第二次才看懂:一个 controller 一个 ticker,TabController 也是一个 controller。我那次是在已有的展开动画卡片里加了个 TabBar,DefaultTabController 在背后又造了一个,报错里那个 runtimeType 就是我的 State 名——这行是最有用的线索,它直接告诉我「谁被当成了两次 vsync」,而不是我新加的那个 widget 有什么问题。同一个 State 要多个 controller 就换 TickerProviderStateMixin;我一般顺手把「这个 State 有几个动画」写在类注释上,因为半年后加第二个的人大概率是我自己。

曲线怎么选,以及为什么线性看起来像坏了

曲线是 0→1 的一个重映射函数,它不改变时长,只改变「这段时间里进度怎么走」。线性看着不对,我觉得值得从原理说清:任何一段动画的起点都是静止、终点也是静止,而 linear 在中间是匀速——于是速度在开头那一帧从 0 跳到常量、在结尾那一帧从常量掉回 0。加速度在两头是无穷大,这在现实里没有对应物,眼睛把它读成「被传送了一下」。easeOut 系就没有这个跳变,它起步就是最快,然后一路减速停住。

曲线观感我用它的场合
Curves.linear两头各有一下「咔」匀速旋转的 loader、循环进度环——它本来就不该有起止
Curves.easeOut / easeOutCubic冲出来、慢慢停住进场、展开,这是我用得最多的一档
Curves.easeIn先犹豫一下再消失退场,配合比进场更短的时长
Curves.fastOutSlowIn起步快、收尾拖得长Material 页面转场的默认值;用它能和框架其它动效同族
Curves.easeInOut两头都缓位移超过半个屏幕时,纯 easeOut 会显得开头就冲过头
Curves.easeOutBack / elasticOut过冲、回弹图标点按那种几像素的弹一下;位移大时像果冻

时长我按位移分了三档,这几组数字来自我自己在真机上一个个试出来的感觉:几像素的图标状态变化 100–150 毫秒,卡片内展开 200–280 毫秒,整页转场 300–400 毫秒。同一条曲线,时长翻倍之后观感不是「更慢的动画」而是「卡顿」,所以这两件事我总是一起调。

进入页面要编排一串动作时,我不给每个属性单开 controller,而是切同一条进度:

class _PageEnterState extends State<PageEnter> with SingleTickerProviderStateMixin {
  late final AnimationController _c;

  // 三条子区间共用一个节拍:谁先谁后写在 curve 里,进度本身只有一个来源
  late final _fade = CurvedAnimation(
    parent: _c,
    curve: const Interval(0.00, 0.45, curve: Curves.easeOut),
  );
  late final _slide = CurvedAnimation(
    parent: _c,
    curve: const Interval(0.10, 1.00, curve: Curves.easeOutCubic),
  );
  late final _scale = CurvedAnimation(
    parent: _c,
    // TweenSequence 处理的是「一个属性自己分段」:先过冲一点再落回 1.0
    curve: const TweenSequence<double>([
      TweenSequenceItem(tween: Tween(begin: 0.90, end: 1.04), weight: 0.6),
      TweenSequenceItem(tween: Tween(begin: 1.04, end: 1.00), weight: 0.4),
    ]),
  );

  @override
  void initState() {
    super.initState();
    _c = AnimationController(vsync: this, duration: const Duration(milliseconds: 520));
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _c,
      // child 传进来的是不参与动画的那半棵树,每帧重建的只有下面这三层包裹
      builder: (context, child) => Opacity(
        opacity: _fade.value,
        child: Transform.translate(
          offset: Offset(0, 24 * (1 - _slide.value)),
          child: Transform.scale(scale: _scale.value, child: child),
        ),
      ),
      child: widget.child,
    );
  }
}

编排的省处不在代码量,在「只有一个东西在推进」。三个 controller 各跑各的,只要有一次 setState 时机不同、或者中间有哪个被 stop() 过,那几个属性就会分家;一条进度加三段区间不存在这个问题。

跟手:把 value 直接接到手指上

这是让我返工的那个需求。做法其实很朴素:拖动期间我不要用动画,动画只在松手之后负责把剩下的路走完。

class _DrawerState extends State<_Drawer> with SingleTickerProviderStateMixin {
  static const double _travel = 320; // 全开行程(像素),速度换算要用它除一下
  late final AnimationController _c;
  double _dragFrom = 0;              // 按下那一刻的进度,整段拖动以它为基准

  @override
  void initState() {
    super.initState();
    _c = AnimationController(vsync: this, duration: const Duration(milliseconds: 280));
  }

  void _onDragStart(DragStartDetails d) {
    _c.stop(); // 上一段动画还在跑的话先掐掉,否则 value 被两方轮流覆盖,画面发抖
    _dragFrom = _c.value;
  }

  void _onDragUpdate(DragUpdateDetails d) {
    // 直接赋值:通知监听者但不产生动画——「跟手」这三个字就来自这一行
    final next = _dragFrom - d.delta.dy / _travel;
    _c.value = next.clamp(0.0, 1.0); // 越过端点继续拖时,进度停在端点而不变成负数
  }

  void _onDragEnd(DragEndDetails d) {
    // 手势给的是 px/秒,controller 要的是「进度/秒」,除以行程换单位
    final v = -d.velocity.pixelsPerSecond.dy / _travel;
    if (v.abs() < 0.5) {
      // 慢慢拖到一半停手:没有速度可继承,按离哪端近来落位
      _c.animateTo(_c.value > 0.5 ? 1 : 0, duration: const Duration(milliseconds: 240));
      return;
    }
    // 有速度就交给 fling:它按当前速度反推一条衰减曲线,松手那一帧速度是连续的
    _c.fling(velocity: v).whenCompleteOrCancel(() {
      // 抛物线可能停在 0.97 这种没落位的地方,收尾补一次归位
      _c.animateTo(_c.value > 0.5 ? 1 : 0, duration: const Duration(milliseconds: 120));
    });
  }
}

value 这个 setter 有两件事值得说清。一是它接到新值的同时会把正在跑的动画停掉——所以我在 _onDragUpdate 里赋值,等于顺手掐掉了上一次 forward 的尾巴,这正是「手指接管」要的语义;反过来说,在同一个回调里既赋值又启动画是不会有效果的。二是它接受的范围只有 lowerBound..upperBound(默认 0..1),超出部分被裁掉,而手势送来的距离是没有上限的(用户可以把手指拖出屏幕),所以我在赋值之前先 clamp 一次。

还有一处方向容易摆反:越界的不只是输入,曲线之后也会越界。Curves.elasticIn、Curves.easeOutBack 这类过冲曲线给的就是 1.08 这种值,CurvedAnimation 不会替我把它压回区间内。我的 clamp 一直放在 controller 这一层;放在曲线之后就变成把过冲那一下削平,而我要的恰恰是那一下。

fling 和 animateTo(1) 的差别值得单独说。后者我在第一版用的就是它:松手瞬间进度从「跟着手指的速度」变成 0,再由一条固定 240 毫秒的曲线重新启动——手指甩得越快,这一下越像卡了一帧。fling 继承的是速度本身,甩得越远走得越快、剩下的距离越短停得越早,这条连续性正是「跟手」这件事的最后一半。顺便一条:Draggable / LongPressDraggable 上有个 DragStartBehavior,start 是拖到一定 slop 之后才开始、down 是指尖落下即动,我的经验是跟手抽屉用 down 更贴合预期,而列表项拖拽排序用 start 才不会被纵向滚动误触——这两种场景对「什么时候算开始拖」的判断本来就不一样。

Hero:跨页面那段飞行

Hero 是我最省事的一层:两侧页面上各放一个 Hero,tag 相同,页面切换时框架自己找到配对、自己补间。

// 列表项
Hero(
  // tag 带业务 id:用 index 的话,列表局部刷新或重排之后它会指向另一张封面
  tag: 'cover-${track.id}',
  child: CoverImage(url: track.coverUrl, size: 56),
)

// 详情页顶部,同一个 tag
Hero(
  tag: 'cover-${track.id}',
  child: CoverImage(url: track.coverUrl, size: 340),
)

它的机制是 Rect 补间:飞行时框架量出两侧矩形,逐帧插值出一个中间矩形,再把 child 按这个矩形摆上去。所以飞行过程中圆角半径是不变的常量,而盒子在从 56 长到 340——中间那几帧看起来是一个被拉长的胶囊。我处理这个问题的办法是让两侧的宽高比尽量一致,或者干脆只给图片本身套 Hero、把圆角挂在一个不参与飞行的外层;至于要不要在意这点,取决于两侧尺寸差多少,我在同一尺寸档的两个页面之间用 Hero 时完全看不出痕迹。

另一处坑是 tag 撞车:

There are multiple heroes that share the same tag within a subtree.

后面还会跟一句「each Hero must have a unique non-null tag」和一份把 offending hero 打出来的 widget 树。我的现场和「详情跳转」没有半点关系:一个页面右下角叠了两个 FloatingActionButton(一个是播放、一个是加入清单),点了播放键就抛这一句。原因是 FloatingActionButton 自带一个默认 heroTag(值就是 'floatingActionButton'),两个 FAB 等于两个同 tag 的 Hero 落在同一路由里。判断方法:报错里那行 tag 值如果是 'floatingActionButton',八成就是 FAB;给两个 FAB 分别传不同的 heroTag 就干净了——这是那句提示里唯一让我觉得反直觉的地方,我以为自己没写 Hero 就没有 Hero 的冲突。

帧预算:掉帧的时候我去看什么

60Hz 屏幕一帧是 16.7 毫秒,90Hz 是 11.1,120Hz 是 8.3。动画期间,build、layout、paint 全都要在这一次 tick 里排完,排不完就是这一帧没了——用户看到的不是「慢一点」,而是画面跳一格。所以动画的性能问题在我这儿从来不问「这段代码快不快」,问的是「这一帧的 16 毫秒里有多少被不属于动画的东西占掉了」。

RepaintBoundary 是我最常用的一招。它的原理是给子树单独开一个图层:这一层画完的内容会被留成一张位图,只要子树自己没变化,外层重绘时直接把它贴回来;反过来,子树内部重绘也不会波及外层。

RepaintBoundary(
  // 单独成层:旋转的唱片重绘面积就只是这个圆,不再捎上旁边那一整列歌词
  child: _SpinningRecord(radius: 44),
)

我那次掉帧不是动画本身重,是 AnimatedBuilder 我图省事放在了页面顶层、builder 里返回了整棵页面树。结果每帧把歌词列表、按钮、封面全部重建一遍,Performance 面板里 build 时长从 1.2 毫秒涨到 9 毫秒多。改法是把这个 builder 往下挪,只包住真正随进度变的那一小块,child 参数把不变的那半棵树递进去——这一行的作用就是让框架知道「这部分我每帧不用重新问」。

判断掉帧在哪一侧,我用的是 flutter run --profile(debug 是 JIT 而且带一堆断言,帧率数字没有参考价值),开 Performance 面板看那条帧时间线:Dart 侧的条子长是 build/layout 重,Raster 侧的条子长是绘制本身重——后者才轮到 RepaintBoundary 和图层数去治。这两个原因我的处理方向完全相反,先分清再动手,否则加十个 RepaintBoundary 也不动。

逐帧自绘(粒子、图表线、弧线进度)那层还有个 CustomPaint 专属的机制:shouldRepaint 默认返回 false。

class _ArcPainter extends CustomPainter {
  _ArcPainter(this.progress);

  final double progress;

  @override
  void paint(Canvas canvas, Size size) {
    // 拿 progress 画一段弧;它由外部每帧喂一个新实例进来
  }

  @override
  bool shouldRepaint(_ArcPainter old) => old.progress != progress;
  // 不重写的话默认 false:框架认为这次重建画的还是同一张画,位图直接复用
}

我的第一版进度环写完了在模拟器上是静止的——build 每帧都在跑,但 shouldRepaint 说不用重画,那张位图就被一直复用。这条 API 的默认值对「内容不变的自定义背景」是优化,对「内容就是进度」的 painter 是个坑。另一条更省事的路是给 CustomPaint 传 repaint: _controller,把动画对象直接交给它,由框架决定什么时候重画。

系统要求「减弱动效」的时候

Android 的「移除动画」、iOS 的「减弱动态效果」,Flutter 读得到一个布尔值:

void _toggle() {
  // 3.10 起的写法;更早的版本上是 MediaQuery.of(context).disableAnimations
  if (MediaQuery.disableAnimationsOf(context)) {
    // 直接落到目标状态,跳过中间过程:功能一点没少,只是不再有位移
    _controller.value = _controller.isCompleted ? 0 : 1;
    return;
  }
  _controller.status == AnimationStatus.completed
      ? _controller.reverse()
      : _controller.forward();
}

这个开关对一部分人是刚需(前庭功能障碍者看到大范围位移会恶心),所以我自己写动效的时候顺手带上这个分支——代价就三行代码。

同一条路径上还有另一件事:辅助功能里的字号。我的展开卡片如果走的是 tween height 那条路(就是前面撞 overflow 那种写法),用户在系统里把字号拉到 2.0 之后,展开态的固定高度装不下三行文字,内容被裁掉一半;换成 SizeTransition 之后这条自动成立,因为高度是子树自己量的。这两件事都不是动画本身的问题,但它们最后都在动画这一层暴露出来。

学习时要特别注意的几处

Bad state: _FooState#xxx is a SingleTickerProviderStateMixin but multiple tickers were created.

症状:新加一个动画(或塞进一个 TabBar、AnimatedList)之后,页面直接白屏,报错的 runtimeType 是我自己的 State 名。原因:这个 mixin 只提供一次节拍订阅,而 AnimationController 和 TabController 各自要一个;State 被复用两次也会计成两次。怎么判断:看后半句「can only be used as a TickerProvider once」,出现这条就是数量问题而不是我哪个 widget 写错了;同一个 State 里确实要多个动画就换 TickerProviderStateMixin。

LateInitializationError: Field '_controller@0012345 has not been initialized.'

症状:热重载之后好着,重新进这个页面就炸,炸在 build() 第一行读 controller 的位置。原因:late 字段只保证「编译器不拦我」,不保证「有人给它赋过值」。我把 controller 的创建从 initState 挪进一个 Future 回调(当时是为了先读本地配置再定时长)之后,第一帧在它跑完之前就到了。怎么判断:报错里的字段名就是没赋值那一个;我的处理是恢复在 initState 里创建(时长这种外部参数用 duration 变量传进去,或者干脆 AnimationController 建好之后再改 duration),异步那半只负责 forward。

A RenderFlex overflowed by N pixels on the bottom.

症状:只在内容长的那几条数据上出现,而且是黄黑条纹加一句控制台警告,界面看起来像「展开到一半被切掉了」。原因:我 tween 的是一个写死的 height,它是给子树的硬约束,而内容的自然高度超过了这个数。怎么判断:报错里带的尺寸信息会给出「约束给了多少、孩子要多少」,两个数字一差就是那个 N;只要动画的目标高度是我估出来的,这条迟早会来。

动画压根不动,值是对的。

症状:expanded 变了,界面「啪」地跳到新样子,中间一帧过渡都没有。原因有两档:一是 implicit 组件靠新旧 widget 对比来发现目标值变了,我如果改字段没走 setState,didUpdateWidget 根本不会被调用;二是 AnimatedContainer 在树上换了位置或换了 key(列表里我用 index 当 key 时最容易碰到),框架把它当成一个新组件,State 重建、controller 从新值起步,于是没有「从旧值补间」这回事。怎么判断:第一条看有没有 setState;第二条在 initState 里加一行 print,动画一次它就响一次就说明组件被重建了。

热重载之后时长改了没反应。

症状:duration 从 260 改成 600,保存、热重载,动画还是原来那个速度。原因:initState 只在这个 State 实例创建时跑一次,热重载保留 State、只替换 widget,那段创建代码不会重跑。怎么判断:热重启(大写 R)之后立刻生效的话就是这条,跟动画本身无关。我的习惯是改 controller 构造参数之后一律热重启。

隐式动画叠三层时各跑各的。

症状:外层容器还在撑高,里面的文字已经淡完等着了,看着像两个组件各自在动。原因:每个 implicit 组件内部都是一个独立 AnimationController,同时开始只是「碰巧在同一帧收到新值」,它们之间没有任何同步关系;一旦其中某个的时长和另外两个不一样,分家就是必然的。怎么判断:把三处 duration 改成完全一样,如果还别扭,说明我要的是先后关系而不是同时——那时候一个 controller 加三段 Interval 更省事。

评论

暂未收到

写下评论

支持 Markdown 语法