Изучаем Reanimated 2 создавая анимированный компонент

React Native предоставляет Animated API который покрывает часть базовых потребностей вашего приложения, однако если требуется создавать сложные и производительные анимации на 60 fps, без сторонних библиотек не обойтись. Сегодня хотел бы рассказать о Reanimated 2 и продемонстрировать возможности библиотеки на примере создания компонента счетчика.

Идея компонента https://dribbble.com/shots/3368130-Stepper-TouchИдея компонента https://dribbble.com/shots/3368130-Stepper-Touch

Reanimated

За анимации в React Native отвечает Animated API позволяющий реализовать множество различных сценариев. Однако у него одна проблема — большинство анимаций рассчитывается в JS потоке и только часть может быть перенесена на UI поток с помощью свойства useNativeDriver

Для того обхода этой проблемы инженеры Software Mansion разработали библиотеку Reanimated которая де-факто стала самым популярным решением для работы с анимацией в React Native. Чтобы освободить JS поток от расчета анимаций Reanimated запускает собственный JS поток, который занимается только расчетом анимации. Это накладывает некоторые ограничения на написание кода и вводит 2 концепции Worklets и Shared Values.

Worklets

Ворклеты в терминах Reanimated это небольшие JS функции которые запускаются в отдельном JS потоке для расчета анимаций. Единственное, что нужно чтобы функция стала ворклетом это пометить функцию директивой worklet

function someWorklet(greeting) {
  'worklet';
  console.log("Hey I'm running on the UI thread");
}

Ворклеты так же можно запустить и в обычном JS. По умолчанию ворклеты запускаются в основном JS потоке. Для того чтобы передать параметры можно воспользоваться параметрами функции или переменными из контекста в котором запускается ворклет. В этом случае эта переменная будет скопирована.

const width = 135.5;

function otherWorklet(greeting) {
  'worklet';
  console.log(greeting, 'From the UI thread');
  console.log('Captured width is', width);
}

Так же внутри ворклета можно вызывать функции из главного потока React Native с помощью функции runOnJS

function callback(text) {
  console.log('Running on the RN thread', text);
}

function someWorklet() {
  'worklet';
  console.log("I'm on UI but can call methods from the RN thread");
  runOnJS(callback)('can pass arguments too');
}

На практике писать ворклеты придется не часто, так как библиотека предоставляет большое количество хуков для анимации которые под капотом уже используют ворклеты.

Shared values

Для того чтобы реализовать анимации одних только ворклетов недостаточно и второй важной концепцией библиотеки являются Shared values. Они решают несколько проблем,  во-первых предоставляют механизм совместного использования памяти для нескольких JS потоков. Вторая функция это реактивность, Reanimated составляет граф зависимостей между Shared values и ворклетами в которых они используются. Далее если ворклет будет запускаться только при начальной инициализации или когда изменяется shared value связанное с этим ворклетом.

import Animated, {
  useSharedValue,
  useAnimatedStyle,
} from 'react-native-reanimated';

function Box() {
  const offset = useSharedValue(0);

  const animatedStyles = useAnimatedStyle(() => {
    return {
      transform: [{ translateX: offset.value }],
    };
  });

  return (
    <>
      
      

useAnimatedStyle под капотом запускает ворклет и каждый раз когда меняется значение offset.value стили пересчитываются. Третьей функцией является управление анимацией. В примере выше View будет менять положение моментально, но если мы хотим это анимировать можно использовать для этого специальные функции которые будут менять значение в shared values с помощью time-based анимаций. Например, если добавить функцию withSpring

Реализация компонента с помощью Reanimated

Без учета стилей компонент получился в 70 строк. Под катом полный код и давайте реазберем его реализацию.

Реализация компонента

import {useState} from 'react';
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import MaskedView from '@react-native-masked-view/masked-view';
import Animated, {
    withSequence,
    useAnimatedStyle,
    useSharedValue,
    withSpring,
    runOnJS,
} from 'react-native-reanimated';
import {StyleSheet, Text, View} from "react-native";

const Counter = ({initValue = 0}) => {
    const [value, setValue] = useState(initValue)
    
    const offset = useSharedValue(0);
    const minusScale = useSharedValue(1);
    const plusScale = useSharedValue(1);

    const ballAnimatedStyles = useAnimatedStyle(() => ({
        transform: [{translateX: offset.value }]
    }));
    const minusAnimatedStyles = useAnimatedStyle(() => ({
        transform: [{scale: minusScale.value }]
    }));
    const plusAnimatedStyles = useAnimatedStyle(() => ({
        transform: [{scale: plusScale.value }]
    }));

    const tap = Gesture.Pan().onUpdate(e => {
        if (e.translationX > 0 && e.translationX < 75) {
            offset.value = e.translationX
        } else if (value > 0 && e.translationX > -75  && e.translationX < 0) {
            offset.value = e.translationX
        } else if (value === 0 && e.translationX > -25  && e.translationX < 0) {
            offset.value = e.translationX
        }
    }).onEnd((e) => {
        offset.value = withSpring(0)

        if (e.translationX > 30) {
            plusScale.value = withSequence(withSpring(1.2), withSpring(1))
            runOnJS(setValue)(value + 1)
        }
        if (e.translationX < -30 && value > 0) {
            minusScale.value = withSequence(withSpring(1.2), withSpring(1))
            runOnJS(setValue)(value - 1)
        }
        if (e.translationX < 0 && value === 0) {
            minusScale.value = withSequence(withSpring(1.2), withSpring(1))
        }
    })

    return }
    >
        
            
                -
            
            
                
                    {value}
                
            
            
                +
            
        
    
}

const styles = StyleSheet.create({
    maskContainer: {
        width: 180,
        height: 80,
    },
    maskElementContainer: {
        width: 180,
        height: 80,
        borderRadius: 40,
        backgroundColor: '#7775ff',
        zIndex: 0
    },
    controlContainer: {
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'space-between',
        width: 180,
        height: 80,
        borderRadius: 40,
        backgroundColor: '#7775ff',
    },
    ballContainer: {
        alignItems: 'center',
        justifyContent: 'center',
        width: 80,
        height: 80,
        borderRadius: 40,
        backgroundColor: '#fff',
        zIndex: 20
    },
    signText: {
        fontSize: 36,
        color: '#fff',
        fontFamily: 'monospace',
    },
    minusText: {
        fontSize: 36,
        color: '#fff',
        fontFamily: 'monospace',
    },
    disabledText: {
        color: 'rgba(256, 256, 256, 0.4)',
    },
    leftContainer: {
        flex: 1,
        alignItems: 'center',
    },
    rightContainer: {
        flex: 1,
        alignItems: 'center',
    },
    counterText: {
        fontSize: 36,
        color: '#7775ff',
        fontFamily: 'monospace',
        fontWeight: 'bold'
    },
});

export default Counter

Помимо Reanimated используются Gesture Handler и библиотека для создания маски Masked View.

}
    >
        
            
                -
            
            
                
                    {value}
                
            
            
                +
            
        

Компонент состоит из контейнера который представляет собой MaskedView и трех Animated.View которые будем анимировать — это значки плюса и минуса и контролер со значением внутри. Для обработки жеста обернем контролер с помощью GestureDetector.

// ...
const offset = useSharedValue(0);

const ballAnimatedStyles = useAnimatedStyle(() => ({
  transform: [{translateX: offset.value }]
}));
// ...
const tap = Gesture.Pan().onUpdate(e => {
    if (e.translationX > 0 && e.translationX < 75) {
        offset.value = e.translationX
    } else if (value > 0 && e.translationX > -75  && e.translationX < 0) {
        offset.value = e.translationX
    }
}).onEnd((e) => {
    offset.value = withSpring(0)

    if (e.translationX > 30) {
        runOnJS(setValue)(value + 1)
    }
    if (e.translationX < -30 && value > 0) {
        runOnJS(setValue)(value - 1)
    }
})
// ...

  
     {value}
  

// ...

Для начала анимируем движение контролера. Для этого понадобится создать shared value с помощью которой будем анимировать движение контролера и добавить обработчик события. Так же используем хук useAnimatedStyle создающий ассоциацию между значением shared value и свойствами View с которыми его ассоциируем. В нашем случае это трансформация по оси X которую передаем во View с помощью свойства style. В обработчике касания используется 2 метода onUpdate и onEnd. В onUpdate фиксируем изменение положения по оси X и соответственно меняем значение shared value ассоциированного с useAnimatedStyle ворклетом. Обработчик onEnd срабатывает при завершении жеста, и с помощью функции withSpring возвращаем значение shared value в 0, а так же увеличиваем или уменьшаем значение нашего счетчика. Важно что делаем это с помощью функции runOnJS так как GestureDetector так же запускается на UI потоке.

const minusScale = useSharedValue(1);
const plusScale = useSharedValue(1);

// ...
const minusAnimatedStyles = useAnimatedStyle(() => ({
    transform: [{scale: minusScale.value }]
}));
const plusAnimatedStyles = useAnimatedStyle(() => ({
    transform: [{scale: plusScale.value }]
}));

const tap = Gesture.Pan().onUpdate(e => {
    // .....
    if (value === 0 && e.translationX > -25  && e.translationX < 0) {
        offset.value = e.translationX
    }
}).onEnd((e) => {
    // ...

    if (e.translationX > 30) {
        plusScale.value = withSequence(withSpring(1.2), withSpring(1))
        // ...
    }
    if (e.translationX < -30 && value > 0) {
        minusScale.value = withSequence(withSpring(1.2), withSpring(1))
        // ...
    }
    if (e.translationX < 0 && value === 0) {
        minusScale.value = withSequence(withSpring(1.2), withSpring(1))
    }
})

// ...


    -

// ...

    +

Для анимации значков плюс и минус так же заведем 2 shared values и хуки для контроля масштаба связанные с этими значениями. Изменяем значения после завершения жеста и используем для этого функцию withSequence которая выполняет несколько анимаций, в нашем случае увеличив, а потом вернув масштаб.

Дальше нам остается только разместить компонент в прилоежнии:

import 'react-native-gesture-handler'
import {StyleSheet} from 'react-native';
import {StatusBar} from 'expo-status-bar';
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import Counter from "./src/Component";

export default function App() {
    return (
      
          
          
      
    );
}

const styles = StyleSheet.create({
    container: {
        flex: 1,
        backgroundColor: '#5a56fd',
        alignItems: 'center',
        justifyContent: 'center',
    },
});

И протестировать его работу:

Видно, что анимация никак не влияет на JS или UI потоки и это одно из основных преимуществ Reanimated.

В статье я постарался рассмотреть лишь базовые понятия этой библиотеки и если она вас заинтересовала, то можно посмотреть реализацию более сложных анимаций на ютуб каналах: Catalin Miron, Reactiive и William Candillon. Правда William Candillon последнее время сосредоточился на разработке React Native Skia о которой я писал в прошлой статье.

© Habrahabr.ru