Собираем анимацию: значение, подписка, кадр
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В «Подходе» между подходами идёт отдых, и полоса над списком должна таять, пока он длится. В вебе вы бы объявили transition и сменили ширину — остальное дорисовал бы браузер (такой переход между двумя состояниями разобран в прологе). Здесь этот приём проваливается, причём коварно: в предпросмотре полоса едет, а на телефоне прыгает. Поэтому движение соберём сами из трёх деталей — значения, подписки и кадра — и добьёмся, чтобы кадр перерисовывал полосу, а не весь экран. В этой миниатюре потом узнаётся Animated.
Начнём с привычки. Полоса — вложенный View, ширина которого зависит от состояния: нажали «Отдых» — ширина стала нулём. Чтобы убывание было плавным, в вебе хватает одной строки transition — перенесём её как есть. Через 300 мс спросим у полосы её ширину методом measure: ref в React Native отдаёт не DOM-узел, а нативный компонент, и размеры тот сообщает в колбэк, а не возвращает.
Примеры этой статьи — про кадры, а кадры браузер выдаёт только видимому «экрану примера». Пока тот прокручен за край окна, requestAnimationFrame не вызывается и переход CSS не идёт — пример напечатает не то. Длинный пример запускайте клавишей Enter в поле прогноза: экран примера стоит прямо над ним.
Прогноз: промолчит ли компилятор? И окажется ли полоса через 300 мс на полпути?
Компилятор возразил сразу, и возразил дословно: «"transition" не существует в типе "ViewStyle | RecursiveArray<Falsy | ViewStyle>"». А полоса тем не менее поехала: через 300 мс она где-то посередине. Кто прав?
Правы оба, но про разные машины. В предпросмотре работает react-native-web (Platform.OS здесь — "web"): он переводит объект стиля в стиль браузерного элемента и незнакомый ключ не выбрасывает. Его проверка стилей ругается на animation и на сокращения вроде font, а про transition молчит. Дальше дело браузера: движок переходов у него есть, и он честно тянет ширину секунду.
На телефоне браузера нет. Перед отправкой в нативный слой пропсы проходят через список известных атрибутов компонента, и то, чего в списке нет, пропускается одной строкой:
transition в этом списке нет, и фильтр отбрасывает его молча. Ширина на телефоне станет нулём за один кадр. Выходит, из трёх свидетелей правду про телефон сказал только компилятор с настоящими типами, а предпросмотр соврал в лучшую сторону.
На телефоне иначе: transition, как и любой ключ стиля вне списка атрибутов, до нативного слоя не доезжает. Движение в предпросмотре — заслуга браузера, а не React Native.
Корень провала не в том, что «CSS-переходы не завезли». Переход — договорённость с движком: вы сообщаете конечное значение, а промежуточные досчитывает он. Движка, который досчитывал бы стили, у React Native нет, значит, промежуточные значения придётся досчитывать самим — по одному на кадр.
Досчитывать промежуточные значения — значит много раз в секунду вызывать функцию, которая ставит следующее. Для этого есть requestAnimationFrame, тот же, что в браузере (его место в цикле событий разобрано в статье «Микрозадачи и макрозадачи» части I). В React Native это тоже глобальная функция, и в 0.86 она, как и setTimeout, приходит из самого рантайма, а не из JS-полифила. Колбэк вызывается один раз, перед ближайшим кадром; хотите следующий — заказывайте его изнутри колбэка.
Самое прямое решение — положить долю оставшегося отдыха в состояние экрана и менять её с каждым кадром. Долю считаем от времени, а не от числа кадров: кадр может выпасть, и отдых от этого удлиняться не должен. Рядом с полосой — список подходов, в котором за время отдыха не меняется ничего. Прогноз: сколько раз он перерисуется?
Полоса поехала — на этот раз и на телефоне поехала бы тоже: ширина честно меняется каждый кадр. Но и список перерисовался вместе с ней, с каждым кадром. Для читателя части IV тут нет загадки: состояние лежит в экране, значит, каждое его изменение перерисовывает экран со всеми детьми. Меняется масштаб: не одно нажатие, а шестьдесят изменений в секунду, на экране 120 Гц — сто двадцать. И все эти рендеры идут в JS-потоке, где выполняются и ваши обработчики нажатий, — на телефоне он один на всё приложение.
Обернуть список в memo (часть IV) — значит снять симптом. Причина в другом: значению, которое меняется каждый кадр, не место в состоянии экрана.
Колбэк requestAnimationFrame получает аргумент — штамп времени, и считать долю пути от него соблазнительно. Но штамп — это время начала кадра, а не момент вызова колбэка. Если поток был занят, кадр успевает начаться раньше, чем колбэк выполнится, и штамп выходит старым.
Насколько старым — зависит от того, чем занят поток. В нашей песочнице отставание штампа от момента вызова держалось в пределах десятка миллисекунд, и анимация от него заметно не дёрнулась бы; на телефоне, где тот же поток занят рендерами и обработчиками, разрыв тем больше, чем дольше длился предыдущий кадр, — и анимация, считающая долю пути от штампа, на первом же кадре уезжает вперёд. Величина здесь поэтому описана, а не показана запуском: замер, снятый на одной машине, на другой не повторится.
Поэтому часы засекают сами. Так поступает и Animated: TimingAnimation и в react-native-web, и в React Native 0.86 берёт Date.now() при старте и на каждом кадре, а аргумент колбэка не читает вовсе.
Вынесем долю отдыха из экрана в отдельный объект — анимируемое значение. Оно хранит число, умеет его отдать и сменить, а при смене само обходит подписчиков. React о нём не знает; узнаёт только тот компонент, который подписался.
Подписываться будет полоса — лист дерева. Для подписки компонента на внешний источник в React есть useSyncExternalStore, им мы подписывались на состояние сети в статье «Свой хук: логика без разметки» части IV: функция подписки плюс функция чтения. Методы get и subscribe сделаны полями-стрелками не ради красоты — почему, сказано в комментарии.
Кадров пока не будет: значение подвинем руками, в двух тактах — сначала одна смена, потом две подряд. Прогноз: сколько строк «экран» и сколько «полоса»?
Экран отрисовался один раз — при монтировании, и больше его не трогали: изменения шли мимо него, прямо в полосу. А строк «полоса» после первой две, хотя смен три. Две смены подряд в одном такте React склеил в одну отрисовку: подписчик React получил оба уведомления, но к моменту отрисовки значение уже было 10%, и промежуточные 30% рисовать не стали. Для анимации это ровно то, что нужно: на экран попадает последнее значение, а не каждое промежуточное.
Уведомлений же три — по числу смен. Само значение ничего не склеивает: о кадрах и отрисовках оно не знает, это забота того, кто подписан.
Слово «такт» здесь буквальное: всё, что выполнилось подряд в одной задаче, до первой паузы. По той же причине два fireEvent подряд в одном такте дают одну отрисовку.
Осталось соединить значение с кадрами. Функция animate запоминает, откуда и когда начали, и на каждом кадре ставит значению ту долю пути, которая к этому моменту пройдена. Кадры она заказывает сама, пока не выйдет время; снаружи видно только значение, которое меняется.
Возвращает она функцию остановки — отмену следующего кадра. Форма выбрана нарочно: такую функцию можно прямо вернуть из эффекта как уборку, и уход экрана остановит анимацию. Класс AnimValue из прошлого раздела уехал в подложку примера.
Прогноз: сколько раз за время отдыха отрисуется экран и сколько — полоса?
Сравните с прошлым разделом: там в состоянии экрана лежала та же доля, и на каждый кадр приходился рендер экрана со всеми детьми. Сейчас на каждый кадр — рендер одного маленького компонента из двух View.
Кадры заказывает сама анимация, а не экран. Уйдёт экран без уборки — цикл продолжит крутиться в JS-потоке и будить подписчиков, которых уже никто не видит. Третья задача ниже — ровно про это.
Настоящий Animated — следующая статья, но узнать в нём нашу миниатюру можно уже сейчас. Его значение, Animated.Value, — такое же число с подписчиками. Прогноз: сколько строк «слушатель» напечатается?
Отличий от нашей миниатюры четыре. Слушатель получает не число, а объект { value }. Подписка возвращает не функцию отписки, а строку-номер, и отписываются по нему через removeListener. Сравнения в setValue нет: одно и то же 0.5 разбудило слушателя дважды. И публичного чтения нет — значение отдают только колбэком, например в stopAnimation. Последнее не небрежность: с useNativeDriver настоящее значение живёт в нативном потоке, и синхронно спросить его из JS нельзя, — об этом третья статья главы.
| Наша миниатюра | Animated | Что это |
|---|---|---|
new AnimValue(1) | new Animated.Value(1) | число вне состояния |
subscribe(fn) → функция отписки | addListener(fn) → строка-номер | подписка на смену |
set(v) со сравнением Object.is | setValue(v) без сравнения | смена и уведомление |
animate(v, to, ms) | Animated.timing(v, …).start() | цикл кадров со своими часами |
полоса на useSyncExternalStore | Animated.View | подписчик в листе дерева |
Последняя строка таблицы в предпросмотре совпадает буквально: Animated.View в react-native-web на каждый кадр JS-анимации перерисовывает сам себя через собственный useReducer — ровно как наша полоса. На телефоне React Native 0.86 идёт дальше. Флаг shouldUseSetNativePropsInFabric там включён по умолчанию, и кадр кладёт новые значения прямо в нативный компонент через setNativeProps, мимо рендера React. Сам React узнаёт о них одним обновлением через 48 мс после последнего кадра — чтобы его дерево не разошлось с экраном.
На телефоне иначе: Animated.View на JS-анимации не перерисовывает себя каждый кадр, а правит нативный компонент напрямую. Наша полоса и предпросмотр перерисовывают лист — это дороже, но подписка устроена так же.
Что осталось за бортом миниатюры: плавность хода (первая задача ниже), пружина, связки анимаций и превращение одного числа в ширину, угол или цвет — это timing, spring и interpolate в следующей статье. А то, что можно не перерисовывать даже лист, потому что кадр считается вовсе не в JS, — статья про useNativeDriver.
Всё, что собрано в статье, без показательных примеров. В таком виде миниатюра лежит в подложке задач ниже и переезжает в следующие статьи главы.
transitionв стиле не работает: компилятор с настоящими типами его не пропускает, предпросмотр двигает полосу силами браузера, а на телефоне ключ молча отбрасывается фильтром атрибутов, и ширина прыгает за один кадр.- Движение — это функция, которая на каждом кадре ставит следующее значение. Следующий кадр заказывают
requestAnimationFrameизнутри колбэка, а долю пути считают по своим часам: выпавший кадр не растягивает анимацию, а старый штамп кадра не бросает её вперёд. - Значению, которое меняется каждый кадр, не место в состоянии экрана: иначе с каждым кадром перерисовывается экран со всеми детьми.
- Анимируемое значение — число с подписчиками вне React. На него подписывается лист дерева через
useSyncExternalStore, и кадр перерисовывает только лист; две смены в одном такте React склеивает в одну отрисовку. animateвозвращает остановку, и её можно отдать эффекту как уборку: уход экрана останавливает кадры.Animated.Valueустроен так же, с отличиями: слушатель получает{ value }, подписка — строка, сравнения вsetValueнет, публичного чтения нет.Animated.Viewв предпросмотре перерисовывает себя каждый кадр, а на телефоне 0.86 правит нативный компонент черезsetNativeProps, мимо React.
1/4Полоса со стилем transition: "width 1s" в предпросмотре плавно убывает. Что будет на телефоне?
2/4Почему долю пути считают от времени, а не от числа прошедших кадров?
3/4Полоса подписана на значение через useSyncExternalStore. За время анимации экран отрисовался один раз, полоса — по разу на кадр. Почему экран не перерисовывается?
4/4Значение сменили дважды подряд в одном такте. Сколько раз вызваны его подписчики и сколько раз отрисовалась полоса?
Плавный ход: easing
Сейчас полоса убывает с постоянной скоростью и останавливается как вкопанная. Допишите easeOut — быстрый старт и мягкая остановка — и научите animate пропускать через неё долю времени. Проверка простая: к половине времени должно быть пройдено больше 70% пути.
Смягчение — это функция, которая переводит долю времени в долю пути: на входе и на выходе числа от 0 до 1, и на концах она обязана давать 0 и 1, иначе анимация начнётся или закончится не там.
К половине времени кубическое смягчение проходит 87,5% пути. Заметьте, что условие окончания осталось прежним — t < 1, по времени, а не по пути: easing меняет форму движения, но не его длительность. В Animated это параметр easing у timing, и готовые кривые лежат в модуле Easing — следующая статья.
Надпись, которая меняется раз в секунду
Под полосой нужна надпись «3 с, 2 с, 1 с». Она подписана на то же значение, что и полоса, и поэтому перерисовывается каждый кадр, хотя видимых смен у неё всего четыре. Сделайте так, чтобы надпись перерисовывалась только тогда, когда меняется число целых секунд. AnimValue и animate трогать нельзя.
Подписчик сам решает, насколько подробно ему нужно значение. useSyncExternalStore после каждого уведомления зовёт функцию чтения и сравнивает результат с прошлым через Object.is: совпало — рендера нет. Значит, читать надо не долю, а то, что видно на экране:
Рендеров становится четыре: при монтировании и на 2, 1 и 0. Уведомлений при этом столько же, сколько кадров, — функция чтения выполняется на каждом, но она дешёвая, а рендер нет. Полоса и надпись подписаны на одно значение и получают его с разной подробностью: в этом и смысл подписки в листе.
Остановить отдых при уходе с экрана
Таймер отдыха закрыли раньше времени, а кадры продолжают идти: значение меняется, подписчики просыпаются, хотя таймера на экране уже нет. Исправьте RestTimer так, чтобы после ухода с экрана не было ни одного кадра.
Эффект запустил анимацию, но не сказал, как её остановить. А animate как раз возвращает функцию остановки, и форма у неё та, что нужна уборке эффекта. Поэтому исправление — одно слово:
Фигурные скобки вокруг тела эффекта убраны нарочно: стрелка без них возвращает результат animate, и React вызовет его при уходе компонента. Так же устроен и Animated: когда Animated.View уходит с экрана, он отцепляется от значения, и если больше ничто к значению не привязано, оно останавливает свою анимацию само. На телефоне это важнее, чем в браузере: JS-поток один на всё приложение, и кадры мёртвого экрана отнимают время у живых.