Создание кастомного React Native компонента Switch с помощью библиотек Reanimated и Gesture Handler (Часть 1)

от автора

Предисловие

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

Сейчас наш компонент должен выглядеть следующим образом

Наш кастомный Switch

Наш кастомный Switch

Обработка нажатий

Теперь давайте добавим немного экшена в наш статичный компонент.

Для этого нам понадобится библиотека 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 вне компонента.


ссылка на оригинал статьи https://habr.com/ru/articles/792946/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *