React

Когда я только начал работать с React Native (RN), у меня никогда не доходили руки до изучения анимации. Многие вещи казались важнее, поэтому я забывал про эту замечательную тему. Но анимации довольно важны, ведь хорошо выглядящему приложению простительны некоторые недостатки. 

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

В этой статье мы сделаем эту анимацию: рассмотрим ее подробно и увидим, насколько просто она реализована в RN.

Итоговая анимация

Настройка проекта и начальный рисунок 

Давайте пойдем с самого начала. Запустим $ react-native-init animations в графическом интерфейсе react native, чтобы запустить проект. Мы увидим этот экран:

Скриншот начала проекта

Первый шаг — создание простого сценария. Избавьтесь от всего шаблонного кода, чтобы начать с пустой страницы. 

import React, { Fragment } from 'react';
import { SafeAreaView, StatusBar, StyleSheet } from 'react-native';

const App = () => {
    return (
        <Fragment>
            <StatusBar
                backgroundColor="transparent"
                barStyle="dark-content"
                translucent={true}
            />

            <SafeAreaView>
            </SafeAreaView>
        </Fragment>
    );
};

const styles = StyleSheet.create({
});

export default App;

Теперь добавляем небо, траву и дорогу:

import React, { Fragment } from 'react';
import { Animated, SafeAreaView, StatusBar, StyleSheet, View } from 'react-native';

const App = () => {
    return (
        <Fragment>
            <StatusBar
                backgroundColor="transparent"
                barStyle="dark-content"
                translucent={true}
            />

            <SafeAreaView style={styles.background}>
                <View style={styles.grass}>
                    <View style={styles.road}>
                        <View style={styles.stripes}>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                            <View style={styles.stripe}/>
                        </View>
                    </View>
                </View>
            </SafeAreaView>
        </Fragment>
    );
};

const styles = StyleSheet.create({
    background: {
        backgroundColor: '#87CEEB',
        flex: 1
    },

    grass: {
        backgroundColor: '#4DED33',
        bottom: 0,
        position: 'absolute',
        height: '70%',
        width: '100%'
    },

    road: {
        backgroundColor: '#666560',
        height: 160,
        justifyContent: 'center',
        marginTop: '10%',
        width: '100%'
    },

    stripe: {
        backgroundColor: '#FFFFFF',
        width: '5%',
        height: 10
    },

    stripes: {
        justifyContent: 'space-between',
        flexDirection: 'row'
    },
});

export default App;

Вот что получилось:

Скриншот экрана с небом, дорогой и травой

Хороший сценарий! Давайте добавим в проект два изображения машинок:

Голубая машинка
Красная машинка

Я добавил эти изображения в директорию “resources” в корне. Теперь нам нужно добавить их в код. По умолчанию RN может анимировать шесть компонентов: <View>, <Text>, <Image>, <ScrollView>, <FlatList> и <SectionList>. Мы используем компонент<Image>. Но вместо простой версии возьмем анимированную, <Animated.Image>, чтобы позднее добавить немного магии. Теперь код дороги выглядит так: 

<View style={styles.road}>
    <Animated.Image
        resizeMode='center'
        source={require('./resources/car_blue.png')}
        style={{...styles.carImage, ...styles.upperCar}}
    />

    <View style={styles.stripes}>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
        <View style={styles.stripe}/>
    </View>

    <Animated.Image
        resizeMode='center'
        source={require('./resources/car_red.png')}
        style={{...styles.carImage, ...styles.lowerCar}}
    />
</View>

С этими стилями: 

carImage: {
    height: 80,
    position: 'absolute',
    width: 160
},

lowerCar: {
    bottom: 10
},

upperCar: {
    top: -20
}

Теперь у нас есть базовое изображение: 

Скриншот сценария с машинками

Добавляем движение 

Теперь давайте разберемся, как анимировать машинки. Используем для этого React Hooks. Простейшая анимация здесь — движение одной из машинок вправо. Начнем с добавления useEffect и useState к импортам React, чтобы следить за анимацией, используя состояние:

import React, { Fragment, useEffect, useState } from 'react';

Теперь нужно добавить переменную, с помощью которой мы будем следить за состоянием анимации. Эта переменная будет получать постепенные обновления анимации от 0% до 100%, в соответствии с установленной продолжительностью. В нашем случае анимируем нижнюю машинку, чтобы она поехала вправо. Мы собираемся пройти от left: 0 до left: 100%.Объявляем переменную:

const [lowerCarLeft] = useState(new Animated.Value(0));

Как вы могли догадаться, функция Animated.Value используется для установки значений, которые будут использоваться в анимации. В переменной хранится не само значение, а функция, похожая на промис, которая разрешается в значение. Это гарантирует, что все будет работать в соответствии с временем анимации. 

Теперь нужно использовать хук useEffect, который гарантирует, что анимация запустится только после монтирования компонента. В данном случае это аналогично использованию componentDidMount (если у вас есть компонент класса). Внутри него вызовем функцию Animated.timing, которая обеспечивает постепенное изменения от одного значения к другому. Animated.timing() принимает два параметра:

  1. Анимируемое значение. В нашем случае это lowerCarLeft.
  2. Конфигурация анимации. Это объект, принимающий такие значения как: продолжительность (длина анимации), замедление (функция, определяющая, как анимация ускоряется и останавливается, подробнее об этом здесь) и задержка (сколько времени должно пройти перед реальным запуском анимации после ее активации). 

Сейчас вам не стоит беспокоиться о функции замедления, так как у функции timing() есть значения по умолчанию. После конфигурации анимацию можно запустить. Просто добавьте вызов функции start() в timing(). Код будет выглядеть так: 

useEffect(() => {
    Animated.timing(lowerCarLeft, {
        toValue: 100,
        duration: 2000
    }).start();
}, []);

Код выше постепенно увеличивает переменную lowerCarLeft в течение 2 секунд (2000 миллисекунд), пока значение переменной не достигнет 100.

Теперь нужно использовать это значение в стиле машинки. Мы анимируем левую позицию нижней машинки, и по мере увеличения значения левой позиции машинка будет двигаться вправо. Получаем результат, добавив left: lowerCarLeft в свойство стиля: 

style={{
    ...styles.carImage,
    ...styles.lowerCar,
    left: lowerCarLeft
}}

Машинка двигается, но останавливается в неправильной позиции. Так как переданное значение является числом, RN понимает его как пиксели, в итоге выдавая неверный результат. Нам же нужно получить значение в процентах, ведь мы хотим переместить машинку не на 100 пикселей вправо, а на 100 процентов размера экрана. Всегда помните, что использование пикселей может сломать анимацию на экранах разных размеров.

Если попытаться добавить “%” в конец объявления стиля, например left: lowerCarLeft+'%' (или другие вариации), это не сработает, потому что, как я говорил ранее, переменная хранит не само значение, а функцию, похожую на промис. Строка просто не может быть присоединена к такой функции. 

Если попытаться анимировать значение напрямую как строку (изменив объявление переменной и toValue внутри timing() с “0%” до “100%”), это тоже не сработает. React Animated не может анимировать строку, так как невозможно определить путь от точки 0 (начало) до точки 100 (конец), если вы работаете не с числами. 

Анимирование строки

И все же существует решение этой проблемы — это “линейная интерполяция” — фича, которая позволяет сопоставлять введенные данные с различными выведенными данными. В нашем случае мы сможем сказать: точка 0 представляет строку “0%” и точка 100 представляет строку “100%”. Но сопоставления могут быть любыми — точка 0 представляет строку “0 градусов”, и точка 100 представляет строку “360 градусов” и так далее. 

Давайте посмотрим, как мы делаем интерполяцию в React. Нужно получить анимируемое значение и вызвать для него функцию interpolate(), передавая массив введенных значений и массив выведенных значений: 

lowerCarLeft.interpolate({
    inputRange: [0, 100],
    outputRange: ['0%', '100%']
})

В точности это означает следующее: когда lowerCarLeft находится в точке 0, функция interpolate() возвращает ‘0%’, и так происходит в любой другой точке.

Теперь нужно заменить style в изображении. Получим такой код: 

style={{
    ...styles.carImage,
    ...styles.lowerCar,
    left: lowerCarLeft.interpolate({
        inputRange: [0, 100],
        outputRange: ['0%', '100%']
    })
}}

К этому моменту нижняя машинка уже анимирована: 

Анимация красной машинки

Верхняя машинка

Теперь нужно повторить то же самое для верхней машинки, только в противоположном направлении. Машинка должна начать движение слева в 100% и двигаться до 0%. Готовы?

Что можно заметить:

Если попытаться изменить интерполяцию inputRange: [0, 100] на [100, 0], также изменив начальную точку слева на 100% и конечную точку на 0%, это не сработает. Имейте в виду — при интерполяции вы не обращаетесь к значениям анимации, вы обращаетесь к точке во времени в анимации. [100, 0] означает, что анимация будет происходить задом наперед, поэтому метод интерполяции ее не принимает. Если вы оставите [0, 100] , интерполяция будет знать, что в точке 0 должно быть возвращено значение ‘100%’, а в точке 100 — значение ‘0%’.

Также вы заметите, что верхняя машинка завершает анимацию, пока все еще присутствует на экране, в то время как нижняя машинка исчезает. Это просто вопрос оформления. Чтобы избежать этого и, возможно, улучшить движение, давайте изменим конечную точку для верхней машинки и начальную точку для нижней машинки на -50 вместо 0.

Вот что мы получим: 

Анимация обеих машинок

Наша анимация уже выглядит классно. Нам осталось реализовать бесконечный цикл, чтобы машинки никогда не останавливались. 

Бесконечный цикл

Давайте напишем его! Нам нужно запустить анимации вместе. Вызываем start() дважды: по одному разу для каждой анимации, чтобы они запускались самостоятельно. Нам нужно заставить их работать вместе при помощи только одного вызова метода. Для этой задачи React-Native Animated предоставляет функцию parallel() (другие функции здесь). В нее можно передать параметр, который является массивом всех анимируемых значений, затем вызвать в цепочке start(), тогда анимации запустятся вместе. Вот так: 

Animated.parallel([
    Animated.timing(lowerCarLeft, {
        toValue: 100,
        duration: 2000
    }),
    Animated.timing(upperCarLeft, {
        toValue: -50,
        duration: 2000
    })
]).start();

Должно сработать. Теперь нужно запустить анимацию снова после окончания. Здесь пригодится трюк с методом start() — он может получать обратный вызов, выполняемый, когда анимация заканчивается. Мы можем повторить код анимации для повторного запуска, но вместо копирования того же самого кода давайте создадим метод, разделяющий эту логику, чтобы просто вызвать его. Я сделал это так:

let [lowerCarLeft] = useState(new Animated.Value(-50)),
    [upperCarLeft] = useState(new Animated.Value(100)),
    runAnimation = () => {
        Animated.parallel([
            Animated.timing(lowerCarLeft, {
                toValue: 100,
                duration: 2000
            }),
            Animated.timing(upperCarLeft, {
                toValue: -50,
                duration: 2000
            })
        ]).start();
    };

useEffect(() => {
    runAnimation();
});

Теперь внутри start() просто вызовем runAnimation():

runAnimation = () => {
    Animated.parallel([
        Animated.timing(lowerCarLeft, {
            toValue: 100,
            duration: 2000
        }),
        Animated.timing(upperCarLeft, {
            toValue: -50,
            duration: 2000
        })
    ]).start(() => runAnimation());
};

Ошибок нет, но не работает. Почему? Потому что нужно снова задать анимируемые значения! 

runAnimation = () => {
    lowerCarLeft.setValue(-50);
    upperCarLeft.setValue(100);

    Animated.parallel([
        Animated.timing(lowerCarLeft, {
            toValue: 100,
            duration: 2000
        }),
        Animated.timing(upperCarLeft, {
            toValue: -50,
            duration: 2000
        })
    ]).start(() => runAnimation());
};

Вот и все! Теперь работает идеально! 

Анимация полностью работает

Готово!

Заключение

Простор для улучшений огромен. Можно задать константы для начального и конечного значений, чтобы не приходилось искать их в коде для изменения. Также можно использовать stagger() (вместо parallel()) или запускать работу в случайное время, чтобы избежать роботизированности. 

Итоги: 

  • Анимации зачастую проще, чем кажутся. Стоит попробовать хотя бы раз, чтобы понять основы. 
  • Interpolate сопоставляет введенные данные с различными выводами (полезно при анимировании строк).
  • parallel() — это способ запустить несколько анимаций вместе. 
  • Метод start() получает обратный вызов, который вызывается по завершении всех анимаций (полезно для создания бесконечных анимаций).

Этого достаточно, чтобы начать самостоятельно создавать простые анимации.

Читайте также:


Перевод статьи Mauricio Luis Comin Araldi: The first step into React Native Animations