Kodokon kodokon.com

Анимации: неявные и явные под капотом

Разберись во внутренней механике неявных анимаций и научись тонко управлять собственным AnimationController.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

AnimatedContainer принадлежит семейству неявных анимаций: ты меняешь целевое значение, а фреймворк анимирует переход. Под капотом каждый ImplicitlyAnimatedWidget держит AnimationController, спрятанный в своём state. Когда приходит новая конфигурация, 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 обобщает подход на любое интерполируемое значение. Как только тебе нужно управлять анимацией - пауза, обратный ход, повтор, реакция на статус - переходи к явному AnimationController. Параметр vsync ждёт TickerProvider: миксин создаёт Ticker, колбэк, который SchedulerBinding вызывает на каждом кадре. Малоизвестная тонкость: TickerMode приглушает тикеры скрытого поддерева (например, под перекрытым маршрутом). Время идёт дальше, но ни один колбэк больше не выполняется - ноль процессора на невидимые анимации.

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: у появления и у исчезновения свой темп.

Контроллер - это всего лишь Animation<double>, идущая от 0 до 1: собирай из неё производные вместо того, чтобы использовать её сырой. controller.drive(CurveTween(...)) даёт производную анимацию без единого лишнего состояния. Чтобы применить значение к дереву, предпочитай виджеты Transition (FadeTransition, RotationTransition): они слушают анимацию напрямую на уровне render object, не перестраивая ни одного виджета за кадр. Когда преобразование произвольное, работу делает 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 перенацеливается: он стартует от текущего значения к новой цели, без визуального скачка
    • Сначала доиграет текущая анимация, потом начнётся новая
    • Виджет мгновенно перескакивает к новому цвету
  2. Для чего именно нужен параметр vsync у AnimationController?
    • Он даёт Ticker, синхронизированный с кадрами, который TickerMode может приглушить, когда поддерево скрыто
    • Он синхронизирует контроллер с остальными анимациями приложения
    • Он ограничивает анимацию 30 кадрами в секунду
    • Он включает аппаратное ускорение на GPU
  3. Зачем передавать статическое поддерево через параметр child у AnimatedBuilder?
    • Оно строится один раз и потом подставляется в builder, вместо перестройки на каждом кадре
    • Это обязательно: builder не может возвращать новые виджеты
    • Так контроллер автоматически освобождается в конце