Предисловие
Приветствую дорогой читатель! Если тебя интересует разработка под React Native и ты хочешь научиться работать с анимациями и отслеживаниями нажатий, то эта статья для тебя. Данная статья первая, что вышла из под моего пера клавиш ноутбука, поэтому прошу сильно не кидаться тапками. Здесь мы рассмотрим работу с кастомными анимациями в React Native и использование библиотек react-native-reanimated и react-native-gesture-handler.
Начало работы
Сначала нам необходимо инициализировать проект. React Native предоставляет нам два варианта на выбор: используя Expo либо же чистый React Native CLI. Для каждого из вариантов необходимо произвести необходимые манипуляции, которые описаны здесь для Expo и здесь для CLI. После того как мы развернули проект, нам необходимо настроить наш проект для работы с нужными нам библиотеками.
Reanimated
Данная библиотека предоставляет нам возможность использовать кастомные анимации в React Native, используя отдельный JS поток. Для начала нам необходимо установить саму библиотеку.
Если проект инициализирован через Expo:
npx expo install react-native-reanimated
Если через CLI:
npm install react-native-reanimated
Далее в независимости от варианта инициализации проекта, нам необходимо добавить babel-плагин в файл babel.config.js для работы с библиотекой:
module.exports = { presets: [ ... // don't add it here :) ], plugins: [ ... 'react-native-reanimated/plugin', ], };
Важно! При работе с IOS не забудьте установить поды для работы нативного кода библиотеки:
cd ios && pod install && cd ..
Gesture Handler
Через эту библиотеку мы будем управлять жестами свайпа и нажатия на наш компонент. Для работы с данной библиотекой нам также необходимо сначала установить нужный нам пакет.
Если проект инициализирован через Expo:
npx expo install react-native-gesture-handler
Если через CLI:
npm install react-native-gesture-handler
Далее необходимо обернуть наше приложение в специальный компонент
import { GestureHandlerRootView } from 'react-native-gesture-handler'; export default function App() { return ( <GestureHandlerRootView style={{ flex: 1 }}> {/* content */} </GestureHandlerRootView> ); }
Также при работе с IOS не забываем установить поды.
Создание компонента
Создавать наш компонент мы будем с использованием языка TypeScript, который стоит по дефолту в последних версиях React Native сразу при инициализации.
import React, { FC } from 'react'; type SwitchProps = {}; export const Switch: FC<SwitchProps> = () => { return <></>; };
С помощью StyleSheet создадим объекты стилей для нашего компонента
const styles = StyleSheet.create({ container: { width: 52, height: 32, borderRadius: 16, justifyContent: 'center', paddingHorizontal: 4, }, circle: { width: 24, height: 24, backgroundColor: 'red', borderRadius: 12, }, });
Стили заданы для примера, могут быть использованы любые другие на ваш вкус (конечно же в пределах разумного). Самое главное в реализации это задать ширину контейнера и окружности в нашем свитче.
Задаем основные пропсы нашего компонента:
value для определения текущего состояния
onValueChange для отслеживания изменения состояния
type SwitchProps = { value: boolean; onValueChange: (value: boolean) => void; };
Теперь нам необходимо вычислить ширину трека, по которому сможет передвигаться наш кружок в компоненте. Вычисляется он просто: берем ширину контейнера и отнимаем ширину нашего круга. Так как мы использовали такой стиль как paddingHorizontal, то его значение умножаем на два (данный стиль задает паддинги с с двух сторон компонента по горизонтали) и также отнимаем от ширины контейнера. Получается вот такая константа:
const TRACK_CIRCLE_WIDTH = styles.container.width - styles.circle.width - styles.container.paddingHorizontal * 2;
Теперь используем специальный хук useSharedValue из библиотеки reanimated и создадим константу, которая будет хранить состояние нашего компонента между JS потоками:
const translateX = useSharedValue(value ? TRACK_CIRCLE_WIDTH : 0);
Здесь мы видим, что если свитчер изначально в активном состоянии, то сразу задаем конечную ширину трека, иначе начальное состояние круга будет в нулевой точке
В догонку к предыдущему хуку, мы также используем еще один хук useAnimatedStyle из той же библиотеки. Он возвращает нам объект стилей, который будет использоваться нами для анимации нашего компонента:
const animatedStyle = useAnimatedStyle(() => { return { transform: [{ translateX: translateX.value }], }; });
Как вы можете увидеть, наша анимация будет основываться на том, что через стиль translateX мы будем менять наше текущее местоположение круга от 0 до TRACK_CIRCLE_WIDTH.
Далее используем этот же хук, но уже для анимации изменения цвета нашего контейнера. Для этого мы будем использовать функцию interpolateColor, которая будет контролировать постепенное изменение нашего цвета в зависимости от расположения нашего круга:
const animatedContainerStyle = useAnimatedStyle(() => { return { backgroundColor: interpolateColor( translateX.value, [0, TRACK_CIRCLE_WIDTH], ['darkgray', 'darkblue'] ), }; });
Здесь цвета 'darkgray' и 'darkblue' используются для примера как цвета неактивного состояния компонента и активного соответственно.
Для работы с данными стилями, в библиотеке находятся специально созданные компоненты Text, View, ScrollView, Image и FlatList. Также есть возможность создать свои компоненты, оборачивая какие нибудь свои кастомные компоненты в специальную функцию createAnimatedComponent. Для нашего компонента нам понадобится только View:
<Animated.View style={[animatedContainerStyle, styles.container]}> <Animated.View style={[animatedStyle, styles.circle]} /> </Animated.View>
Родительский View это контейнер нашего компонента, а дочерний это круг.
Сейчас наш компонент должен выглядеть следующим образом

Обработка нажатий
Теперь давайте добавим немного экшена в наш статичный компонент.
Для этого нам понадобится библиотека Gesture Handler.
Во второй версии этой библиотеки появился компонент GestureDetector, который содержит в себе внутри возможность отслеживания всех видов взаимодействий с компонентом:
<GestureDetector> <Animated.View style={[animatedContainerStyle, styles.container]}> <Animated.View style={[animatedStyle, styles.circle]} /> </Animated.View> </GestureDetector>
Typescript нам подсказывает, что был пропущен обязательный пропс gesture, в который мы должны передать функцию отслеживания нажатий.
Эти функции находятся в специальном объекте Gesture. Сейчас нам нужен метод Tap, который предназначен для отслеживания нажатий на компонент:
const tap = Gesture.Tap().onEnd(() => { translateX.value = value ? 0 : TRACK_CIRCLE_WIDTH; });
Здесь мы описываем то, что при завершении нажати�� пользователем, мы меняем значение положения круга на противоположное. Также нам необходимо вызвать функцию onValueChange и передать в нее значение, противоположное текущему значению value. Проблема в том, что что если мы ее просто вызовем, то будет ошибка при исполнении кода. Все дело в JS потоке, в котором происходит логика обработки нажатий. Она происходит во втором потоке, основной поток о ней не вкурсе. Для того, чтобы его оповестить об этом, есть специальная функция runOnJS из библиотеки reanimated, которая оповестит основной поток о том, что нужно выполнить переданную функцию:
const tap = Gesture.Tap().onEnd(() => { translateX.value = value ? 0 : TRACK_CIRCLE_WIDTH; runOnJS(onValueChange)(!value); });
Передадим эту константу в пропс gesture и посмотрим что выйдет. Экспортируем наш компонент и добавим useState для контроля:
export default function App() { const [value, onValueChange] = useState(false); return ( <GestureHandlerRootView style={{ flex: 1 }}> <Switch value={value} onValueChange={onValueChange} /> </GestureHandlerRootView> ); }

Как видим плавной анимацией здесь и не пахнет. Для того чтобы значение translateX менялось плавно, есть специальные вспомогательные функции из библиотеки reanimated (withTiming, withSpring и т.д.). Давайте возьмем функцию withTiming, подредактируем константу tap и посмотрим что у нас выйдет:
const tap = Gesture.Tap() .onEnd(() => { translateX.value = withTiming(value ? 0 : TRACK_CIRCLE_WIDTH); runOnJS(onValueChange)(!value); })

Вот теперь мы видим плавность нашей анимации при нажатии на наш компонент.
Обработка жестов
Перейдем к взаимодействию с нашим компонентом через свайп.
Для этого будем будем использовать метод Pan из объекта Gesture. В нем нам понадобятся два метода: onUpdate и onEnd. Первый отвечает за каждый апдейт движения пальца по компоненту, второй нам уже известен и отвечает за прослушку прекращения жестов. В оба метода мы можем передать функцию, которая принимает в себя набор параметров, изменяемых при движении пальцев относительно компонента. Из всех параметров нам понадобится лишь ключ translationX, который отвечает за отслеживание перемещение пальца по горизонтальной оси координат:
const pan = Gesture.Pan().onUpdate(({ translationX }) => {});
Создадим константу, которая будет отдавать корректное расположение круга с учетом перемещения пальца:
const translate = value ? TRACK_CIRCLE_WIDTH + translationX : translationX;
Здесь мы определяем, что если свитчер активный, то наш круг находится в конечной правой точке трека и нам к этому значению необходимо прибавить нахождение пальца по оси Х, если свитчер неактивный, то значение пальца мы прибавляем к нулю, который откидывается из формулы за ненадобностью.
Сделаем функцию, которая будет отвечать за ограничение перемещения круга при свайпах пальцами. Значение не должно быть меньше 0 и превышать значение TRACK_CIRCLE_WIDTH:
const currentTranslate = () => { if (translate < 0) { return 0; } if (translate > TRACK_CIRCLE_WIDTH) { return TRACK_CIRCLE_WIDTH; } return translate; };
После всех наших манипуляций просто передаем наше значение в константу translateX:
const pan = Gesture.Pan().onUpdate(({ translationX }) => { const translate = value ? TRACK_CIRCLE_WIDTH + translationX : translationX; const currentTranslate = () => { if (translate < 0) { return 0; } if (translate > TRACK_CIRCLE_WIDTH) { return TRACK_CIRCLE_WIDTH; } return translate; }; translateX.value = currentTranslate(); });
Теперь перейдем к методу onEnd. Для удобства создаем такую же константу translate что и в предыдущем методе. Также нам понадобится еще одна дополнительная константа, которая будет отслеживать конечное местоположение круга и в зависимости от нее отдавать либо крайнюю левую точку трека либо крайнюю правую:
const selectedSnapPoint = translate > TRACK_CIRCLE_WIDTH / 2 ? TRACK_CIRCLE_WIDTH : 0;
Необходимо нам это для того, чтобы при прекращении свайпа пользователя наш круг не застрял в той точке, в которой его оставил пользователь, а перешел в одну из крайних точек трека. Для этого делим ширину трека нашего компонента пополам и смотрим, на какой из половин остановился наш круг и возвращаем его в крайнюю точку трека этой половины.
После этого отдаем это значение в translateX и добавляем немного анимации через функцию withTiming:
translateX.value = withTiming(selectedSnapPoint, { duration: 100 });
Как мы видим по коду, данная функция может принимать в себя дополнительный объект конфигураций, в который мы передали задержку на старт функции в 100 миллисекунд (дефолтная задержка составляет 300 миллисекунд). В самом конце мы вызовем уже знакомую нам функцию runOnJS и передадим в нее onValueChange для изменения состояния свитчера:
.onEnd(({ translationX }) => { const translate = value ? TRACK_CIRCLE_WIDTH + translationX : translationX; const selectedSnapPoint = translate > TRACK_CIRCLE_WIDTH / 2 ? TRACK_CIRCLE_WIDTH : 0; translateX.value = withTiming(selectedSnapPoint, { duration: 100 }); runOnJS(onValueChange)(!!selectedSnapPoint); })
В конечном итоге наша константа pan будет выглядеть вот так:
const pan = Gesture.Pan() .onUpdate(({ translationX }) => { const translate = value ? TRACK_CIRCLE_WIDTH + translationX : translationX; const currentTranslate = () => { if (translate < 0) { return 0; } if (translate > TRACK_CIRCLE_WIDTH) { return TRACK_CIRCLE_WIDTH; } return translate; }; translateX.value = currentTranslate(); }) .onEnd(({ translationX }) => { const translate = value ? TRACK_CIRCLE_WIDTH + translationX : translationX; const selectedSnapPoint = translate > TRACK_CIRCLE_WIDTH / 2 ? TRACK_CIRCLE_WIDTH : 0; translateX.value = withTiming(selectedSnapPoint, { duration: 100 }); runOnJS(onValueChange)(!!selectedSnapPoint); })
Для того чтобы передать в наш GestureDetector несколько констант, есть специальный метод Gesture.Race, который сможет объединить наши методы с жестами
const gesture = Gesture.Race(tap, pan);
Передаем эту константу как пропс gesture и посмотрим что у нас получилось

В следующей заключительной части мы реализуем логику пропса disabled, добавим пару новых фич и напишем обработку изменения состояния value вне компонента.
