Роман Турусов
Старший разработчик приложений IT-компании Lad
Сегодня хочу поговорить именно об анимации в приложениях, написанных с помощью react-native (далее RN), а точнее о библиотеке react-native-reanimated, заменяющая инструмент стандартного api Animated в RN.
В статье используется react-native-reanimated версии 1.13.3, поскольку начиная со второй версии библиотека получила много архитектурных обновлений. А также ограничения, которые противоречили важным фичам, использующимся в RN, и удобству отладки приложения.
Статья будет полезна тем, у кого есть бэкграунд в разработке мобильных приложений на RN.
Мотивация
Оптимизация
Первоначально проект был создан для решения проблемы взаимодействия с приёмником событий жестов — когда компонент можно перетаскивать по экрану. А при отпускании он привязывается к какому-либо месту. Несмотря на использование Animated.event и сравнивание текущего положения жеста с положением компонента на экране, а также выполнение всего этого взаимодействия в UI-потоке с флагом useNativeDriver, нам всё равно в конце анимации приходилось возвращать состояние жеста в JS. Это могло привести к потере кадров.
Это связано с тем, что выполнение анимации Animated.spring(props).start() не может использоваться по-настоящему декларативно. Когда функция вызывается, то возникает «побочный эффект» в виде запуска процесса (анимации). Он обновляет значение некоторое время.
Добавление узлов «побочных эффектов» в текущую реализацию Animated оказалось довольно сложной задачей — модель выполнения api запускает все зависимые узлы каждого рендера для компонентов, которые необходимо обновить. Разработчики библиотеки не хотели запускать «побочные эффекты» чаще, чем необходимо, потому что это, например, приведёт к многократному выполнению одной и той же анимации.
Удобство
Еще одним источником вдохновения для изменения внутреннего устройства Animated стала работа Krzysztof Magiera про перенос функциональности Анимированного отслеживания в собственный драйвер.
Стандартное Animated api оказалось поддерживает не всё, что могла делать неродная API. Одна из целей react-native-reanimated заключалась в предоставлении расширенной кодовой базы для создания API, которое позволяло писать более сложные анимации только на JS. И сделать код настолько минимальным, насколько это возможно.
Подход
В react-native-reanimated свойства анимации компонента объявляются в виде узлов (функций), которые передают поведение этого компонента в зависимости от значений, описанных в этих узлах. В сочетании с жестами можно запускать чисто нативные анимации, не пересекая мост между JS движком и нативной частью RN.
Для начала будет полезным ознакомится с 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;}
Затем сверстаем
exportdefault()=>{const clock = useClock();// Вызываем функцию runTiming, определённый выше, чтобы создать// узел, который будет использоваться для translateX трансформации. const translateX = runTiming(clock,-120,120)return(<View style={styles.container}><Animated.View
style={[styles.box,{ transform:[{ translateX }]}]}/></View>);}const styles = StyleSheet.create({
container:{
flex:1,
justifyContent:'center',
alignItems:'center',},
box:{
width:50,
height:50,
backgroundColor:'#4585E6'}}
Сократим
Теперь реализуем универсальный инструмент, который может анимировать значения в зависимости от состояния. Для начала стоит разделить функцию расчета значений runTiming на функцию получения настроек для описания узлов анимации и хук, возвращающий анимированное значение для вёрстки.
interface AnimationTimingProps {
value: Value;
trigger:boolean;
easing: Animated.EasingFunction;
duration: number;}// Получить объект с настройками для описания узлов анимацииconst getTimingSettings =({
value,
trigger =false,
easing = Easing.inOut(Easing.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 до 1return 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<P = number>({
range:[from, to],
callback =()=>0,
trigger =false,
easing = Easing.inOut(Easing.circle),
duration =300,}: HookTimingProps): Animated.Node<P>{// ...// ...// возвращаем из функцииif(typeof from ==='string'&&typeof to ==='string'){// преобразовать число "value" в цвет, который будет находится в градиенте // между from и toreturn interpolateColors(value,{
inputRange:[0,1],
outputColorRange:[from, to],}) as Animated.Node<P & string>;}return value.interpolate({
inputRange:[0,1],
outputRange:[from, to],
extrapolate: Extrapolate.CLAMP,}) as Animated.Node<P & number>;// ...
Осталось лишь добавить узел в вёрстку
// Расположим рядом с translateXconst boxBackColor = useTiming({
trigger,
range:['#4585E6','#37BA96'],});// И добавим анимированный цвет к квадрату<Animated.View
style={[
styles.box,{
transform:[{ translateX }],
backgroundColor: boxBackColor,},]}/>
Теперь useTiming может возвращать анимированое числовое и цветовое значение узла в зависимости от переданного интервала в range. Такого примера достаточно, чтобы создавать простые анимации и делать ваши приложения приятнее глазу.
Дальше что?
Сейчас мы рассмотрели пример базовой анимации, которая показывает основную возможность — создание её декларативно. react-native-reanimated имеет широкое api и множество возможностей для использования. Часть из них ещё необходимо будет разобрать. И мы вернёмся к этому в следующей части.
Мы используем файлы cookies. Это необходимо для анализа трафика и корректной работы сайта. Продолжая работу с сайтом, вы подтверждаете свое согласие на применение этих технологий.