Статьи

Практические примеры анимации в React Native

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

  • Обеспечьте визуальную обратную связь: например, когда пользователь нажимает кнопку, вы хотите использовать анимацию, чтобы показать пользователю, что кнопка действительно нажата.
  • Показать текущее состояние системы: при выполнении процесса, который не завершается мгновенно (например, при загрузке фотографии или отправке электронного письма), вы хотите показать анимацию, чтобы у пользователя было представление о том, сколько времени займет этот процесс.
  • Визуально подключите переходные состояния: когда пользователь нажимает кнопку, чтобы вывести что-то на переднюю часть экрана, этот переход должен быть анимированным, чтобы пользователь знал, откуда возник этот элемент.
  • Захватите внимание пользователя: когда есть важное уведомление, вы можете использовать анимацию, чтобы привлечь внимание пользователя.

Это руководство является продолжением моего поста Animate Your React Native App . Так что, если вы новичок в анимации в React Native, сначала убедитесь в этом, потому что некоторые концепции, которые будут использоваться в этом руководстве, будут объяснены более подробно там.

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

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

  • Страница новостей : использует жесты для визуальной обратной связи и отображения текущего состояния системы.
  • Страница кнопок : использует кнопки для визуальной обратной связи и отображения текущего состояния системы.
  • Страница прогресса : использует индикатор выполнения для отображения текущего состояния системы.
  • Развернуть страницу : визуально связывает переходные состояния с помощью движений расширения и сжатия.
  • AttentionSeeker Page : использует привлекательные движения, чтобы привлечь внимание пользователя.

Если вы хотите увидеть предварительный просмотр каждой анимации, посмотрите этот альбом Imgur .

Начните с создания нового проекта React Native:

1
react-native init RNPracticalAnimations

После создания проекта перейдите во вновь созданную папку, откройте файл package.json и добавьте следующее в dependencies :

1
2
«react-native-animatable»: «^0.6.1»,
«react-native-vector-icons»: «^3.0.0»

Выполните npm install чтобы установить эти два пакета. response-native-animatable используется для простой реализации анимации, а response-native-vector-icons используется для визуализации значков для страницы расширения в дальнейшем. Если вы не хотите использовать значки, вы можете просто использовать компонент Text . В противном случае, следуйте инструкциям по установкеact-native-vector-icons на их странице GitHub .

Откройте файл index.android.js или index.ios.js и замените существующее содержимое следующим:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
import React, { Component } from ‘react’;
 
import {
  AppRegistry
} from ‘react-native’;
 
import NewsPage from ‘./src/pages/NewsPage’;
import ButtonsPage from ‘./src/pages/ButtonsPage’;
import ProgressPage from ‘./src/pages/ProgressPage’;
import ExpandPage from ‘./src/pages/ExpandPage’;
import AttentionSeekerPage from ‘./src/pages/AttentionSeekerPage’;
 
class RNPracticalAnimation extends Component {
  render() {
    return (
       <NewsPage />
    );
  }
}
 
AppRegistry.registerComponent(‘RNPracticalAnimation’, () => RNPracticalAnimation);

После этого обязательно создайте соответствующие файлы, чтобы не было ошибок. Все файлы, над которыми мы будем работать, хранятся в каталоге src . Внутри этого каталога находятся следующие папки:

  • components : повторно используемые компоненты, которые будут использоваться другими компонентами или страницами.
  • img : изображения, которые будут использоваться в приложении. Вы можете получить изображения из репозитория GitHub .
  • pages : страницы приложения.

Давайте начнем со страницы новостей.

Сначала добавьте компоненты, которые мы будем использовать:

01
02
03
04
05
06
07
08
09
10
11
12
13
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  Text,
  View,
  Animated,
  Easing,
  ScrollView,
  RefreshControl
} from ‘react-native’;
 
import NewsItem from ‘../components/NewsItem’;

Вы уже должны быть знакомы с большинством из них, за исключением RefreshControl и пользовательского компонента NewsItem , который мы NewsItem позже. RefreshControl используется для добавления функции «тянуть к обновлению» внутри компонента ScrollView или ListView . Так что это фактически тот, который будет обрабатывать жест и анимацию смахивания вниз для нас. Не нужно реализовывать наши собственные. По мере того, как вы приобретете больше опыта в использовании React Native, вы заметите, что анимация фактически встроена в некоторые компоненты, и нет необходимости использовать класс Animated для реализации своего собственного.

Создайте компонент, который будет содержать всю страницу:

1
2
3
export default class NewsPage extends Component {
    …
}

Внутри constructor инициализируйте анимированное значение для хранения текущей непрозрачности ( opacityValue ) новостных элементов. Мы хотим, чтобы новостные объекты были менее прозрачными, пока они обновляются. Это дает пользователю представление о том, что они не могут взаимодействовать со всей страницей, пока обновляются новости. is_news_refreshing используется в качестве переключателя, чтобы указать, обновляются ли в данный момент новости или нет.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
constructor(props) {
    super(props);
    this.opacityValue = new Animated.Value(0);
    this.state = {
        is_news_refreshing: false,
        news_items: [
            {
                title: ‘CTO Mentor Network – a virtual peer-to-peer network of CTOs’,
                website: ‘ctomentor.network’,
                url: ‘https://ctomentor.network/’
            },
            {
                title: ‘The No More Ransom Project’,
                website: ‘nomoreransom.org’,
                url: ‘https://www.nomoreransom.org/’
            },
            {
                title: ‘NASA Scientists Suggest We’ve Been Underestimating Sea Level Rise’,
                website: ‘vice.com’,
                url: ‘http://motherboard.vice.com/read/nasa-scientists-suggest-weve-been-underestimating-sea-level-rise’
            },
            {
                title: ‘Buttery Smooth Emacs’,
                website: ‘facebook.com’,
                url: ‘https://www.facebook.com/notes/daniel-colascione/buttery-smooth-emacs/10155313440066102/’
            },
            {
                title: ‘Elementary OS’,
                website: ‘taoofmac.com’,
                url: ‘http://taoofmac.com/space/blog/2016/10/29/2240’
            },
            {
                title: ‘The Strange Inevitability of Evolution’,
                website: ‘nautil.us’,
                url: ‘http://nautil.us/issue/41/selection/the-strange-inevitability-of-evolution-rp’
            },
        ]
    }
}

Функция opacity() — это та, которая запускает анимацию для изменения прозрачности.

01
02
03
04
05
06
07
08
09
10
11
opacity() {
    this.opacityValue.setValue(0);
    Animated.timing(
      this.opacityValue,
      {
        toValue: 1,
        duration: 3500,
        easing: Easing.linear
      }
    ).start();
}

Внутри функции render() определите, как будет изменяться значение непрозрачности. Здесь outputRange имеет значение [1, 0, 1] , что означает, что он начнется с полной непрозрачностью, затем достигнет нулевой непрозрачности, а затем снова вернется к полной непрозрачности. Как определено в функции opacity() , этот переход будет выполняться в течение 3500 миллисекунд (3,5 секунды).

1
2
3
4
5
6
7
8
9
render() {
 
    const opacity = this.opacityValue.interpolate({
      inputRange: [0, 0.5, 1],
      outputRange: [1, 0, 1]
    });
 
    …
}

Компонент <RefreshControl> добавляется в <ScrollView> . Это вызывает функцию refreshNews() всякий раз, когда пользователь проводит пальцем вниз, находясь в верхней части списка (когда scrollY равен 0 ). Вы можете добавить colors опору, чтобы настроить цвет анимации обновления.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
return (
    <View style={styles.container}>
        <View style={styles.header}>
        </View>
        <ScrollView
            refreshControl={
                <RefreshControl
                    colors={[‘#1e90ff’]}
                    refreshing={this.state.is_news_refreshing}
                    onRefresh={this.refreshNews.bind(this)}
                />
            }
            style={styles.news_container}>
            …
        </ScrollView>
    </View>
);

Внутри <ScrollView> используйте компонент <Animated.View> и установите style , соответствующий значению opacity :

1
2
3
<Animated.View style={[{opacity}]}>
    { this.renderNewsItems() }
</Animated.View>

Функция refreshNews() вызывает функцию opacity() и обновляет значение is_news_refreshing до true . Это позволяет компоненту <RefreshControl> знать, что анимация обновления уже должна отображаться. После этого используйте функцию setTimeout() чтобы обновить значение is_news_refreshing обратно в false через 3500 миллисекунд (3,5 секунды). Это скроет анимацию обновления из вида. К тому времени анимация непрозрачности также должна быть сделана, так как мы ранее установили одно и то же значение для длительности в функции opacity .

1
2
3
4
5
6
7
refreshNews() {
    this.opacity();
    this.setState({is_news_refreshing: true});
    setTimeout(() => {
        this.setState({is_news_refreshing: false});
    }, 3500);
}

renderNewsItems() принимает массив новостных элементов, которые мы объявили ранее в constructor() и отображает каждый из них с помощью компонента <NewsItem> .

1
2
3
4
5
6
7
renderNewsItems() {
    return this.state.news_items.map((news, index) => {
        return (
            <NewsItem key={index} index={index} news={news} />
        );
    });
}

Компонент NewsItem ( src/components/NewsItem.js ) отображает заголовок и веб-сайт новости и помещает их в компонент <Button> чтобы с ними можно было взаимодействовать.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  Text,
  View,
} from ‘react-native’;
 
import Button from ‘./Button’;
 
const NewsItem = ({ news, index }) => {
 
    function onPress(news) {
        //do anything you want
    }
     
    return (
        <Button
            key={index}
            noDefaultStyles={true}
            onPress={onPress.bind(this, news)}
        >
            <View style={styles.news_item}>
                <Text style={styles.title}>{news.title}</Text>
                <Text>{news.website}</Text>
            </View>
        </Button>
    );
}
 
const styles = StyleSheet.create({
    news_item: {
        flex: 1,
        flexDirection: ‘column’,
        paddingRight: 20,
        paddingLeft: 20,
        paddingTop: 30,
        paddingBottom: 30,
        borderBottomWidth: 1,
        borderBottomColor: ‘#E4E4E4’
    },
    title: {
        fontSize: 20,
        fontWeight: ‘bold’
    }
});
 
export default NewsItem;

Компонент Button ( src/components/Button.js ) использует компонент TouchableHighlight для создания кнопки. underlayColor используется для указания цвета подложки при нажатии кнопки. Это встроенный способ React Native для обеспечения визуальной обратной связи; позже в разделе « Страница кнопок » мы рассмотрим другие способы, с помощью которых кнопки могут обеспечивать визуальную обратную связь.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  Text,
  TouchableHighlight,
} from ‘react-native’;
 
const Button = (props) => {
 
    function getContent() {
        if(props.children){
            return props.children;
        }
        return <Text style={props.styles.label}>{props.label}</Text>
    }
 
    return (
        <TouchableHighlight
            underlayColor=»#ccc»
            onPress={props.onPress}
            style={[
                props.noDefaultStyles ?
                props.styles ?
        >
            { getContent() }
        </TouchableHighlight>
    );
}
 
const styles = StyleSheet.create({
    button: {
        alignItems: ‘center’,
        justifyContent: ‘center’,
        padding: 20,
        borderWidth: 1,
        borderColor: ‘#eee’,
        margin: 20
    }
});
 
export default Button;

Возвращаясь к компоненту NewsPage , добавьте стиль:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
const styles = StyleSheet.create({
    container: {
        flex: 1,
    },
    header: {
        flexDirection: ‘row’,
        backgroundColor: ‘#FFF’,
        padding: 20,
        justifyContent: ‘space-between’,
        borderBottomColor: ‘#E1E1E1’,
        borderBottomWidth: 1
    },
    news_container: {
        flex: 1,
    }
});

Страница кнопок ( src/pages/ButtonsPage.js ) показывает три вида кнопок: обычно используемая кнопка, которая выделяется, кнопка, которая становится немного больше, и кнопка, которая показывает текущее состояние операции. Начните с добавления необходимых компонентов:

01
02
03
04
05
06
07
08
09
10
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  View
} from ‘react-native’;
 
import Button from ‘../components/Button’;
import ScalingButton from ‘../components/ScalingButton’;
import StatefulButton from ‘../components/StatefulButton’;

Ранее вы видели, как работает компонент Button , поэтому мы просто сосредоточимся на двух других кнопках.

Сначала давайте взглянем на кнопку масштабирования ( src/components/ScalingButton.js ). В отличие от кнопки, которую мы использовали ранее, для создания кнопки используется встроенный компонент TouchableWithoutFeedback . Ранее мы использовали компонент TouchableHighlight , который поставляется со всеми прибамбасами для чего-то, что можно считать кнопкой. Вы можете думать о TouchableWithoutFeedback как обнаженной кнопке, в которой вам нужно указать все, что ему нужно делать, когда пользователь нажимает на нее. Это идеально подходит для нашего варианта использования, потому что нам не нужно беспокоиться о поведении кнопок по умолчанию, мешающем анимации, которую мы хотим реализовать.

1
2
3
4
5
6
7
8
9
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  Text,
  Animated,
  Easing,
  TouchableWithoutFeedback
} from ‘react-native’;

Как и компонент Button , это будет функциональный тип компонента, поскольку нам не нужно работать с состоянием.

1
2
3
const ScalingButton = (props) => {
    …
}

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

1
var scaleValue = new Animated.Value(0);

Добавьте функцию, которая запустит анимацию масштаба. Мы не хотим, чтобы приложение выглядело медленным, поэтому сделайте duration как можно меньше, но и достаточно высокой, чтобы пользователь мог воспринимать происходящее. 300 миллисекунд — хорошая отправная точка, но не стесняйтесь играть со значением.

01
02
03
04
05
06
07
08
09
10
11
function scale() {
    scaleValue.setValue(0);
    Animated.timing(
        scaleValue,
        {
          toValue: 1,
          duration: 300,
          easing: Easing.easeOutBack
        }
    ).start();
}

Определите, как кнопка будет масштабироваться ( outputRange ) в зависимости от текущего значения ( inputRange ). Мы не хотим, чтобы он стал слишком большим, поэтому мы придерживаемся 1.1 как наивысшего значения. Это означает, что он будет на 0.1 больше, чем его первоначальный размер, в середине ( 0.5 ) всей анимации.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
const buttonScale = scaleValue.interpolate({
  inputRange: [0, 0.5, 1],
  outputRange: [1, 1.1, 1]
});
 
return (
    <TouchableWithoutFeedback onPress={onPress}>
        <Animated.View style={[
            props.noDefaultStyles ?
            props.styles ?
            {
                transform: [
                    {scale: buttonScale}
                ]
            }
            ]}
        >
            { getContent() }
        </Animated.View>
    </TouchableWithoutFeedback>
);

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

1
2
3
4
function onPress() {
    scale();
    props.onPress();
}

Функция getContent() выводит дочерние компоненты, если она доступна. Если нет, визуализируется компонент Text содержащий label .

1
2
3
4
5
6
function getContent() {
    if(props.children){
        return props.children;
    }
    return <Text style={props.styles.label}>{ props.label }</Text>;
}

Добавьте стили и экспортируйте кнопку:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
const styles = StyleSheet.create({
    default_button: {
        alignItems: ‘center’,
        justifyContent: ‘center’
    },
    button: {
        alignItems: ‘center’,
        justifyContent: ‘center’,
        padding: 20,
        borderWidth: 1,
        borderColor: ‘#eee’,
        margin: 20
    },
});
 
export default ScalingButton;

Далее идет кнопка с состоянием ( src/components/StatefulButton.js ). При нажатии эта кнопка изменит свой фоновый цвет и покажет загрузочное изображение, пока не будет выполнена выполняемая операция.

Загружаемое изображение, которое мы будем использовать, является анимированным GIF. По умолчанию React Native на Android не поддерживает анимированные GIF-изображения. Чтобы это работало, вам нужно отредактировать файл android/app/build.gradle и добавить compile 'com.facebook.fresco:animated-gif:0.12.0' в dependencies например:

1
2
3
4
5
dependencies {
    //default dependencies here
     
    compile ‘com.facebook.fresco:animated-gif:0.12.0’
}

Если вы работаете на iOS, анимированные картинки должны работать по умолчанию.

Возвращаясь к компоненту кнопки с отслеживанием состояния, так же как и к кнопке масштабирования, для создания кнопки используется компонент TouchableWithoutFeedback , поскольку он также реализует собственную анимацию.

01
02
03
04
05
06
07
08
09
10
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  View,
  Image,
  Text,
  TouchableWithoutFeedback,
  Animated
} from ‘react-native’;

В отличие от кнопки масштабирования, этот компонент будет полноценным компонентом на основе классов, поскольку он управляет своим собственным состоянием.

Внутри constructor() создайте анимированное значение для хранения текущего цвета фона. После этого инициализируйте состояние, которое действует как переключатель для сохранения текущего состояния кнопки. По умолчанию установлено значение false . Как только пользователь нажмет на кнопку, она будет обновлена ​​до « true и будет снова установлена ​​в « false после завершения воображаемого процесса.

01
02
03
04
05
06
07
08
09
10
export default class StatefulButton extends Component {
 
    constructor(props) {
        super(props);
        this.colorValue = new Animated.Value(0);
        this.state = {
            is_loading: false
        }
    }
}

Внутри функции render() укажите различные цвета фона, которые будут использоваться на основе текущего значения анимированного значения.

1
2
3
4
5
6
7
8
9
render() {
 
    const colorAnimation = this.colorValue.interpolate({
      inputRange: [0, 50, 100],
      outputRange: [‘#2196f3’, ‘#ccc’, ‘#8BC34A’]
    });
 
    …
}

Затем оберните все внутри компонента TouchableWithoutFeedback , а внутри <Animated.View> применяется цвет анимированного фона. Мы также визуализируем изображение загрузчика, если текущее значение is_loading равно true . Метка кнопки также изменяется в зависимости от этого значения.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
return (
    <TouchableWithoutFeedback onPress={this.onPress.bind(this)}>
        <Animated.View style={[
            styles.button_container,
            this.props.noDefaultStyles ?
            this.props.styles ?
            {
                backgroundColor: colorAnimation
            },
            ]}>
            {
                this.state.is_loading &&
                <Image
                  style={styles.loader}
                  source={require(‘../img/ajax-loader.gif’)}
                />
            }
            <Text style={this.props.styles.label}>
            { this.state.is_loading ?
            </Text>
        </Animated.View>
    </TouchableWithoutFeedback>
);

Когда кнопка нажата, она сначала выполняет функцию, которая была передана через реквизит, прежде чем выполнять анимацию.

1
2
3
4
onPress() {
    this.props.onPress();
    this.changeColor();
}

Функция changeColor() отвечает за обновление состояния и анимацию цвета фона кнопки. Здесь мы предполагаем, что процесс займет 3000 миллисекунд (3 секунды). Но в реальном сценарии вы не всегда можете знать, сколько времени займет процесс. Что вы можете сделать — это запустить анимацию в течение более короткого периода времени, а затем рекурсивно вызывать changeColor() пока процесс не будет завершен.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
changeColor() {
  this.setState({
    is_loading: true
  });
 
  this.colorValue.setValue(0);
  Animated.timing(this.colorValue, {
    toValue: 100,
    duration: 3000
  }).start(() => {
    this.setState({
        is_loading: false
    });
  });
}

Добавьте стили:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
const styles = StyleSheet.create({
    button_container: {
        flexDirection: ‘row’,
        alignItems: ‘center’,
        backgroundColor: ‘#2196f3’
    },
    button: {
        alignItems: ‘center’,
        justifyContent: ‘center’,
        padding: 20,
        borderWidth: 1,
        borderColor: ‘#eee’,
        margin: 20
    },
    loader: {
        width: 16,
        height: 16,
        marginRight: 10
    }
});

Вернитесь на страницу «Кнопки»: создайте компонент, визуализируйте три вида кнопок и добавьте их стили.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
export default class ButtonsPage extends Component {
    press() {
        //do anything you want
    }
 
    render() {
        return (
            <View style={styles.container}>
                <Button
                    underlayColor={‘#ccc’}
                    label=»Ordinary Button»
                    onPress={this.press.bind(this)}
                    styles={{button: styles.ordinary_button, label: styles.button_label}} />
 
                <ScalingButton
                    label=»Scaling Button»
                    onPress={this.press.bind(this)}
                    styles={{button: styles.animated_button, label: styles.button_label}} />
 
                <StatefulButton
                    label=»Stateful Button»
                    onPress={this.press.bind(this)}
                    styles={{button: styles.stateful_button, label: styles.button_label}} />
            </View>
        );
    }
}
  
const styles = StyleSheet.create({
    container: {
        flex: 1,
        flexDirection: ‘column’,
        padding: 30
    },
    ordinary_button: {
        backgroundColor: ‘#4caf50’,
    },
    animated_button: {
        backgroundColor: ‘#ff5722’
    },
    button_label: {
        color: ‘#fff’,
        fontSize: 20,
        fontWeight: ‘bold’
    }
});

Страница Progress ( src/pages/ProgressPage.js ) показывает анимацию прогресса для пользователя во время длительного процесса. Мы будем реализовывать свои собственные вместо использования встроенных компонентов, потому что в React Native пока нет единого способа реализации анимации индикатора выполнения. Если вам интересно, вот ссылки на два встроенных компонента индикатора выполнения:

Чтобы построить нашу страницу прогресса, начните с импорта необходимых нам компонентов:

1
2
3
4
5
6
7
8
9
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  Text,
  View,
  Animated,
  Dimensions
} from ‘react-native’;

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

1
2
var { width } = Dimensions.get(‘window’);
var available_width = width — 40 — 12;

Чтобы вышеприведенная формула имела смысл, давайте перейдем сразу к стилям:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    justifyContent: ‘center’
  },
  progress_container: {
    borderWidth: 6,
    borderColor: ‘#333’,
    backgroundColor: ‘#ccc’
  },
  progress_status: {
    color: ‘#333’,
    fontSize: 20,
    fontWeight: ‘bold’,
    alignSelf: ‘center’
  }
});

container имеет padding 20 с каждой стороны — таким образом, мы вычитаем 40 из available_width . У элемента progress_container есть граница 6 с каждой стороны, поэтому мы просто удваиваем это значение снова и вычитаем 12 из ширины индикатора выполнения.

01
02
03
04
05
06
07
08
09
10
11
export default class ProgressPage extends Component {
   
  constructor(props) {
    super(props);
    this.progress = new Animated.Value(0);
    this.state = {
      progress: 0
    };
  }
 
}

Создайте компонент, а внутри конструктора создайте анимированное значение для хранения текущих значений анимации для индикатора выполнения.

Я сказал «значения», потому что на этот раз мы собираемся использовать это единственное анимированное значение для анимации как ширины, так и цвета фона индикатора выполнения. Вы увидите это в действии позже.

Кроме того, вам также нужно инициализировать текущий прогресс в состоянии.

01
02
03
04
05
06
07
08
09
10
11
export default class ProgressPage extends Component {
   
  constructor(props) {
    super(props);
    this.progress = new Animated.Value(0);
    this.state = {
      progress: 0
    };
  }
 
}

Внутри функции render() , progress_container действует как контейнер для индикатора выполнения, а <Animated.View> внутри него является фактическим индикатором выполнения, ширина и цвет фона которого будут меняться в зависимости от текущего прогресса. Ниже мы также отображаем текущий прогресс в текстовой форме (от 0% до 100%).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
render() {
    return (
        <View style={styles.container}>
          <View style={styles.progress_container}>
            <Animated.View
              style={[this.getProgressStyles.call(this)]}
            >
            </Animated.View>
          </View>
          <Text style={styles.progress_status}>
          { this.state.progress }
          </Text>
        </View>
    );
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
getProgressStyles() {
  var animated_width = this.progress.interpolate({
    inputRange: [0, 50, 100],
    outputRange: [0, available_width / 2, available_width]
  });
  //red -> orange -> green
  const color_animation = this.progress.interpolate({
    inputRange: [0, 50, 100],
    outputRange: [‘rgb(199, 45, 50)’, ‘rgb(224, 150, 39)’, ‘rgb(101, 203, 25)’]
  });
 
  return {
    width: animated_width,
    height: 50, //height of the progress bar
    backgroundColor: color_animation
  }
}

Анимация выполняется сразу после монтирования компонента. Начните с установки начального значения прогресса, а затем добавьте прослушиватель к текущему значению прогресса. Это позволяет нам обновлять состояние каждый раз, когда изменяется значение прогресса. Мы используем parseInt() , поэтому значение прогресса преобразуется в целое число. После этого мы запускаем анимацию продолжительностью 7000 миллисекунд (7 секунд). Как только это будет сделано, мы изменим текст прогресса на готово!

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
componentDidMount() {
    this.progress.setValue(0);
    this.progress.addListener((progress) => {
      this.setState({
        progress: parseInt(progress.value) + ‘%’
      });
    });
 
    Animated.timing(this.progress, {
      duration: 7000,
      toValue: 100
    }).start(() => {
      this.setState({
        progress: ‘done!’
      })
    });
}

Страница src/pages/ExpandPage.js ( src/pages/ExpandPage.js ) показывает, как визуально соединить переходные состояния с помощью движений расширения и сжатия. Важно показать пользователю, как появился конкретный элемент. Он отвечает на вопросы о том, откуда появился этот элемент и какова его роль в текущем контексте. Как всегда, начните с импорта того, что нам нужно:

01
02
03
04
05
06
07
08
09
10
11
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  Text,
  View,
  Animated
} from ‘react-native’;
 
import Icon from ‘react-native-vector-icons/FontAwesome’;
import ScalingButton from ‘../components/ScalingButton’;

Внутри constructor() создайте анимированное значение, в котором будет храниться текущая y-позиция меню. Идея состоит в том, чтобы иметь большую коробку, достаточную для размещения всех пунктов меню.

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

Вам может быть интересно, почему мы используем этот подход, а не просто масштабируем прямоугольник, чтобы вместить всех его детей. Это потому, что нам нужно только масштабировать атрибут высоты. Подумайте, что происходит с изображениями, когда вы просто настраиваете их высоту или ширину — они выглядят растянутыми. То же самое произошло бы с элементами внутри коробки.

Возвращаясь к constructor() , мы также добавляем флаг состояния, который указывает, развернуто ли меню в настоящее время или нет. Нам это нужно, потому что нам нужно скрыть кнопку для расширения меню, если меню уже развернуто.

01
02
03
04
05
06
07
08
09
10
11
12
export default class ExpandPage extends Component {
 
  constructor(props) {
    super(props);
    this.y_translate = new Animated.Value(0);
    this.state = {
      menu_expanded: false
    };
  }
 
  …
}

Внутри функции render() укажите, как будет переводиться bottom позиция. inputRange0 и 1 , а outputRange0 и -300 . Таким образом, если значение y_translate равно 0 , ничего не произойдет, поскольку эквивалент outputRange равен 0 . Но если значение становится равным 1 , bottom позиция меню переводится в -300 по -300 с исходной позицией.

Обратите внимание на отрицательный знак, потому что, если это только 300 , коробка опустится еще дальше. Если это отрицательное число, произойдет обратное.

1
2
3
4
5
6
7
8
render() {
    const menu_moveY = this.y_translate.interpolate({
        inputRange: [0, 1],
        outputRange: [0, -300]
    });
     
    …
}

Чтобы это стало понятнее, давайте перейдем к стилям:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const styles = StyleSheet.create({
  container: {
    flex: 10,
    flexDirection: ‘column’
  },
  body: {
    flex: 10,
    backgroundColor: ‘#ccc’
  },
  footer_menu: {
    position: ‘absolute’,
    width: 600,
    height: 350,
    bottom: -300,
    backgroundColor: ‘#1fa67a’,
    alignItems: ‘center’
  },
  tip_menu: {
    flexDirection: ‘row’
  },
  button: {
    backgroundColor: ‘#fff’
  },
  button_label: {
    fontSize: 20,
    fontWeight: ‘bold’
  }
});

Обратите внимание на стиль footer_menu . Его общая height установлена 350 , а bottom позиция равна -300 , что означает, что по умолчанию отображаются только верхние 50 . Когда анимация перевода выполняется для расширения меню, bottom позиция заканчивается значением 0 . Почему? Потому что, если вы все еще помните правила вычитания отрицательных чисел, два знака минус становятся положительными. Так (-300) - (-300) становится (-300) + 300 .

Мы все знаем, что происходит при добавлении положительных и отрицательных чисел: они взаимно компенсируют друг друга. Таким образом, bottom позиция в конечном итоге становится 0 , и отображается все меню.

Возвращаясь к функции render() , у нас есть основное содержимое ( body ) и меню нижнего колонтитула, которое будет расширено и сжато. Преобразование translateY используется для перевода его положения по оси Y. Поскольку весь container имеет flex: 10 а body также flex: 10 , отправная точка фактически находится в самом низу экрана.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
return (
  <View style={styles.container}>
    <View style={styles.body}></View>
    <Animated.View
      style={[
        styles.footer_menu,
        {
          transform: [
            {
              translateY: menu_moveY
            }
          ]
        }
      ]}
    >
       
      …
 
    </Animated.View>
  </View>
);

Внутри <Animated.View> находятся tip_menu и полное меню. Если меню расширено, мы не хотим, чтобы меню подсказок отображалось, поэтому мы отображаем его, только если для menu_expanded установлено значение false .

1
2
3
4
5
6
7
8
{
  !this.state.menu_expanded &&
  <View style={styles.tip_menu}>
    <ScalingButton onPress={this.openMenu.bind(this)} noDefaultStyles={true}>
      <Icon name=»ellipsis-h» size={50} color=»#fff» />
    </ScalingButton>
  </View>
}

С другой стороны, мы хотим отображать полное меню, только если menu_expanded имеет значение true . Каждая из кнопок вернет меню в исходное положение.

1
2
3
4
5
6
7
8
{
  !this.state.menu_expanded &&
  <View style={styles.tip_menu}>
    <ScalingButton onPress={this.openMenu.bind(this)} noDefaultStyles={true}>
      <Icon name=»ellipsis-h» size={50} color=»#fff» />
    </ScalingButton>
  </View>
}

При открытии меню сначала необходимо обновить состояние, чтобы скрытые меню отображались. Только когда это сделано, анимация перевода может быть выполнена. Это использует Animated.spring в отличие от Animated.timing чтобы добавить немного игривости к анимации. Чем выше значение, которое вы вводите для friction , тем меньше будет отскок. Помните, что не переусердствуйте с вашими анимациями, потому что вместо того, чтобы помогать пользователю, они могут раздражать вас.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
openMenu() {
  this.setState({
    menu_expanded: true
  }, () => {
    this.y_translate.setValue(0);
    Animated.spring(
      this.y_translate,
      {
        toValue: 1,
        friction: 3
      }
    ).start();
  });
}

hideMenu() делает противоположность showMenu() , поэтому мы просто обращаем то, что он делает:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
hideMenu() {
  this.setState({
    menu_expanded: false
  }, () => {
    this.y_translate.setValue(1);
    Animated.spring(
      this.y_translate,
      {
        toValue: 0,
        friction: 4
      }
    ).start();
  });
}

И последнее, но не менее важное — это страница src/pages/AttentionSeekerPage.js внимания ( src/pages/AttentionSeekerPage.js ). Я знаю, что это руководство уже довольно длинное, поэтому, чтобы сделать его короче, давайте используем пакет Reaction-native-Animatable для реализации анимации для этой страницы.

01
02
03
04
05
06
07
08
09
10
import React, { Component } from ‘react’;
 
import {
  StyleSheet,
  Text,
  View
} from ‘react-native’;
 
import * as Animatable from ‘react-native-animatable’;
import ScalingButton from ‘../components/ScalingButton’;

Создайте массив, содержащий тип анимации и цвет фона, который будет использоваться для каждого блока:

01
02
03
04
05
06
07
08
09
10
11
var animations = [
  [‘bounce’, ‘#62B42C’],
  [‘flash’, ‘#316BA7’],
  [‘jello’, ‘#A0A0A0’],
  [‘pulse’, ‘#FFC600’],
  [‘rotate’, ‘#1A7984’],
  [‘rubberBand’, ‘#435056’],
  [‘shake’, ‘#FF6800’],
  [‘swing’, ‘#B4354F’],
  [‘tada’, ‘#333333’]
];

Создайте компонент:

1
2
3
4
export default class AttentionSeekerPage extends Component {
    
   …
}

Функция render() использует функцию renderBoxes() для создания трех строк, каждый из которых будет отображать три блока.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
render() {
  return (
    <View style={styles.container}>
      <View style={styles.row}>
        { this.renderBoxes(0) }
      </View>
 
      <View style={styles.row}>
        { this.renderBoxes(3) }
      </View>
 
      <View style={styles.row}>
        { this.renderBoxes(6) }
      </View>
    </View>
  );
}

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

Здесь мы используем компонент <Animatable.View> вместо <Animated.View> . Это принимает animation и iterationCount качестве реквизита. animation указывает тип анимации, которую вы хотите выполнить, а iterationCount указывает, сколько раз вы хотите выполнить анимацию. В этом случае мы просто хотим, чтобы пользователи глючили, пока они не нажмут на поле.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
renderBoxes(start) {
    var selected_animations = animations.slice(start, start + 3);
    return selected_animations.map((animation, index) => {
      return (
 
        <ScalingButton
          key={index}
          onPress={this.stopAnimation.bind(this, animation[0])}
          noDefaultStyles={true}
        >
          <Animatable.View
            ref={animation[0]}
            style={[styles.box, { backgroundColor: animation[1] }]}
            animation={animation[0]}
            iterationCount={«infinite»}>
            <Text style={styles.box_text}>{ animation[0] }</Text>
          </Animatable.View>
        </ScalingButton>
 
      );
    });
}

stopAnimation() останавливает stopAnimation() окна. При этом используются «ссылки», чтобы однозначно идентифицировать каждое поле, чтобы их можно было остановить отдельно.

1
2
3
stopAnimation(animation) {
    this.refs[animation].stopAnimation();
}

Наконец, добавьте стили:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: ‘column’,
    padding: 20
  },
  row: {
    flex: 1,
    flexDirection: ‘row’,
    justifyContent: ‘space-between’
  },
  box: {
    alignItems: ‘center’,
    justifyContent: ‘center’,
    height: 100,
    width: 100,
    backgroundColor: ‘#ccc’
  },
  box_text: {
    color: ‘#FFF’
  }
});

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

Как всегда, еще многое предстоит узнать, когда дело доходит до анимации. Например, мы до сих пор не коснулись следующих областей:

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

Возможно, я расскажу о некоторых из этих тем в следующем уроке. А пока ознакомьтесь с некоторыми другими нашими курсами и учебными пособиями по React Native!

  • Создайте социальное приложение с React Native

  • Начните с React Native Layouts

  • Анимируйте свое приложение React Native

  • Создание приложения для словаря с использованием React Native для Android