Предисловие
Приветствую дорогой читатель! Если тебя интересует разработка под 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 вне компонента.