Сегодня хочу поговорить именно об анимации в приложениях, написанных с помощью 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 про перенос функциональности Анимированного отслеживания в собственный драйвер.
Working on native driver support for Animated.tracking. Here is what you'd be able to do with it if you add gesture handler library to the mix, and even when JS thread is completely blocked (note the JS FPS monitor) pic.twitter.com/dE1KST1i3g
— Krzysztof Magiera (@kzzzf) January 30, 2018
Стандартное 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 и множество возможностей для использования. Часть из них ещё необходимо будет разобрать. И мы вернёмся к этому в следующей части.