Прокачиваем анимацию с react-native-reanimated. Часть 1

Kate

Administrator
Команда форума
Роман Турусов

Старший разработчик приложений IT-компании Lad​

Сегодня хочу поговорить именно об анимации в приложениях, написанных с помощью react-native (далее RN), а точнее о библиотеке react-native-reanimated (<https://docs.swmansion.com>), заменяющая инструмент стандартного api Animated в RN.

В статье используется react-native-reanimated версии 1.13.3 (<https://docs.swmansion.com/react-native-reanimated/docs/1.x.x/>), поскольку начиная со второй версии библиотека получила много архитектурных обновлений. А также ограничения, которые противоречили важным фичам, использующимся в RN, и удобству отладки приложения.

Статья будет полезна тем, у кого есть бэкграунд в разработке мобильных приложений на RN.

Мотивация​

Оптимизация​

Первоначально проект был создан для решения проблемы взаимодействия с приёмником событий жестов — когда компонент можно перетаскивать по экрану. А при отпускании он привязывается к какому-либо месту. Несмотря на использование Animated.event и сравнивание текущего положения жеста с положением компонента на экране, а также выполнение всего этого взаимодействия в UI-потоке с флагом useNativeDriver, нам всё равно в конце анимации приходилось возвращать состояние жеста в JS. Это могло привести к потере кадров.
https://tproger.ru/jobs/lead-frontend-engineer/?utm_source=in_text
Это связано с тем, что выполнение анимации Animated.spring(props).start() не может использоваться по-настоящему декларативно. Когда функция вызывается, то возникает «побочный эффект» в виде запуска процесса (анимации). Он обновляет значение некоторое время.

Добавление узлов «побочных эффектов» в текущую реализацию Animated оказалось довольно сложной задачей — модель выполнения api запускает все зависимые узлы каждого рендера для компонентов, которые необходимо обновить. Разработчики библиотеки не хотели запускать «побочные эффекты» чаще, чем необходимо, потому что это, например, приведёт к многократному выполнению одной и той же анимации.

Удобство​

Еще одним источником вдохновения для изменения внутреннего устройства Animated стала работа Krzysztof Magiera про перенос функциональности Анимированного отслеживания в собственный драйвер.


Стандартное Animated api оказалось поддерживает не всё, что могла делать неродная API. Одна из целей react-native-reanimated заключалась в предоставлении расширенной кодовой базы для создания API, которое позволяло писать более сложные анимации только на JS. И сделать код настолько минимальным, насколько это возможно.

Подход​

В react-native-reanimated свойства анимации компонента объявляются в виде узлов (функций), которые передают поведение этого компонента в зависимости от значений, описанных в этих узлах. В сочетании с жестами можно запускать чисто нативные анимации, не пересекая мост между JS движком и нативной частью RN.

block([
cond(not(clockRunning(clock)), startClock(clock)),
timing(clock, state, config),
cond(state.finished,stopClock(clock))
])

К практике​

Для начала будет полезным ознакомится с api Animated.

Установим​

Shell

# Установим библиотеку
npm i react-native-reanimated@1.13.3

# Для iOS
cd ios
pod install

Также вам понадобится ndk версии 21.3.6528147 или выше.

Используем​

Воспользуемся примером из документации и прокомментируем код. Он заключается в передвижении компонента по экрану. Для начала необходимо определить анимацию.

import Animated, {
block,
clockRunning,
cond,
Clock,
debug,
Easing,
set,
startClock,
stopClock,
timing,
Value,
useCode,
Node,
interpolateColors
} from 'react-native-reanimated';

const runTiming = (clock, value, dest) => {

const state = {
finished: new Value(0),
position: new Value(0),
time: new Value(0),
frameTime: new Value(0),
};

const config = {
duration: 5000,
toValue: new Value(0),
// Определяем какое будет "смягчение" относительно, линейного Clock
easing: Easing.inOut(Easing.ease),
};

// Возвращаем узел, который объединяет несколько функций, вызывает их
// в порядке, в котором они передаются в block и возвращает результат
// последнего узла.
return block([
cond(
clockRunning(clock),
[
// Если счетчик уже запущен, то мы обновляем значение toValue.
set(config.toValue, dest),
],
[
// Если счётчик не запущен, то мы сбрасываем все значения и
// запускаем счётчик.
set(state.finished, 0),
set(state.time, 0),
set(state.position, value),
set(state.frameTime, 0),
set(config.toValue, dest),
startClock(clock),
]
),
// Мы определяем здесь шаг, который запускает процесс расчёта значений.
timing(clock, state, config),
// Если анимация закончилась, то мы останавливаем счётчик.
cond(state.finished, debug('stop clock', stopClock(clock))),
// Определяем узел возвращения обновлённого значения.
state.position,
]);
}
Прежде чем верстать интерфейс, нужно сделать хук для создания ссылки на узел счётчика. Это расширенный объект анимированного значения Value. Он может обновляться в каждом рендере и возвращать его timestamp.

function useClock() {
const ref = useRef(new Clock());
return ref.current;
}

Затем сверстаем​

export default () => {
const clock = useClock();

// Вызываем функцию runTiming, определённый выше, чтобы создать
// узел, который будет использоваться для translateX трансформации.
const translateX = runTiming(clock, -120, 120)

return (

);
}

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
box: {
width: 50,
height: 50,
backgroundColor: '#4585E6'
}
}

Сократим​

Теперь реализуем универсальный инструмент, который может анимировать значения в зависимости от состояния. Для начала стоит разделить функцию расчета значений runTiming на функцию получения настроек для описания узлов анимации и хук, возвращающий анимированное значение для вёрстки.

g.circle),
duration = 300,
}: AnimationTimingProps) => {
return {
clock: new Clock(),
state: {
finished: new Value(0),
position: value || new Value(trigger ? 1 : 0),
time: new Value(0),
frameTime: new Value(0),
},
config: {
duration,
toValue: new Value(trigger ? 0 : 1),
easing,
},
};
};

// Определим какие настройки мы будем использовать для расчётов
interface HookTimingProps {
trigger: boolean;
range: [number, number];
duration?: number;
callback?: () => void;
easing?: Animated.EasingFunction;
}

// Хук, возвращающий анимированное значение для вёрстки.
function useTiming({
range: [from, to],
callback,
trigger = false,
easing = Easing.inOut(Easing.circle),
duration = 300,
}: HookTimingProps) {
const value = useValue(trigger ? 1 : 0);

// Хук запуска расчёта анимации
useCode(() => {
const { clock, config, state } = getTimingSettings({
trigger,
value,
easing,
duration,
});

return [
cond(not(clockRunning(clock)), startClock(clock)),
timing(clock, state, config),
cond(state.finished, block([stopClock(clock), call([], callback)])),
state.position,
];
}, [trigger]);

// мапим значения, потому что значение "value" менялось в интервале от 0 до 1
return value.interpolate({
inputRange: [0, 1],
outputRange: [from, to],
extrapolate: Extrapolate.CLAMP,
});
}
Хук useCode в качестве первого параметра получает функцию фабрики, которая должна возвращать узел анимации или массив из узлов. Они будут затем переданы в узел block — и вторым параметром массив зависимостей. Функция обновляет коренной узел во время первого рендера и при каждом изменении значений в зависимостях.

Дальше понадобится изменить вёрстку.

const [trigger, setTrigger] = useState(false);

const translateX = useTiming({
trigger,
range: [-120, 120],
easing: Easing.inOut(Easing.cubic),
duration: 400,
// Вызов после выполнения анимации
callback: () => {
setTimeout(() => {
setTrigger(!trigger);
}, 600);
},
});
Теперь мы получили хук, который в зависимости от состояния возвращает новое анимированное значение, имеет настройки для изменения продолжительности анимации, «смягчение» вектора изменения компонента и возможность выполнить кастомную функцию после выполнения анимации.

Усложним​

Также Reanimated умеет работать не только с числами, но и с цветом.

Мы можем усложнить функцию useTiming и получить не только числовое значение. На самом деле, различие функции расчёта анимации между числовым и цветовым значением различается только в итоговом преобразовании значения value в интервале числовых или цветовых from и to. Поэтому мы можем объединить всё в функции useTiming.

// ...
// Изменяем тип функции
function useTiming

({
range: [from, to],
callback = () => 0,
trigger = false,
easing = Easing.inOut(Easing.circle),
duration = 300,
}: HookTimingProps): Animated.Node

{
// ...
// ...
// возвращаем из функции
if (typeof from === 'string' && typeof to === 'string') {
// преобразовать число "value" в цвет, который будет находится в градиенте
// между from и to
return interpolateColors(value, {
inputRange: [0, 1],
outputColorRange: [from, to],
}) as Animated.Node

;
}
return value.interpolate({
inputRange: [0, 1],
outputRange: [from, to],
extrapolate: Extrapolate.CLAMP,
}) as Animated.Node

;
// ...

Осталось лишь добавить узел в вёрстку​

// Расположим рядом с translateX
const boxBackColor = useTiming({
trigger,
range: ['#4585E6', '#37BA96'],
});
// И добавим анимированный цвет к квадрату
Теперь useTiming может возвращать анимированое числовое и цветовое значение узла в зависимости от переданного интервала в range. Такого примера достаточно, чтобы создавать простые анимации и делать ваши приложения приятнее глазу.

Дальше что?​

Сейчас мы рассмотрели пример базовой анимации, которая показывает основную возможность — создание её декларативно. react-native-reanimated имеет широкое api и множество возможностей для использования. Часть из них ещё необходимо будет разобрать. И мы вернёмся к этому в следующей части.

 
Сверху