Обложка: Прокачиваем анимацию с react-native-reanimated. Часть 1

Прокачиваем анимацию с react-native-reanimated. Часть 1

Роман Турусов
Роман Турусов

Старший разработчик приложений IT-компании Lad

Сегодня хочу поговорить именно об анимации в приложениях, написанных с помощью react-native (далее RN), а точнее о библиотеке react-native-reanimated, заменяющая инструмент стандартного api Animated в RN.

В статье используется react-native-reanimated версии 1.13.3, поскольку начиная со второй версии библиотека получила много архитектурных обновлений. А также ограничения, которые противоречили важным фичам, использующимся в RN, и удобству отладки приложения.

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

Мотивация

Оптимизация

Первоначально проект был создан для решения проблемы взаимодействия с приёмником событий жестов — когда компонент можно перетаскивать по экрану. А при отпускании он привязывается к какому-либо месту. Несмотря на использование Animated.event и сравнивание текущего положения жеста с положением компонента на экране, а также выполнение всего этого взаимодействия в UI-потоке с флагом useNativeDriver, нам всё равно в конце анимации приходилось возвращать состояние жеста в JS.  Это могло привести к потере кадров.

Это связано с тем, что выполнение анимации Animated.spring(props).start() не может использоваться по-настоящему декларативно. Когда функция вызывается, то возникает «побочный эффект» в виде запуска процесса (анимации). Он обновляет значение некоторое время.

Добавление узлов «побочных эффектов» в текущую реализацию Animated оказалось довольно сложной задачей — модель выполнения api запускает все зависимые узлы каждого рендера для компонентов, которые необходимо обновить. Разработчики библиотеки не хотели запускать «побочные эффекты» чаще, чем необходимо, потому что это, например, приведёт к многократному выполнению одной и той же анимации.

Удобство

Еще одним источником вдохновения для изменения внутреннего устройства Animated стала работа Krzysztof Magiera про перенос функциональности Анимированного отслеживания в собственный драйвер.

Стандартное Animated api оказалось поддерживает не всё, что могла делать неродная API. Одна из целей react-native-reanimated заключалась в предоставлении расширенной кодовой базы для создания API, которое позволяло писать более сложные анимации только на JS. И сделать код настолько минимальным, насколько это возможно.

Подход

В react-native-reanimated свойства анимации компонента объявляются в виде узлов (функций), которые передают поведение этого компонента в зависимости от значений, описанных в этих узлах. В сочетании с жестами можно запускать чисто нативные анимации, не пересекая мост между JS движком и нативной частью RN.

block([
  cond(not(clockRunning(clock)), startClock(clock)),
  timing(clock, state, config),
  cond(state.finished,stopClock(clock))
])

К практике

Для начала будет полезным ознакомится с api Animated.

Установим

# Установим библиотеку
npm i react-native-reanimated@1.13.3

# Для iOS
cd ios
pod install

Также вам понадобится ndk версии 21.3.6528147 или выше.

Используем

Воспользуемся примером из документации и прокомментируем код. Он заключается в передвижении компонента по экрану. Для начала необходимо определить анимацию.

import Animated, {
  block,
  clockRunning,
  cond,
  Clock,
  debug,
  Easing,
  set,
  startClock,
  stopClock,
  timing,
  Value,
  useCode,
  Node,
  interpolateColors
} from 'react-native-reanimated';

const runTiming = (clock, value, dest) => {
	
  const state = {
    finished: new Value(0),
    position: new Value(0),
    time: new Value(0),
    frameTime: new Value(0),
  };

  const config = {
    duration: 5000,
    toValue: new Value(0),
    // Определяем какое будет "смягчение" относительно, линейного Clock 
    easing: Easing.inOut(Easing.ease),
  };

  // Возвращаем узел, который объединяет несколько функций, вызывает их
  // в порядке, в котором они передаются в block и возвращает результат 
  // последнего узла.
  return block([
    cond(
      clockRunning(clock),
      [
        // Если счетчик уже запущен, то мы обновляем значение toValue.
        set(config.toValue, dest),
      ],
      [
        // Если счётчик не запущен, то мы сбрасываем все значения и
    	  // запускаем счётчик.
        set(state.finished, 0),
        set(state.time, 0),
        set(state.position, value),
        set(state.frameTime, 0),
        set(config.toValue, dest),
        startClock(clock),
      ]
    ),
    // Мы определяем здесь шаг, который запускает процесс расчёта значений.
    timing(clock, state, config),
    // Если анимация закончилась, то мы останавливаем счётчик.
    cond(state.finished, debug('stop clock', stopClock(clock))),
    // Определяем узел возвращения обновлённого значения.
    state.position,
  ]);
}

Прежде чем верстать интерфейс, нужно сделать хук для создания ссылки на узел счётчика. Это расширенный объект анимированного значения Value. Он может обновляться в каждом рендере и возвращать его timestamp.

function useClock() {
  const ref = useRef(new Clock());
  return ref.current;
}

Затем сверстаем

export default () => {
  const clock = useClock();

  // Вызываем функцию runTiming, определённый выше, чтобы создать
  // узел, который будет использоваться для translateX трансформации. 
  const translateX = runTiming(clock, -120, 120)

  return (
     <View style={styles.container}>
       <Animated.View
         style={[styles.box, { transform: [{ translateX }] }]}
       />
     </View>
   );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  box: {
    width: 50,
    height: 50,
    backgroundColor: '#4585E6'
  }
}

Сократим

Теперь реализуем универсальный инструмент, который может анимировать значения в зависимости от состояния. Для начала стоит разделить функцию расчета значений runTiming на функцию получения настроек для описания узлов анимации и хук, возвращающий анимированное значение для вёрстки.

interface AnimationTimingProps {
  value: Value;
  trigger: boolean;
  easing: Animated.EasingFunction;
  duration: number;
}

// Получить объект с настройками для описания узлов анимации
const getTimingSettings = ({
  value,
  trigger = false,
  easing = Easing.inOut(Easing.circle),
  duration = 300,
}: AnimationTimingProps) => {
  return {
    clock: new Clock(),
    state: {
      finished: new Value(0),
      position: value || new Value(trigger ? 1 : 0),
      time: new Value(0),
      frameTime: new Value(0),
    },
    config: {
      duration,
      toValue: new Value(trigger ? 0 : 1),
      easing,
    },
  };
};

// Определим какие настройки мы будем использовать для расчётов
interface HookTimingProps {
  trigger: boolean;
  range: [number, number];
  duration?: number;
  callback?: () => void;
  easing?: Animated.EasingFunction;
}

// Хук, возвращающий анимированное значение для вёрстки.
function useTiming({
  range: [from, to],
  callback,
  trigger = false,
  easing = Easing.inOut(Easing.circle),
  duration = 300,
}: HookTimingProps) {
  const value = useValue(trigger ? 1 : 0);

	// Хук запуска расчёта анимации
  useCode(() => {
    const { clock, config, state } = getTimingSettings({
      trigger,
      value,
      easing,
      duration,
    });

    return [
      cond(not(clockRunning(clock)), startClock(clock)),
      timing(clock, state, config),
      cond(state.finished, block([stopClock(clock), call([], callback)])),
      state.position,
    ];
  }, [trigger]);

	// мапим значения, потому что значение "value" менялось в интервале от 0 до 1
  return value.interpolate({
    inputRange: [0, 1],
    outputRange: [from, to],
    extrapolate: Extrapolate.CLAMP,
  });
}

Хук useCode в качестве первого параметра получает функцию фабрики, которая должна возвращать узел анимации или массив из узлов. Они будут затем переданы в узел block — и вторым параметром массив зависимостей. Функция обновляет коренной узел во время первого рендера и при каждом изменении значений в зависимостях.

Дальше понадобится изменить вёрстку.

const [trigger, setTrigger] = useState(false);

  const translateX = useTiming({
    trigger,
    range: [-120, 120],
    easing: Easing.inOut(Easing.cubic),
    duration: 400,
		// Вызов после выполнения анимации
    callback: () => {
      setTimeout(() => {
        setTrigger(!trigger);
      }, 600);
    },
  });

Теперь мы получили хук, который в зависимости от состояния возвращает новое анимированное значение, имеет настройки для изменения продолжительности анимации, «смягчение» вектора изменения компонента и возможность выполнить кастомную функцию после выполнения анимации.

Усложним

Также Reanimated умеет работать не только с числами, но и с цветом.

Мы можем усложнить функцию useTiming и получить не только числовое значение. На самом деле, различие функции расчёта анимации между числовым и цветовым значением различается только в итоговом преобразовании значения value в интервале числовых или цветовых from и to. Поэтому мы можем объединить всё в функции useTiming.

// ...
// Изменяем тип функции
function useTiming<P = number>({
  range: [from, to],
  callback = () => 0,
  trigger = false,
  easing = Easing.inOut(Easing.circle),
  duration = 300,
}: HookTimingProps): Animated.Node<P> {
// ...

// ...
// возвращаем из функции
if (typeof from === 'string' && typeof to === 'string') {
    // преобразовать число "value" в цвет, который будет находится в градиенте 
    // между from и to
    return interpolateColors(value, {
      inputRange: [0, 1],
      outputColorRange: [from, to],
	 }) as Animated.Node<P & string>;
 }
 return value.interpolate({
   inputRange: [0, 1],
   outputRange: [from, to],
   extrapolate: Extrapolate.CLAMP,
 }) as Animated.Node<P & number>;
// ...

Осталось лишь добавить узел в вёрстку

// Расположим рядом с translateX
const boxBackColor = useTiming({
  trigger,
  range: ['#4585E6', '#37BA96'],
});
// И добавим анимированный цвет к квадрату
<Animated.View
  style={[
    styles.box,
    { 
      transform: [{ translateX }],
      backgroundColor: boxBackColor,
    }, 
  ]} 
/>

Теперь useTiming может возвращать анимированое числовое и цветовое значение узла в зависимости от переданного интервала в range. Такого примера достаточно, чтобы создавать простые анимации и делать ваши приложения приятнее глазу.

Дальше что?

Сейчас мы рассмотрели пример базовой анимации, которая показывает основную возможность — создание её декларативно. react-native-reanimated имеет широкое api и множество возможностей для использования. Часть из них ещё необходимо будет разобрать. И мы вернёмся к этому в следующей части.