Создание кастомного 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 (
    
      {/* content */}
    
  );
}

Также при работе с IOS не забываем установить поды.

Создание компонента

Создавать наш компонент мы будем с использованием языка TypeScript, который стоит по дефолту в последних версиях React Native сразу при инициализации.

import React, { FC } from 'react';

type SwitchProps = {};
export const Switch: FC = () => {
  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,
  },
});

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

Задаем основные пропсы нашего компонента:  

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:


  

Родительский View это контейнер нашего компонента, а дочерний это круг.

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

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

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

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

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

Для этого нам понадобится библиотека Gesture Handler.

Во второй версии этой библиотеки появился компонент 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 (
    
        
    
  );
}

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

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

Как видим плавной анимацией здесь и не пахнет. Для того чтобы значение 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 вне компонента.

© Habrahabr.ru