Animated: timing, spring, interpolate

движение≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Полоса из прошлой статьи двигалась нашей миниатюрой: число с подписчиками и цикл кадров. Таймеру отдыха в «Подходе» нужно больше: кольцо, которое заполняется, пока идёт отдых, толчок, когда он кончился, и несколько ударов пульса перед следующим подходом. Всё это есть в Animated, и почти всё узнаётся: timing — наш animate с другими умолчаниями, interpolate превращает одно число в два угла кольца, spring движется без длительности, а sequence и loop складывают шаги в сценарий. Там, где Animated ведёт себя не так, как ждёшь, это показано запуском.

Разделы темы:·

Animated.timing(значение, настройки) возвращает анимацию, а её start запускает тот самый цикл, который мы собрали: свои часы на Date.now(), доля прошедшего времени, следующий кадр через requestAnimationFrame. Настройки называются знакомо: toValue — куда, duration — за сколько миллисекунд, easing — как (та самая функция из первой задачи прошлой статьи). Обязательна ещё одна, useNativeDriver. Её смысл — тема следующей статьи, пока везде false. Забыть её не дадут типы, а если всё же пропустить, react-native-web напишет в консоль «Animated: `useNativeDriver` was not specified. This is a required option and must be explicitly set to `true` or `false`».

Как и в прошлой статье: кадры идут, только пока экран примера виден. Запускайте примеры с анимацией клавишей Enter из поля прогноза — экран стоит прямо над ним.

Наш animate из подложки и Animated.timing везут по значению от 0 до 1 за одну и ту же секунду и стартуют в одном такте. Прогноз: кто впереди на четверти времени, кто — на трёх четвертях и кто доедет первым?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

На четверти времени timing позади, на трёх четвертях — впереди, а доезжают оба в последнем кадре секунды: наш — строкой раньше, потому что его кадр заказан раньше. Длительность одна, форма пути разная. У animate доля пути равна доле времени, а timing без easing пропускает её через S-образную кривую: медленный разгон, быстрая середина, мягкая остановка. Это умолчание записано в исходнике прямо:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Для таймера отдыха такое умолчание вредно: кольцо, которое трогается нехотя и тормозит у конца, врёт о времени. Поэтому кольцу — easing: Easing.linear, а S-кривая остаётся тому, что должно выглядеть мягко: появлению, сдвигу, толчку. Готовые кривые лежат в модуле Easing: linear, ease, quad, cubic, bezier(…) и обёртки in, out, inOut.

Второе отличие — как сообщают о конце. Колбэк start получает не пустой вызов, а объект { finished }: true, если анимация дошла сама, и false, если её прервали. Прервать можно, даже не вызывая stop: у значения идёт не больше одной анимации за раз, и новая останавливает прежнюю. На этом держится кнопка «+30 с»: отдых убывает, а нажатие запускает новую анимацию того же значения. Прогноз: в каком порядке появятся три строки?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Прежняя анимация получила finished: false, причём синхронно — внутри вызова start новой, раньше строки, которая стоит после него. Молча значение её не бросило: колбэк прерванной анимации вызывается всегда, и в разделе про связки на этом флаге держится весь сценарий.

В подложке этого примера и всех следующих, где анимацию прерывают, стоит строка var global = globalThis. Останавливая анимацию, react-native-web зовёт global.cancelAnimationFrame: имя global в React Native есть, а в браузере его нет. Без этой строки прерывание падает с ReferenceError, но в выводе вы увидите не её, а «Uncaught — ошибка выброшена из подключённой библиотеки…»: ошибка брошена внутри react-native-web, и браузер прячет её текст. На телефоне строка не нужна.

Кольцо отдыха управляется одним числом — долей прошедшего отдыха от 0 до 1. Но на экран нужно не оно, а углы. Кольцо без SVG собирается из двух половин: в каждой за окном в полкруга крутится дуга в полкруга, и видно ровно то, что успело въехать в окно (устройство — в углублении ниже). Правая половина заполняется за первую половину отдыха, левая — за вторую.

Число в угол превращает interpolate: inputRange — точки входа, outputRange — что им соответствует на выходе, между точками — прямая. На выходе можно писать и строки с единицами вроде "180deg", и цвета. Результат — не число, а новый анимируемый узел: он пересчитывается сам, когда меняется исходное значение, и его можно отдать в стиль Animated.View, как само значение.

Анимации пока нет: поставим долю 0.25, затем 1 — через setValue. Прогноз: какие углы напечатают половины? Посмотрите и на экран примера.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

На четверти правая половина честно повернулась на 90°, а левая — на −90°, хотя её отрезок ещё не начинался. На экране это видно: дуга занимает не четверть кольца, а половину, — лишнюю четверть нарисовала левая, провернувшись в обратную сторону. А в конце правая ушла на 360°: дуга сделала полный оборот, вернулась за край окна, и правая половина кольца опустела.

Дело в том, что interpolate делает за краем inputRange. По умолчанию extrapolate равен "extend": прямая продолжается за крайние точки, и доля 0.25 для отрезка [0.5, 1] даёт законные −90°. Половине кольца нужен "clamp": за краем отрезка выход стоит на крайнем значении. Допишите extrapolate: "clamp" обеим половинам и запустите снова: на четверти левая скажет 0deg, в конце правая — 180deg, и кольцо станет честным.

extrapolateЧто за краем inputRangeКогда нужно
"extend" — по умолчаниюпрямая продолжается дальшевыход без естественного предела: сдвиг, который может уйти за край
"clamp"выход стоит на крайнем значенииуглы половин, прозрачность, цвет — всё, у чего есть предел
"identity"выход равен входуредко: когда за краем значение должно идти как есть

Край можно задать и с одной стороны: extrapolateLeft и extrapolateRight перекрывают общий extrapolate.

Слушатель у interpolate срабатывает, только пока узел кто-то рисует. Узел, не отданный ни одному Animated.View, к исходному значению не подцеплен, и его addListener молчит: значение будит только подцепившихся детей. Так в предпросмотре, и так же в исходнике React Native 0.86.

Каждая половина — три View. Внешний — окно: половина квадрата кольца с overflow: "hidden", всё, что за его краем, обрезается. Внутри — вертушка: квадрат размером со всё кольцо, поставленный так, чтобы его центр совпал с центром кольца; поворот в React Native, как и в CSS, идёт вокруг центра элемента. На вертушке — дуга: полукруг с рамкой в 12 dp, без рамки на прямой стороне и со скруглением в половину размера.

В исходном положении дуга правой половины лежит в левой половине квадрата, за краем окна, и не видна. Поворачиваясь по часовой стрелке, она въезжает в окно от верхней точки, и видимая часть растёт ровно на угол поворота; к 180° дуга целиком в окне. Левая половина устроена зеркально: её дуга начинает справа и въезжает в левое окно снизу.

Обычно кольцо рисуют через react-native-svg, но это отдельный нативный пакет, и в предпросмотре его нет. Три View на половину обходятся тем, что есть в любом React Native, а поворот — одно из свойств, которые в следующей статье уедут в нативный поток.

Кольцо с clamp уехало в подложку: дальше оно называется Ring и принимает одно значение progress.

Когда отдых кончился, кольцо должно не просто замереть, а толкнуться: так приложение говорит «пора». Для толчков у Animated есть spring. Длительности у пружины нет вовсе — в её настройках нет поля duration. Вместо часов — затухающие колебания груза на пружине: жёсткость stiffness, трение damping, масса mass. Кадр за кадром пружина считает, где сейчас груз, и останавливается сама, когда и отклонение от цели, и скорость стали не больше тысячной.

Задают одну из трёх групп: stiffness/damping/mass, tension/friction или bounciness/speed. Смешивать их нельзя — пружина бросит ошибку при старте. Без настроек берутся tension: 40 и friction: 7.

Подвинем долю кольца пружиной от 0.2 к 0.6. Прогноз: окажется ли доля хоть раз больше 0.6? И на каком значении пружина остановится?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Пружина перелетела цель и вернулась. Перелёт невелик: доля доходит примерно до 0.615, на кольце это около пяти градусов. При умолчаниях у неё коэффициент затухания около 0.72 — меньше единицы, значит, колебание будет. Остановилась она ровно на 0.6: на последнем кадре SpringAnimation ставит значение точно в цель, чтобы не остался хвост вроде 0.5999.

Перелёт — главное, что надо помнить, отдавая пружине значение, из которого что-то считается. Кольцо его пережило благодаря clamp: правая половина стоит на своих 180°, куда бы ни улетела доля. А полоса шириной доля × 300 на перелёте вылезла бы за дорожку. Если перелёт не нужен вовсе, есть overshootClamping: true: пружина заканчивается на первом кадре за целью и ставит значение ровно в цель — но сам этот кадр слушатели всё же получают.

Пружину, в отличие от timing, можно перенаправить на лету без рывка: новая spring того же значения подхватывает скорость прерванной — SpringAnimation.start читает её у предыдущей анимации. Поэтому пружины любят жесты, о них четвёртая статья главы.

Соберём таймер целиком: кольцо заполняется за время отдыха, потом три раза бьётся пульс. Можно цеплять шаги колбэками — start(() => следующий.start()), — но у Animated есть готовые связки. Animated.sequence([…]) запускает анимации по очереди: следующая стартует из колбэка предыдущей. Animated.loop(анимация, { iterations }) повторяет одну анимацию, а без iterations — бесконечно. Связка сама тоже анимация, с start и stop, поэтому связки вкладываются друг в друга.

Пульс — масштаб всего кольца, transform: [{ scale: pulse }]. Прогноз: в каком порядке появятся строки и сколько ударов насчитает слушатель пульса?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
Пульс тронулся, только когда кольцо уже было полным: sequence запускает следующий шаг из колбэка предыдущего, а тот приходит в кадре, где шаг кончился. Ударов три — по числу iterations, это число кругов, а не повторов сверх первого. Колбэк связки пришёл один раз, в самом конце, с finished: true: у шагов внутри связки своих колбэков нет — каждый шаг связка запускает сама и передаёт ему свой обработчик окончания.

Внутри loop — не просто повтор. Перед каждым кругом он сбрасывает анимацию:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

reset() у простой анимации возвращает значению то, с чего оно начинало жизнь, — число из new Animated.Value(…), и возвращает молча, без слушателей. Для пульса «вверх — вниз» это незаметно: круг и так кончается там, где начинался. А петля из одного шага «вверх» перед каждым кругом прыгала бы вниз — это вторая задача.

СвязкаЧто делаетПодробность
sequence([a, b, …])по очередишаг с finished: false обрывает всю цепочку
parallel([a, b, …])все сразу; колбэк — когда кончились всепрерванная по умолчанию останавливает остальные (stopTogether)
stagger(мс, [a, b, …])все сразу, но каждая следующая с задержкойвнутри — parallel из sequence([delay(мс × i), анимация])
delay(мс)пауза как шаг сценариявнутри — timing пустого значения с задержкой
loop(a, { iterations })повтор; без iterations — бесконечноперед каждым кругом reset()

Отдых можно пропустить. stop() у связки останавливает текущий шаг, тот сообщает finished: false, и sequence, получив его, дальше не идёт: колбэк связки вызывается с тем же false, а оставшиеся шаги не стартуют вовсе. Прогноз: что напечатает колбэк отдыха после «Пропустить», был ли толчок и где встанет кольцо?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Значение застыло там, где его застали, — никакого возврата в начало, это делает только reset(). Толчка не было: до второго шага связка не дошла. Сбросить кольцо после пропуска — отдельное решение, и принимать его приходится в колбэке, по finished.

Есть и второй путь к остановке, о котором в прошлой статье было сказано словами. Когда последний Animated.View, рисующий значение, уходит с экрана, узлы отцепляются, и значение останавливает свою анимацию само: в исходнике 0.86 его __detach вызывает stopAnimation(). Проверим: закроем экран с кольцом посреди отдыха.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Сначала закончился обработчик нажатия, затем React убрал экран с кольцом — и в этот момент анимация получила finished: false. Кадров дальше не было, кольцо до конца не дошло, хотя эффект уборки не написан. Отсюда правило: анимацию, которую рисует один экран, отдельно останавливать не нужно. Но только если значение больше никто не рисует: значение, общее для двух экранов, — скажем, кольцо в шапке и на экране отдыха, — будет крутить кадры, пока жив второй. Тогда остановку отдают эффекту, как в третьей задаче прошлой статьи: return () => animation.stop().

В React Native 0.86 значение в компоненте заводят хуком useAnimatedValue(0) — внутри это useRef, который создаёт Animated.Value один раз на жизнь компонента. В типах хук есть, а в react-native-web 0.21 его нет: в предпросмотре вызов падает с «TypeError: useAnimatedValue is not a function». Поэтому примеры держат значения на уровне модуля, а в компоненте предпросмотра значение заводят так же, как внутри хука: useRef(null) и new Animated.Value(0) только при первом рендере, пока ref пуст.

Кольцо в том виде, в каком оно лежит в подложке примеров со spring и дальше и переезжает в следующую статью: там его половины будут поворачиваться уже в нативном потоке. Над ним в подложке — строка var global = globalThis.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

  • Animated.timing — тот же цикл кадров со своими часами, что наш animate, но без easing он идёт по кривой Easing.inOut(Easing.ease), а без duration длится 500 мс. Кольцу таймера нужен Easing.linear.
  • Колбэк start получает { finished }. Новая анимация того же значения прерывает прежнюю, и та синхронно получает finished: false.
  • interpolate делает из значения производный узел: число — в угол, цвет, ширину. За краем inputRange прямая по умолчанию продолжается ("extend"), и половинам кольца нужен "clamp". Слушатель у такого узла молчит, пока его никто не рисует.
  • spring не знает длительности: он движется по затухающим колебаниям, при умолчаниях перелетает цель и останавливается сам, ставя значение ровно в цель.
  • sequence и loop — тоже анимации: они вкладываются, stop() обрывает всю цепочку с finished: false, а loop перед каждым кругом молча сбрасывает значение в начальное.
  • Когда последний Animated.View значения уходит с экрана, значение останавливает анимацию само; значение, которое рисуют двое, останавливают вручную в уборке эффекта.

1/4Animated.timing без easing и линейный animate везут значение от 0 до 1 за одну секунду. Что на 250-й миллисекунде?

2/4Половине кольца задан interpolate с inputRange: [0.5, 1] и outputRange: ["0deg", "180deg"], без extrapolate. Какой угол при значении 0.25?

3/4Что видно на экране от Animated.loop(Animated.timing(pulse, { toValue: 1.2, … })), если pulse создан как new Animated.Value(1)?

4/4Связку sequence([заполнение, толчок]) остановили посреди заполнения. Что будет?

Кольцо краснеет к концу

Пусть последние 20% отдыха будут заметны: до 80% цвет зелёный, дальше краснеет и к концу становится красным. Сейчас цвет начинает меняться с первой секунды. Поправьте interpolate — больше ничего менять не нужно.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

В inputRange может быть больше двух точек: между каждой соседней парой — своя прямая. Зелёный до 0.8 — это отрезок, на котором выход не меняется, то есть две точки с одинаковым цветом:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

На половине отдыха цвет теперь ровно rgba(46, 125, 50, 1), а к концу — красный. Точки inputRange не должны убывать, иначе interpolate бросит ошибку «inputRange must be monotonically non-decreasing». За краями здесь clamp не нужен, пока доля честно лежит от 0 до 1; если её двигает пружина, цвет на перелёте уйдёт дальше красного — тогда extrapolate: "clamp".

Пульс без скачка

Пульс должен плавно расти до 1.2 и так же плавно возвращаться, три раза. Сейчас кольцо растёт, а потом рывком падает обратно. Исправьте beat, не трогая слушателя.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Рывок делает сам loop: перед каждым кругом он вызывает reset(), и значение молча возвращается к начальной 1, а следующий круг снова растит его с 1. Обратный путь должен быть шагом анимации, а не сбросом:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Сброс перед кругом остался, но теперь он незаметен: круг кончается на 1, и reset() ставит ту же 1. Выключить сброс через resetBeforeIteration: false здесь не помогло бы: второй круг начался бы с 1.2 и поехал бы к тем же 1.2 — кольцо застыло бы увеличенным после первого роста.

Не хвалить за пропущенный отдых

Когда отдых кончился, экран подбадривает: «Отдых окончен — к следующему подходу!». Но сейчас подбадривает и того, кто нажал «Пропустить». Сделайте так, чтобы похвала звучала только за отдых, дошедший до конца.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

stop() не отменяет колбэк, а вызывает его — с finished: false. Значит, колбэк обязан смотреть на флаг:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Колбэк без проверки finished — частая ошибка: всё, что стоит в нём, случится и при пропуске, и при закрытии экрана, и при «+30 с», которое тоже прерывает анимацию. Так же ведут себя и связки: sequence и loop передают своему колбэку тот finished, на котором остановились.