AnimationController выдаёт число, которое за duration проходит от 0 до 1 и продвигается раз в кадр с помощью Ticker; Tween переводит эти 0..1 в нужный диапазон, и в итоге получается Animation<T>, которую слушают transition-виджеты и по которой перерисовываются.
**Animation<T>**: Listenable с текущими value и status; о времени она ничего не знает
**AnimationController**: та Animation<double>, которой вы управляете через forward(), reverse(), repeat() и stop(); раз вы его создаёте, вы его и уничтожаете
**Ticker**: вызывает контроллер раз в кадр, пока Flutter рисует; его даёт vsync: this вместе с SingleTickerProviderStateMixin
**Tween<T>**: begin и end; tween.animate(controller) возвращает Animation<T>, а Curve между ними меняет темп, но не диапазон
Статусы: forward и reverse, пока анимация идёт, completed, когда она остановилась на верхней границе, а dismissed, когда на нижней
FlutterMiddle
Анимации и движение
AnimationController, Ticker, Tween, Curves, AnimatedBuilder, staggered-анимации, Lottie и Rive
Закрыто вопросов
0 / 21
- 0Повторить
- 4Часто спрашивают
Вопросы
Вы перестраиваете его с новым значением свойства; он сравнивает его с предыдущим и анимирует от того, что сейчас на экране, к новой цели за свои duration и curve, причём контроллером, который сам создаёт и сам уничтожает.
Триггер: движение запускает только изменившееся значение: setState с новыми width, color или opacity; перестройка с теми же значениями ничего не делает
Смена цели на ходу: новая цель перезапускает анимацию с текущего значения на экране, поэтому ничего не прыгает
Семейство: AnimatedContainer, AnimatedOpacity, AnimatedPadding, AnimatedAlign, AnimatedPositioned, AnimatedScale, AnimatedSlide, AnimatedDefaultTextStyle; onEnd срабатывает, когда анимация закончилась
Ограничения: ни цикла, ни реверса по команде, ни последовательности; для этого нужен явный AnimationController
Контроллер создаётся в build и нигде не уничтожается. Тест, который разбирает дерево, пока он ещё работает, падает с «was disposed with an active Ticker»; уже второй build бросает «multiple tickers were created», а с TickerProviderStateMixin каждая перестройка протекала бы ещё одним контроллером.
Создавайте один раз: в initState или в поле late final
Уничтожайте: _controller.dispose() в dispose(), до super.dispose()
Изменения от родителя: если поменялся widget.duration, присвойте _controller.duration в didUpdateWidget; пересоздание контроллера оборвёт текущую анимацию
vsync выдаёт Ticker, который продвигает контроллер раз в кадр и глушит его, когда поддерево скрыто. SingleTickerProviderStateMixin даёт ровно один тикер; State с двумя и более контроллерами требует TickerProviderStateMixin.
**Это не Timer**: тикер срабатывает, только когда Flutter собирается рисовать кадр, в такт с дисплеем (60, 90 или 120 Гц); Timer расходится с кадрами и продолжает срабатывать, даже когда ничего не видно
Приглушение: тикеры, созданные State, подчиняются TickerMode, поэтому поддерево под TickerMode(enabled: false) (например, страница под непрозрачным роутом) перестаёт запрашивать кадры
Не тот миксин: второй контроллер на миксине для одного тикера падает с «is a SingleTickerProviderStateMixin but multiple tickers were created»
Зачем два миксина: одиночный чуть дешевле, поэтому он по умолчанию; переходите на множественный, только когда State действительно владеет несколькими контроллерами
Он выведет forward, затем forward animating=false, затем reverse, а примерно через 200 мс выведет dismissed. Слушатели статуса срабатывают только при смене статуса, а stop() замораживает значение, не меняя статус.
**forward() / reverse()**: сразу переводят статус в forward / reverse и заканчиваются в completed (верхняя граница) или dismissed (нижняя)
**stop()**: оставляет value и status как есть и не вызывает слушателей; меняется только isAnimating, который становится false. Вернуть значение в 0 со статусом dismissed позволяет reset()
**reverse() из середины**: проходит только оставшееся расстояние, поэтому здесь занимает примерно половину длительности (reverseDuration, если он задан)
**repeat(reverse: true)**: никогда не останавливается на краю: статус чередуется между forward и reverse и не становится completed, поэтому ветка «по завершении» за циклом не сработает никогда
forward() ведёт контроллер к верхней границе от текущего значения. После первого тапа он уже в completed на 1.0, поэтому каждый следующий forward() ничего не делает и сердечко больше не пульсирует.
Почему баг незаметен: forward() значит «дойти до 1.0», а не «проиграть»; TweenSequence заканчивается на масштабе 1.0, так что контроллер, застрявший в конце, выглядит так же, как контроллер в покое
Исправление: forward(from: 0) сбрасывает значение и проигрывает пульсацию целиком; быстрый второй тап перезапускает её на ходу, и для лайка это как раз то, что нужно. reset(), а затем forward() делают то же самое в два вызова
Когда важен обратный путь: если эффект должен уходить анимацией, а не прыжком, вызывайте reverse() на completed или repeat(reverse: true) для цикла; одиночный forward() срабатывает только один раз
Как поймать: виджет-тест, который тапает дважды, после каждого тапа делает pump() и затем pump(const Duration(milliseconds: 150)) и ожидает масштаб больше 1.0 и после второго тапа
Tween задаёт формулу, а не анимацию: вы задаёте begin и end, а transform(t) возвращает значение в точке t между ними. tween.animate(controller) возвращает Animation<T>, которая прогоняет текущее значение контроллера через tween при каждом чтении .value.
Базовый Tween считает begin + (end - begin) * t, поэтому работает с double и Offset, то есть типами с +, - и *. Остальным типам нужен подкласс, который зовёт lerp самого типа: ColorTween, TextStyleTween, RectTween, AlignmentTween, BorderRadiusTween, а для int нужен IntTween, который округляет.
**animate() ничего не хранит: возвращённая анимация держит родителя и tween и пересчитывает значение по запросу, поэтому один tween может питать много анимаций
controller.drive(tween): то же самое, записанное со стороны контроллера
chain()**: tween.chain(CurveTween(curve: …)) сначала применяет кривую, потом tween; слушателя он не добавляет, уничтожать нечего
Частая ошибка: Tween<Color> бросает «Cannot lerp between …»; берите ColorTween, у которого значение типа Color?
Curves.easeOutBack и семейство elastic намеренно заходят за 1, прежде чем осесть (in-кривые ещё и уходят ниже 0), поэтому tween-ы дальше по цепочке получают значения вроде 1.09. Для масштаба или сдвига это нормально, а Opacity ассертит, что значение лежит в 0..1.
Оставляйте перелёт там, где он допустим: ScaleTransition, SlideTransition, поворот; прозрачность ведите второй кривой, которая не выходит за 0..1. Иметь две кривые от одного контроллера нормально
**FadeTransition: не ассертит, а зажимает значение, так что перелёт там не ошибка, он просто теряется
CurvedAnimation**: ваша собственная Animation<double>: умеет другую reverseCurve на обратном ходе, и её могут делить несколько tween-ов. Она подписана на родителя, поэтому уничтожайте её вместе с контроллером
**Аргумент curve:** у AnimatedContainer и подобных действует только на приватный контроллер этого виджета: одна кривая, всегда вперёд к новой цели, ни с кем не делится
Наследуйтесь от Curve и переопределите transformInternal(double t), который переводит время 0..1 в прогресс. Кривая обязана возвращать ровно 0 в начале и 1 в конце (это за вас гарантирует Curve.transform), так что ваша функция задаёт только то, что происходит между ними.
Cubic-bezier от дизайнера: подкласс не нужен: Cubic(0.2, 0.0, 0.0, 1.0) принимает те же четыре числа, что и CSS cubic-bezier()
Между краями можно всё: значения могут выходить за 0..1 ради замаха или перелёта, с той же оговоркой про Opacity, что и у Curves.easeOutBack
Сначала соберите из готового: Interval сдвигает и сжимает кривую, Threshold прыгает в одной точке, SawTooth повторяет, curve.flipped зеркалит её для обратного хода
**Чистая и const**: transformInternal вызывается каждый кадр для каждой анимации, которая её использует: без состояния и без аллокаций
**Работает везде, где принимается Curve**: CurvedAnimation, CurveTween, curve: у AnimatedContainer
Все три слушают Animation и перерисовываются на её тиках, так что setState вызывать самим не нужно. В первую очередь берите встроенный *Transition, для разового эффекта подойдёт AnimatedBuilder, а от AnimatedWidget наследуйтесь, когда тот же эффект переиспользуется как именованный компонент.
***Transition-виджеты**: FadeTransition, ScaleTransition, SlideTransition, RotationTransition, SizeTransition, DecoratedBoxTransition; перестраивают только себя, а FadeTransition даже этого не делает (его render object слушает анимацию напрямую)
**AnimatedBuilder(animation:, builder:, child:)**: по месту, для эффекта, который не покрывает ни один transition (3D-переворот, цвет у CustomPaint); принимает любой Listenable, поэтому работает и с ValueNotifier, и со ScrollController
**Параметр child**: часть, не зависящая от анимации, строится один раз и передаётся в builder на каждом тике; без него это поддерево перестраивается 60–120 раз в секунду
**AnimatedWidget**: наследуйтесь, передайте анимацию как listenable и читайте её в build: тот же механизм, что у AnimatedBuilder, упакованный для переиспользования
Частая ошибка: controller.addListener(() => setState(() {})): так каждый кадр перестраивается весь State, а не только анимируемая часть
forward() и reverse() возвращают TickerFuture, который завершается, только если анимация дошла до конца. Если её прервали (stop(), вызов в обратную сторону, dispose()), future не завершится никогда: голый await не продолжится, поэтому код после него, finally и любой вызывающий, который ждёт ваш метод, никогда не выполнятся.
**await _c.reverse().orCancel**: при прерывании бросает TickerCanceled, и вы трактуете это как «пользователь передумал»
**whenCompleteOrCancel: колбэк, который выполнится, как бы анимация ни закончилась; для очистки
Status listener**: addStatusListener срабатывает на каждом переходе; проверяйте completed или dismissed. Он живёт столько же, сколько контроллер, поэтому подходит для реакции на каждое проигрывание
Что выбрать: одноразовый уход: await … .orCancel; повторяющаяся реакция: status listener
**После await**: проверяйте mounted, прежде чем трогать context или состояние: экрана может уже не быть
onWrongPin при каждом вызове добавляет новый status listener и никогда его не удаляет. Слушатели живут столько же, сколько контроллер, поэтому на N-й тряске их N, и onShaken (событие аналитики, сброс ввода) по её завершении выполняется N раз.
Почему так происходит: addStatusListener добавляет слушателя в список и не проверяет дубликаты; каждый вызов создаёт новое замыкание, так что тот же код каждый раз становится новым слушателем. Заодно это медленная утечка на всё время жизни экрана
Исправление: регистрируйте слушателя один раз, там же, где создаётся контроллер (или в initState); метод только запускает анимацию. Тогда реакция срабатывает ровно один раз на каждую завершённую тряску
Правило: на addListener или addStatusListener вне initState нужен парный removeStatusListener с той же ссылкой на замыкание; для реакции на один конкретный прогон подойдёт await _shake.forward(from: 0).orCancel, где отписываться не от чего
Как поймать: виджет-тест, который трижды вызывает onWrongPin с pumpAndSettle после каждого вызова и ожидает 3 вызова колбэка, а не 6
Один контроллер: stagger представляет собой единый таймлайн, контроллер и есть этот таймлайн, а каждый Interval говорит, какой его кусок принадлежит свойству. Тогда один reverse() проигрывает всю последовательность назад с той точки, где её прервали.
**Как работает Interval**: Interval(begin, end, curve: …) является Curve, которая возвращает 0 до begin, 1 после end, а между ними прогоняет свою внутреннюю кривую
Доли, а не миллисекунды: три шага по 300 мс в контроллере на 900 мс превращаются в Interval(0.0, 0.33), (0.33, 0.66), (0.66, 1.0); разрыв даёт паузу, наложение смешивает два движения
Смена общей длительности: все куски масштабируются вместе с ней, и ритм сохраняется
Когда нужны отдельные контроллеры: когда у движений независимые времена жизни: крутилка вращается в цикле, пока рядом выезжает шторка
Он забирает ваш tween себе: при каждом новом end ставит в begin значение, которое сейчас на экране, в end ставит новую цель и перезапускает внутренний контроллер с 0. Бейдж всегда продолжает с текущего места, но каждое перенацеливание снова стоит полный duration.
**Сравнивается только end**: новый end запускает анимацию, прежний не запускает ничего
**begin важен только на первой сборке**: если он null, туда копируется end, и при появлении ничего не анимируется; задайте его, если нужна анимация входа
Tween мутируется: виджет хранит и правит первый полученный экземпляр, поэтому никогда не делите один tween между двумя билдерами
Ловушка перенацеливания: позиция остаётся непрерывной, а скорость нет: кривая начинается заново при каждой смене цели, и частые обновления выглядят дёргано; для значения, которое меняется постоянно, берите свой контроллер с пружиной
**child**: передавайте статичную часть в child, чтобы она не перестраивалась на каждом тике
AnimatedSwitcher решает «ребёнок новый» через Widget.canUpdate: тот же runtime-тип и тот же key означают тот же виджет. Два Text с разными строками и без ключа для него одинаковы, поэтому он просто обновляет текст на месте, без анимации.
Дайте ребёнку ключ: ValueKey(value) на том, что должно перезапускать движение; без него подмена проходит молча
Во время подмены живут оба ребёнка: уходящий идёт назад со switchOutCurve, а приходящий идёт вперёд со switchInCurve
**transitionBuilder(child, animation)**: оборачивает каждого ребёнка (по умолчанию FadeTransition), поэтому должен хорошо выглядеть в обе стороны
**layoutBuilder**: расставляет сосуществующих детей; Stack по центру по умолчанию делает переключатель размером с самого большого ребёнка
Потому что удалённая строка должна оставаться на экране, пока проигрывается её исчезновение, а в данных её уже нет, поэтому AnimatedList просит вас отдать виджет самим через билдер, переданный в removeItem.
AnimatedList не сравнивает списки: он держит собственный счётчик элементов, и о каждом изменении вы сообщаете через AnimatedListState.
Удаление: сохраните элемент, удалите его из списка и вызовите removeItem(index, (context, animation) => …) в одном синхронном шаге; билдер захватывает сохранённый элемент, а его анимация идёт от 1 к 0
Вставка: добавьте в свой список, потом вызовите insertItem(index); анимация этой строки идёт от 0 к 1
Несколько сразу: удаляйте от большего индекса к меньшему, иначе индексы съедут
Частая ошибка: setState(() => items.removeAt(i)) без removeItem: счётчики расходятся, и itemBuilder спрашивают индекс, которого больше нет
Lottie проигрывает фиксированный таймлайн из After Effects, который вы запускаете, зацикливаете или перематываете; Rive крутит state machine, входы которой вы задаёте из Dart, поэтому подходит для анимаций, реагирующих на ввод; анимация на Flutter остаётся обычным кодом, который следует теме и состоянию приложения и легко тестируется.
Lottie (пакет lottie): экспорт из After Effects, который каждый кадр рисуется векторными путями; в рантайме его можно проиграть, зациклить или перематывать своим AnimationController, а длительность задаёт файл
Rive (пакет rive): файл .riv с артбордами и state machine; Dart задаёт её входы или привязанные данные, а машина сама выбирает переход, поэтому тапы, наведение и прогресс есть из коробки; файлы часто меньше такого же Lottie
Руками: AnimationController плюс transition-виджеты или CustomPainter: без ассета, цвета темы и RTL бесплатно, покрывается виджет-тестами; платите кругами согласований с дизайнером
Цена на слабых устройствах: сложный Lottie каждый кадр нагружает CPU (renderCache меняет CPU на память); прогоните его на бюджетном Android до релиза
Пусть числа даёт layout, а анимируется контейнер, а не значение: AnimatedSize измеряет ребёнка и, когда измерение меняется, плавно ведёт собственный размер от старого к новому; ни begin, ни end вы не пишете.
**AnimatedSize**: задайте duration, curve и alignment; его собственный размер меняется каждый кадр, поэтому родитель перекладывается каждый кадр: оборачивайте карточку, а не страницу, и дайте ему нежёсткие констрейнты, потому что в тугой коробке размер не изменится
Когда двигаются только позиции: Flow с FlowDelegate(repaint: animation) раскладывает детей один раз и двигает их в paintChildren; анимация трогает только отрисовку, поэтому веер кнопок обходится куда дешевле
Когда не подходит ни то, ни другое: свой RenderObject: подпишитесь на анимацию, на каждом тике зовите markNeedsLayout и читайте animation.value в performLayout
Правило: анимируйте на самой дешёвой фазе пайплайна, которая выражает эффект: отрисовка раньше раскладки, раскладка раньше build
Наследуйтесь от ImplicitlyAnimatedWidget, сделайте его State наследником AnimatedWidgetBaseState и реализуйте один метод, forEachTween: вы отдаёте фреймворку каждый хранимый tween и сохраняете обратно тот, что он вернул.
Взамен вы получаете duration, curve и onEnd в конструкторе, готовую кривую animation, setState на каждый тик и контроллер, который не нужно уничтожать.
Когда вызывается: из initState и из didUpdateWidget, чтобы проверить, изменилась ли хоть одна цель
Вызов: _t = visitor(_t, widget.value, (v) => Tween<T>(begin: v as T)) as Tween<T>? по разу на каждое анимируемое свойство; колбэк-конструктор срабатывает, только пока tween ещё null
Что дальше делает фреймворк: ставит в begin значение tween.evaluate(animation) (то, что сейчас на экране), в end ставит новую цель и перезапускает свой контроллер с 0
Обходите каждое свойство при каждом вызове: пропущенное сохранит старый end и тихо перестанет анимироваться
Типы без арифметики: берите подходящий tween (ColorTween, EdgeInsetsTween), ровно как в явных анимациях
Пока палец на экране, вы сами пишете в controller.value на каждом обновлении драга; когда палец отрывается, отдаёте скорость жеста в controller.fling(velocity: …), и контроллер доводит движение пружиной с той скоростью, которую оставил палец.
**onVerticalDragUpdate**: controller.value -= details.primaryDelta! / extent; сеттер останавливает текущую анимацию и зажимает значение в границах
**onVerticalDragEnd**: переведите в единицы контроллера: -details.velocity.pixelsPerSecond.dy / extent даёт высоты шторки в секунду; fling гонит пружину к 1 при положительной скорости и к 0 при отрицательной
Медленное отпускание: ниже порога скорости бросайте к ближнему краю искусственной скоростью ±1, чтобы шторка всё равно доехала
Частая ошибка: отдать контроллеру 0..1 сырые пиксели в секунду (каждое отпускание выглядит мгновенным) или вызвать на отпускании animateTo (он игнорирует скорость отпускания, и шторка дёргается на фиксированной скорости)
Сначала проверьте готовое: DraggableScrollableSheet и showModalBottomSheet уже это умеют; собирайте руками, только если нужен жест, которого у них нет
У кривой фиксированные форма и длительность, поэтому она не может использовать скорость, которая у карточки уже есть; Simulation стартует с реальной скорости и физических констант, а её длительность получается из физики. «Мёртвое» движение и значит, что карточка проигнорировала скорость, с которой её отпустили.
controller.animateWith(simulation) тикает её каждый кадр: value = simulation.x(t), пока simulation.isDone(t) не вернёт true.
**SpringSimulation(spring, start, end, velocity)**: spring имеет тип SpringDescription: коэффициент демпфирования меньше 1 даёт перелёт с возвратом, а ровно 1 даёт критическое демпфирование; SpringDescription.withDurationAndBounce принимает длительность и «упругость», то есть те числа, которыми говорят дизайнеры
**FrictionSimulation(drag, position, velocity): инерция без цели, как у брошенного списка, который сам докатывается до остановки
Частая ошибка**: обычный контроллер зажимает значение в 0..1 и съедает перелёт, ради которого вы брали пружину; берите AnimationController.unbounded
Что выбирать: продолжение жеста или смена цели на ходу: симуляция с текущей скоростью; старт из покоя по тапу: кривая проще