Практические примеры анимации в React Native
Russian (Pусский) translation by Masha Kolesnikova (you can also view the original English article)
В этом уроке вы узнаете, как реализовать анимацию, которую вы обычно используете в мобильных приложениях. В частности, вы узнаете, как реализовать анимацию, которая:
- Предоставляет визуальную обратную связь: например, когда пользователь нажимает кнопку, вы хотите использовать анимацию, чтобы показать пользователю, что кнопка действительно нажата.
- Показывает текущий статус системы: при выполнении процесса, который не завершается мгновенно (например, при загрузке фотографии или отправке сообщения электронной почты), вы хотите показать анимацию, чтобы пользователь понял, сколько времени займет этот процесс.
- Визуально соединяет состояния перехода: когда пользователь нажимает кнопку, чтобы что-то перенести в начало экрана, этот переход должен быть анимированным, чтобы пользователь знал, откуда возник элемент.
- Получает внимание пользователя: когда есть важное уведомление, вы можете использовать анимацию, чтобы привлечь внимание пользователя.
Этот учебник является продолжением моей статьи «Анимация вашего React Native приложения». Поэтому, если вы новичок в анимации в React Native, обязательно сначала посетите ту статью, потому что некоторые из концепций, которые будут использоваться в этом руководстве, более подробно объясняются там.
Кроме того, если хотите, вы можете найти полный исходный код, используемый в этом учебнике, в репозитории на GitHub.
Что мы строим
Мы собираемся создать приложение, которое реализует каждый из различных типов анимации, о котором я упоминал ранее. В частности, мы собираемся создать следующие страницы, каждая из которых будет реализовывать анимации для разных целей.
- Страница новостей: использует жесты для визуальной обратной связи и отображения текущего состояния системы.
- Страница кнопок: использует кнопки для визуальной обратной связи и отображения текущего состояния системы.
- Страница прогресса: использует индикатор выполнения для отображения текущего состояния системы.
- Страница расширения: визуально связывает переходные состояния с использованием расширяющихся и сокращающихся движений.
- Страница привлечения внимания: использует захватывающие внимание движения, чтобы привлечь внимание пользователя.
Если вы хотите получить привью каждой из анимаций, ознакомьтесь с этим альбомом Imgur.
Настройка проекта
Начните с создания нового проекта React Native:
1 |
react-native init RNPracticalAnimations |
После создания проекта перейдите во вновь созданную папку, откройте файл package.json и добавьте следующие dependencies:
1 |
"react-native-animatable": "^0.6.1", |
2 |
"react-native-vector-icons": "^3.0.0" |
Выполните команду npm install для установки этих двух пакетов. react-native-animatable используется для легкого внедрения анимаций, а для создания значков для страницы расширенного использования используются react-native-vector-icons. Если вы не хотите использовать значки, вы можете просто использовать компонент Text. В противном случае следуйте инструкциям по установке react-native-vector-icons на их странице GitHub.
Создание приложения
Откройте файл index.android.js или файл index.ios.js и замените существующее содержимое следующим:
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
AppRegistry
|
5 |
} from 'react-native'; |
6 |
|
7 |
import NewsPage from './src/pages/NewsPage'; |
8 |
import ButtonsPage from './src/pages/ButtonsPage'; |
9 |
import ProgressPage from './src/pages/ProgressPage'; |
10 |
import ExpandPage from './src/pages/ExpandPage'; |
11 |
import AttentionSeekerPage from './src/pages/AttentionSeekerPage'; |
12 |
|
13 |
class RNPracticalAnimation extends Component { |
14 |
render() { |
15 |
return ( |
16 |
<NewsPage /> |
17 |
);
|
18 |
}
|
19 |
}
|
20 |
|
21 |
AppRegistry.registerComponent('RNPracticalAnimation', () => RNPracticalAnimation); |
Как только это будет сделано, обязательно создайте соответствующие файлы, чтобы вы не получили никаких ошибок. Все файлы, над которыми мы будем работать, хранятся в каталоге src. Внутри этого каталога находятся следующие папки:
-
components: повторно используемые компоненты, которые будут использоваться другими компонентами или страницами. -
img: изображения, которые будут использоваться во всем приложении. Вы можете получить изображения из репозитория GitHub. -
pages: страницы приложения.
Страница новостей
Начнем с страницы новостей.
Во-первых, добавьте компоненты, которые мы будем использовать:
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
Text, |
6 |
View, |
7 |
Animated, |
8 |
Easing, |
9 |
ScrollView, |
10 |
RefreshControl
|
11 |
} from 'react-native'; |
12 |
|
13 |
import NewsItem from '../components/NewsItem'; |
Вы уже должны быть знакомы с большинством из них, за исключением RefreshControl и настраиваемого компонента NewsItem, который мы будем создавать позже. RefreshControl используется для добавления функциональности «pull to refresh» внутри компонента ScrollView или ListView. Таким образом, это на самом деле тот компонент, который будет обрабатывать жесты и анимацию для нас, когда мы тянем вниз. Не нужно реализовывать наши собственные. По мере того как вы приобретете больше опыта использования React Native, вы заметите, что анимации фактически встроены в некоторые компоненты, и нет необходимости использовать класс Animated для реализации своих собственных.
Создайте компонент, который будет содержать всю страницу:
1 |
export default class NewsPage extends Component { |
2 |
...
|
3 |
}
|
Внутри constructor инициализируйте анимированное значение для сохранения текущей непрозрачности (opacityValue) новостей. Мы хотим, чтобы новостные объекты имели меньшую прозрачность, в то время как новостные статьи обновляются. Это дает пользователю представление о том, что они не могут взаимодействовать со всей страницей во время обновления новостей. Функция is_news_refreshing используется в качестве переключателя для указания того, обновляется ли новостная лента или нет.
1 |
constructor(props) { |
2 |
super(props); |
3 |
this.opacityValue = new Animated.Value(0); |
4 |
this.state = { |
5 |
is_news_refreshing: false, |
6 |
news_items: [ |
7 |
{
|
8 |
title: 'CTO Mentor Network – a virtual peer-to-peer network of CTOs', |
9 |
website: 'ctomentor.network', |
10 |
url: 'https://ctomentor.network/' |
11 |
},
|
12 |
{
|
13 |
title: 'The No More Ransom Project', |
14 |
website: 'nomoreransom.org', |
15 |
url: 'https://www.nomoreransom.org/' |
16 |
},
|
17 |
{
|
18 |
title: 'NASA Scientists Suggest We’ve Been Underestimating Sea Level Rise', |
19 |
website: 'vice.com', |
20 |
url: 'http://motherboard.vice.com/read/nasa-scientists-suggest-weve-been-underestimating-sea-level-rise' |
21 |
},
|
22 |
{
|
23 |
title: 'Buttery Smooth Emacs', |
24 |
website: 'facebook.com', |
25 |
url: 'https://www.facebook.com/notes/daniel-colascione/buttery-smooth-emacs/10155313440066102/' |
26 |
},
|
27 |
{
|
28 |
title: 'Elementary OS', |
29 |
website: 'taoofmac.com', |
30 |
url: 'http://taoofmac.com/space/blog/2016/10/29/2240' |
31 |
},
|
32 |
{
|
33 |
title: 'The Strange Inevitability of Evolution', |
34 |
website: 'nautil.us', |
35 |
url: 'http://nautil.us/issue/41/selection/the-strange-inevitability-of-evolution-rp' |
36 |
},
|
37 |
]
|
38 |
}
|
39 |
}
|
Функция opacity() - это функция, которая запускает анимацию для изменения непрозрачности.
1 |
opacity() { |
2 |
this.opacityValue.setValue(0); |
3 |
Animated.timing( |
4 |
this.opacityValue, |
5 |
{
|
6 |
toValue: 1, |
7 |
duration: 3500, |
8 |
easing: Easing.linear |
9 |
}
|
10 |
).start(); |
11 |
}
|
Внутри функции render() определите, как изменится значение непрозрачности. Здесь outputRange равен [1, 0, 1], что означает, что он начнется с полной прозрачностью, затем перейдит в нулевую прозрачность и снова вернится к полной непрозрачности. Как определено внутри функции opacity(), этот переход будет выполнен в течение 3500 миллисекунд (3,5 секунды).
1 |
render() { |
2 |
|
3 |
const opacity = this.opacityValue.interpolate({ |
4 |
inputRange: [0, 0.5, 1], |
5 |
outputRange: [1, 0, 1] |
6 |
});
|
7 |
|
8 |
...
|
9 |
}
|
Компонент <RefreshControl> добавляется в <ScrollView>. Это вызывает функцию refreshNews() всякий раз, когда делает свайп вниз, пока он находится в верхней части списка (когда scrollY равно 0). Вы можете добавить поддержку colors для настройки цвета анимации обновления.
1 |
return ( |
2 |
<View style={styles.container}> |
3 |
<View style={styles.header}> |
4 |
</View> |
5 |
<ScrollView |
6 |
refreshControl={ |
7 |
<RefreshControl |
8 |
colors={['#1e90ff']} |
9 |
refreshing={this.state.is_news_refreshing} |
10 |
onRefresh={this.refreshNews.bind(this)} |
11 |
/> |
12 |
}
|
13 |
style={styles.news_container}> |
14 |
...
|
15 |
</ScrollView> |
16 |
</View> |
17 |
);
|
Внутри <ScrollView> используйте компонент <Animated.View> и установите для style значение opacity:
1 |
<Animated.View style={[{opacity}]}> |
2 |
{ this.renderNewsItems() } |
3 |
</Animated.View> |
Функция refreshNews() вызывает функцию opacity() и обновляет значение is_news_refreshing до true. Это позволяет компоненту <RefreshControl> узнать, что анимация обновления должна быть уже показана. После этого используйте функцию setTimeout(), чтобы обновить значение is_news_refreshing до false после 3500 миллисекунд (3,5 секунды). Это скроет анимацию обновления из представления. К тому времени анимация прозрачности также должна быть выполнена, поскольку мы устанавливаем ранее то же самое значение для продолжительности в функции opacity.
1 |
refreshNews() { |
2 |
this.opacity(); |
3 |
this.setState({is_news_refreshing: true}); |
4 |
setTimeout(() => { |
5 |
this.setState({is_news_refreshing: false}); |
6 |
}, 3500); |
7 |
}
|
RenderNewsItems() принимает массив новостей, которые мы объявили ранее внутри constructor(), и отображает каждую из них с помощью компонента <NewsItem>.
1 |
renderNewsItems() { |
2 |
return this.state.news_items.map((news, index) => { |
3 |
return ( |
4 |
<NewsItem key={index} index={index} news={news} /> |
5 |
);
|
6 |
});
|
7 |
}
|
Компонент NewsItem
Компонент NewsItem (src/components/NewsItem.js) отображает заголовок и сайт новостной статьи и обертывает их внутри компонента <Button>, чтобы с ними можно было взаимодействовать.
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
Text, |
6 |
View, |
7 |
} from 'react-native'; |
8 |
|
9 |
import Button from './Button'; |
10 |
|
11 |
const NewsItem = ({ news, index }) => { |
12 |
|
13 |
function onPress(news) { |
14 |
//do anything you want
|
15 |
}
|
16 |
|
17 |
return ( |
18 |
<Button |
19 |
key={index} |
20 |
noDefaultStyles={true} |
21 |
onPress={onPress.bind(this, news)} |
22 |
>
|
23 |
<View style={styles.news_item}> |
24 |
<Text style={styles.title}>{news.title}</Text> |
25 |
<Text>{news.website}</Text> |
26 |
</View> |
27 |
</Button> |
28 |
);
|
29 |
}
|
30 |
|
31 |
const styles = StyleSheet.create({ |
32 |
news_item: { |
33 |
flex: 1, |
34 |
flexDirection: 'column', |
35 |
paddingRight: 20, |
36 |
paddingLeft: 20, |
37 |
paddingTop: 30, |
38 |
paddingBottom: 30, |
39 |
borderBottomWidth: 1, |
40 |
borderBottomColor: '#E4E4E4' |
41 |
},
|
42 |
title: { |
43 |
fontSize: 20, |
44 |
fontWeight: 'bold' |
45 |
}
|
46 |
});
|
47 |
|
48 |
export default NewsItem; |
Компонент кнопки
Компонент Button (src/components/Button.js) использует компонент TouchableHighlight для создания кнопки. Свойство underlayColor используется для указания цвета подкладки при нажатии кнопки. Это встроенный способ React Native, обеспечивающий визуальную обратную связь; Позже в разделе «Страница кнопок» мы рассмотрим другие способы, которыми кнопки могут обеспечивать визуальную обратную связь.
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
Text, |
6 |
TouchableHighlight, |
7 |
} from 'react-native'; |
8 |
|
9 |
const Button = (props) => { |
10 |
|
11 |
function getContent() { |
12 |
if(props.children){ |
13 |
return props.children; |
14 |
}
|
15 |
return <Text style={props.styles.label}>{props.label}</Text> |
16 |
}
|
17 |
|
18 |
return ( |
19 |
<TouchableHighlight |
20 |
underlayColor="#ccc" |
21 |
onPress={props.onPress} |
22 |
style={[ |
23 |
props.noDefaultStyles ? '' : styles.button, |
24 |
props.styles ? props.styles.button : '']} |
25 |
>
|
26 |
{ getContent() } |
27 |
</TouchableHighlight> |
28 |
);
|
29 |
}
|
30 |
|
31 |
const styles = StyleSheet.create({ |
32 |
button: { |
33 |
alignItems: 'center', |
34 |
justifyContent: 'center', |
35 |
padding: 20, |
36 |
borderWidth: 1, |
37 |
borderColor: '#eee', |
38 |
margin: 20 |
39 |
}
|
40 |
});
|
41 |
|
42 |
export default Button; |
Возвращаясь к компоненту NewsPage, добавьте стиль:
1 |
const styles = StyleSheet.create({ |
2 |
container: { |
3 |
flex: 1, |
4 |
},
|
5 |
header: { |
6 |
flexDirection: 'row', |
7 |
backgroundColor: '#FFF', |
8 |
padding: 20, |
9 |
justifyContent: 'space-between', |
10 |
borderBottomColor: '#E1E1E1', |
11 |
borderBottomWidth: 1 |
12 |
},
|
13 |
news_container: { |
14 |
flex: 1, |
15 |
}
|
16 |
});
|
Страница кнопок
На странице кнопок (src/pages/ButtonsPage.js) отображаются три вида кнопок: обычно используемая кнопка, которая подсвечивается, кнопка, которая становится немного больше, и кнопка, отображающая текущее состояние операции. Начните с добавления необходимых компонентов:
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
View
|
6 |
} from 'react-native'; |
7 |
|
8 |
import Button from '../components/Button'; |
9 |
import ScalingButton from '../components/ScalingButton'; |
10 |
import StatefulButton from '../components/StatefulButton'; |
Раньше вы видели, как работает компонент Button, поэтому мы просто сосредоточимся на двух других кнопках.
Компонент кнопки масштабирования
Сначала давайте посмотрим на кнопку масштабирования (src/components/ScalingButton.js). В отличие от кнопки, которую мы использовали ранее, для ее создания используется встроенный компонент TouchableWithoutFeedback. Раньше мы использовали компонент TouchableHighlight, который поставляется со всеми необходимыми для кнопки настройками. Вы можете рассматривать TouchableWithoutFeedback как голую кнопку, в которой вы должны указать все, что нужно, когда пользователь нажимает на нее. Это идеально подходит для нашего случая, потому что нам не нужно беспокоиться о поведении кнопки по умолчанию, мешающем анимации, которую мы хотим реализовать.
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
Text, |
6 |
Animated, |
7 |
Easing, |
8 |
TouchableWithoutFeedback
|
9 |
} from 'react-native'; |
Подобно компоненту Button, это будет функциональный тип компонента, так как нам действительно не нужно работать с состоянием.
1 |
const ScalingButton = (props) => { |
2 |
...
|
3 |
}
|
Внутри компонента создайте анимированное значение, которое сохранит текущую шкалу кнопок.
1 |
var scaleValue = new Animated.Value(0); |
Добавьте функцию, которая запустит анимацию масштаба. Мы не хотим, чтобы приложение выглядело медленным, поэтому сделайте duration максимально маленьким, но одновременно достаточно высоким, чтобы пользователь мог понять, что происходит. 300 миллисекунд - хорошая отправная точка, но не стесняйтесь поиграть с этим значением.
1 |
function scale() { |
2 |
scaleValue.setValue(0); |
3 |
Animated.timing( |
4 |
scaleValue, |
5 |
{
|
6 |
toValue: 1, |
7 |
duration: 300, |
8 |
easing: Easing.easeOutBack |
9 |
}
|
10 |
).start(); |
11 |
}
|
Определите то, как кнопка будет масштабироваться (outputRange) в зависимости от текущего значения (inputRange). Мы не хотим, чтобы она стала слишком большой, поэтому мы придерживаемся 1.1 как самого высокого значения. Это означает, что она будет на 0,1 больше, чем ее первоначальный размер на полпути (0.5) всей анимации.
1 |
const buttonScale = scaleValue.interpolate({ |
2 |
inputRange: [0, 0.5, 1], |
3 |
outputRange: [1, 1.1, 1] |
4 |
});
|
5 |
|
6 |
return ( |
7 |
<TouchableWithoutFeedback onPress={onPress}> |
8 |
<Animated.View style={[ |
9 |
props.noDefaultStyles ? styles.default_button : styles.button, |
10 |
props.styles ? props.styles.button : '', |
11 |
{
|
12 |
transform: [ |
13 |
{scale: buttonScale} |
14 |
]
|
15 |
}
|
16 |
]}
|
17 |
>
|
18 |
{ getContent() } |
19 |
</Animated.View> |
20 |
</TouchableWithoutFeedback> |
21 |
);
|
Функция onPress() выполняет анимацию масштаба сначала перед вызовом метода, переданного пользователем через свойства.
1 |
function onPress() { |
2 |
scale(); |
3 |
props.onPress(); |
4 |
}
|
Функция getContent() выводит дочерние компоненты, если они доступны. Если нет, отображается компонент Text, содержащий свойства label.
1 |
function getContent() { |
2 |
if(props.children){ |
3 |
return props.children; |
4 |
}
|
5 |
return <Text style={props.styles.label}>{ props.label }</Text>; |
6 |
}
|
Добавьте стили и экспортируйте кнопку:
1 |
const styles = StyleSheet.create({ |
2 |
default_button: { |
3 |
alignItems: 'center', |
4 |
justifyContent: 'center' |
5 |
},
|
6 |
button: { |
7 |
alignItems: 'center', |
8 |
justifyContent: 'center', |
9 |
padding: 20, |
10 |
borderWidth: 1, |
11 |
borderColor: '#eee', |
12 |
margin: 20 |
13 |
},
|
14 |
});
|
15 |
|
16 |
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 |
dependencies {
|
2 |
//default dependencies here |
3 |
|
4 |
compile 'com.facebook.fresco:animated-gif:0.12.0' |
5 |
} |
Если вы работаете в iOS, анимированные gif будут работать по умолчанию.
Возвращаясь к компоненту кнопки состояния, как и кнопка масштабирования, для создания кнопки используется компонент TouchableWithoutFeedback, так как он также будет реализовывать свою собственную анимацию.
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
View, |
6 |
Image, |
7 |
Text, |
8 |
TouchableWithoutFeedback, |
9 |
Animated
|
10 |
} from 'react-native'; |
В отличие от кнопки масштабирования, этот компонент будет полноценным компонентом класса, поскольку он управляет своим собственным состоянием.
Внутри constructor() создайте анимированное значение для сохранения текущего цвета фона. После этого инициализируйте состояние, которое действует как переключатель для сохранения текущего состояния кнопки. По умолчанию установлено значение false. Как только пользователь нажал на кнопку, оно будет обновлено true и будет снова установлено в значение false снова после выполнения воображаемого процесса.
1 |
export default class StatefulButton extends Component { |
2 |
|
3 |
constructor(props) { |
4 |
super(props); |
5 |
this.colorValue = new Animated.Value(0); |
6 |
this.state = { |
7 |
is_loading: false |
8 |
}
|
9 |
}
|
10 |
}
|
Внутри функции render() укажите различные цвета фона, которые будут использоваться на основе текущего значения анимированного значения.
1 |
render() { |
2 |
|
3 |
const colorAnimation = this.colorValue.interpolate({ |
4 |
inputRange: [0, 50, 100], |
5 |
outputRange: ['#2196f3', '#ccc', '#8BC34A'] |
6 |
});
|
7 |
|
8 |
...
|
9 |
}
|
Затем оберните все внутри компонента TouchableWithoutFeedback, а внутри <Animated.View> находится анимированный цвет фона. Мы также отображаем изображение загрузчика, если текущее значение is_loading равно true. Метка кнопки также изменяется в зависимости от этого значения.
1 |
return ( |
2 |
<TouchableWithoutFeedback onPress={this.onPress.bind(this)}> |
3 |
<Animated.View style={[ |
4 |
styles.button_container, |
5 |
this.props.noDefaultStyles ? '' : styles.button, |
6 |
this.props.styles ? this.props.styles.button : '', |
7 |
{
|
8 |
backgroundColor: colorAnimation |
9 |
},
|
10 |
]}> |
11 |
{
|
12 |
this.state.is_loading && |
13 |
<Image |
14 |
style={styles.loader} |
15 |
source={require('../img/ajax-loader.gif')} |
16 |
/> |
17 |
}
|
18 |
<Text style={this.props.styles.label}> |
19 |
{ this.state.is_loading ? 'loading...' : this.props.label} |
20 |
</Text> |
21 |
</Animated.View> |
22 |
</TouchableWithoutFeedback> |
23 |
);
|
Когда кнопка нажата, она сначала выполняет функцию, которая была передана через свойства перед выполнением анимации.
1 |
onPress() { |
2 |
this.props.onPress(); |
3 |
this.changeColor(); |
4 |
}
|
Функция changeColor() отвечает за обновление состояния и анимирование цвета фона кнопки. Здесь мы предполагаем, что процесс займет 3000 миллисекунд (3 секунды). Но в реальном сценарии вы не всегда можете знать, сколько времени займет процесс. Что вы можете сделать, так это выполнить анимацию в течение более короткого периода времени, а затем вызвать функцию changeColor() рекурсивно до тех пор, пока процесс не будет выполнен.
1 |
changeColor() { |
2 |
this.setState({ |
3 |
is_loading: true |
4 |
});
|
5 |
|
6 |
this.colorValue.setValue(0); |
7 |
Animated.timing(this.colorValue, { |
8 |
toValue: 100, |
9 |
duration: 3000 |
10 |
}).start(() => { |
11 |
this.setState({ |
12 |
is_loading: false |
13 |
});
|
14 |
});
|
15 |
}
|
Добавьте стили:
1 |
const styles = StyleSheet.create({ |
2 |
button_container: { |
3 |
flexDirection: 'row', |
4 |
alignItems: 'center', |
5 |
backgroundColor: '#2196f3' |
6 |
},
|
7 |
button: { |
8 |
alignItems: 'center', |
9 |
justifyContent: 'center', |
10 |
padding: 20, |
11 |
borderWidth: 1, |
12 |
borderColor: '#eee', |
13 |
margin: 20 |
14 |
},
|
15 |
loader: { |
16 |
width: 16, |
17 |
height: 16, |
18 |
marginRight: 10 |
19 |
}
|
20 |
});
|
Вернемся к странице Кнопки: создайте компонент, отрисуйте три вида кнопок и добавьте их стили.
1 |
export default class ButtonsPage extends Component { |
2 |
press() { |
3 |
//do anything you want
|
4 |
}
|
5 |
|
6 |
render() { |
7 |
return ( |
8 |
<View style={styles.container}> |
9 |
<Button |
10 |
underlayColor={'#ccc'} |
11 |
label="Ordinary Button" |
12 |
onPress={this.press.bind(this)} |
13 |
styles={{button: styles.ordinary_button, label: styles.button_label}} /> |
14 |
|
15 |
<ScalingButton |
16 |
label="Scaling Button" |
17 |
onPress={this.press.bind(this)} |
18 |
styles={{button: styles.animated_button, label: styles.button_label}} /> |
19 |
|
20 |
<StatefulButton |
21 |
label="Stateful Button" |
22 |
onPress={this.press.bind(this)} |
23 |
styles={{button: styles.stateful_button, label: styles.button_label}} /> |
24 |
</View> |
25 |
);
|
26 |
}
|
27 |
}
|
28 |
|
29 |
const styles = StyleSheet.create({ |
30 |
container: { |
31 |
flex: 1, |
32 |
flexDirection: 'column', |
33 |
padding: 30 |
34 |
},
|
35 |
ordinary_button: { |
36 |
backgroundColor: '#4caf50', |
37 |
},
|
38 |
animated_button: { |
39 |
backgroundColor: '#ff5722' |
40 |
},
|
41 |
button_label: { |
42 |
color: '#fff', |
43 |
fontSize: 20, |
44 |
fontWeight: 'bold' |
45 |
}
|
46 |
});
|
Страница прогресса
Страница прогресса (src/pages/ProgressPage.js) показывает анимацию прогресса пользователю во время продолжительного процесса. Мы будем использовать наш собственный компонент, вместо встроенного, потому что у React Native нет единого способа реализации анимации индикатора выполнения. Если вам интересно, вот ссылки на два встроенных компонента индикатора выполнения:
Чтобы создать страницу «Прогресс», начните с импорта необходимых нам компонентов:
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
Text, |
6 |
View, |
7 |
Animated, |
8 |
Dimensions
|
9 |
} from 'react-native'; |
Мы используем Dimensions, чтобы получить ширину устройства. В итоге мы можем рассчитать ширину, доступную для индикатора выполнения. Чтобы сделать это, мы вычтем сумму левого и правого paddings, которые мы добавим в контейнер, а также левую и правую границы, которые мы добавим в контейнер индикаторов выполнения.
1 |
var { width } = Dimensions.get('window'); |
2 |
var available_width = width - 40 - 12; |
Для того чтобы приведенная выше формула имела смысл, перейдем к стилям:
1 |
const styles = StyleSheet.create({ |
2 |
container: { |
3 |
flex: 1, |
4 |
padding: 20, |
5 |
justifyContent: 'center' |
6 |
},
|
7 |
progress_container: { |
8 |
borderWidth: 6, |
9 |
borderColor: '#333', |
10 |
backgroundColor: '#ccc' |
11 |
},
|
12 |
progress_status: { |
13 |
color: '#333', |
14 |
fontSize: 20, |
15 |
fontWeight: 'bold', |
16 |
alignSelf: 'center' |
17 |
}
|
18 |
});
|
container имеет padding по 20 с каждой стороны, поэтому мы вычитаем 40 из available_width. У progress_container есть граница шириной 6 с каждой стороны, поэтому мы просто удваиваем ее и вычитаем 12 из ширины прогресс бара.
1 |
export default class ProgressPage extends Component { |
2 |
|
3 |
constructor(props) { |
4 |
super(props); |
5 |
this.progress = new Animated.Value(0); |
6 |
this.state = { |
7 |
progress: 0 |
8 |
};
|
9 |
}
|
10 |
|
11 |
}
|
Создайте компонент и внутри конструктора создайте анимированное значение для сохранения текущих значений анимации для индикатора выполнения.
Я сказал «значения», потому что на этот раз мы будем использовать это одиночное анимированное значение для анимации как ширины, так и цвета фона индикатора выполнения. Все это в действии вы увидите чуть позже.
Помимо этого, вам также необходимо инициализировать текущий прогресс в состоянии.
1 |
export default class ProgressPage extends Component { |
2 |
|
3 |
constructor(props) { |
4 |
super(props); |
5 |
this.progress = new Animated.Value(0); |
6 |
this.state = { |
7 |
progress: 0 |
8 |
};
|
9 |
}
|
10 |
|
11 |
}
|
Внутри функции render(), progress_container действует как контейнер для индикатора выполнения, а <Animated.View> внутри него -как фактический индикатор выполнения, ширина и цвет фона которого будут меняться в зависимости от текущего прогресса. Ниже мы также показываем текущий прогресс в текстовой форме (от 0% до 100%).
1 |
render() { |
2 |
return ( |
3 |
<View style={styles.container}> |
4 |
<View style={styles.progress_container}> |
5 |
<Animated.View |
6 |
style={[this.getProgressStyles.call(this)]} |
7 |
>
|
8 |
</Animated.View> |
9 |
</View> |
10 |
<Text style={styles.progress_status}> |
11 |
{ this.state.progress } |
12 |
</Text> |
13 |
</View> |
14 |
);
|
15 |
}
|
Стили для прогресс бара возвращаются функцией getProgressStyles(). Здесь мы используем анимированное значение ранее, чтобы рассчитать ширину и цвет фона. Мы делаем так вместо создания отдельного анимированного значения для каждой анимации, как как мы все равно интерполируем одно и то же значение. Если бы мы использовали два отдельных значения, нам нужно было бы иметь две анимации параллельно, что менее эффективно.
1 |
getProgressStyles() { |
2 |
var animated_width = this.progress.interpolate({ |
3 |
inputRange: [0, 50, 100], |
4 |
outputRange: [0, available_width / 2, available_width] |
5 |
});
|
6 |
//red -> orange -> green
|
7 |
const color_animation = this.progress.interpolate({ |
8 |
inputRange: [0, 50, 100], |
9 |
outputRange: ['rgb(199, 45, 50)', 'rgb(224, 150, 39)', 'rgb(101, 203, 25)'] |
10 |
});
|
11 |
|
12 |
return { |
13 |
width: animated_width, |
14 |
height: 50, //height of the progress bar |
15 |
backgroundColor: color_animation |
16 |
}
|
17 |
}
|
Анимация будет немедленно выполнена сразу после монтирования компонента. Начните с установки начального значения прогресса, а затем добавьте слушателя к текущему значению прогресса. Это позволяет нам обновлять состояние каждый раз, когда изменяется значение прогресса. Мы используем parseInt(), поэтому значение прогресса преобразуется в целое число. После этого мы запускаем анимацию продолжительностью 7000 миллисекунд (7 секунд). Как только это будет сделано, мы изменим текст прогресса на done!
1 |
componentDidMount() { |
2 |
this.progress.setValue(0); |
3 |
this.progress.addListener((progress) => { |
4 |
this.setState({ |
5 |
progress: parseInt(progress.value) + '%' |
6 |
});
|
7 |
});
|
8 |
|
9 |
Animated.timing(this.progress, { |
10 |
duration: 7000, |
11 |
toValue: 100 |
12 |
}).start(() => { |
13 |
this.setState({ |
14 |
progress: 'done!' |
15 |
})
|
16 |
});
|
17 |
}
|
Страница расширения
Страница расширения (src/pages/ExpandPage.js) показывает, как визуально связывать переходные состояния с помощью расширяющихся и сокращающихся движений. Важно показать пользователю, как появился конкретный элемент. Он отвечает на вопросы о том, откуда взялся этот элемент и какова его роль в текущем контексте. Как всегда, начните с импорта необходимых нам вещей:
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
Text, |
6 |
View, |
7 |
Animated
|
8 |
} from 'react-native'; |
9 |
|
10 |
import Icon from 'react-native-vector-icons/FontAwesome'; |
11 |
import ScalingButton from '../components/ScalingButton'; |
Внутри constructor() создайте анимированное значение, которое будет сохранять y-позицию в меню. Идея состоит в том, чтобы получить большой бокс, достаточный для того, чтобы содержать все пункты меню.
Первоначально поле будет иметь отрицательное значение для позиции bottom. Это означает, что по умолчанию будет отображаться только верхушка всего окна. Как только пользователь нажимает на меню, весь бокс будет выглядеть так, как если бы он был расширен, когда на самом деле мы только меняем bottom позицию.
Вам может быть интересно, почему мы используем этот подход вместо того, чтобы просто масштабировать поле для размещения всех его дочерних элементов. Это потому, что нам нужно только масштабировать атрибут высоты. Подумайте, что происходит с изображениями, когда вы просто корректируете их высоту или ширину - они выглядят растянутыми. То же самое произойдет с элементами внутри бокса.
Возвращаясь к constructor(), мы также добавляем флаг состояния, который указывает, будет ли в настоящее время меню расширено или нет. Нам это нужно, так как нужно скрыть кнопку для расширения меню, если меню итак уже расширено.
1 |
export default class ExpandPage extends Component { |
2 |
|
3 |
constructor(props) { |
4 |
super(props); |
5 |
this.y_translate = new Animated.Value(0); |
6 |
this.state = { |
7 |
menu_expanded: false |
8 |
};
|
9 |
}
|
10 |
|
11 |
...
|
12 |
}
|
Внутри функции render() укажите, как будет изменена bottom позиция. inputRange равен 0 и 1, а outputRange равен 0 и -300. Поэтому, если y_translate значение 0, ничего не произойдет, потому что эквивалент outputRange равен 0. Но если значение становится равным 1, bottom положение меню переводится на -300 из своего исходного положения.
Обратите внимание на отрицательный знак, потому что, если это будет просто 300, то бокс расширится еще дальше. А если это отрицательное число, произойдет обратное.
1 |
render() { |
2 |
const menu_moveY = this.y_translate.interpolate({ |
3 |
inputRange: [0, 1], |
4 |
outputRange: [0, -300] |
5 |
});
|
6 |
|
7 |
...
|
8 |
}
|
Чтобы это имело больше смысла, давайте перейдем к стилям:
1 |
const styles = StyleSheet.create({ |
2 |
container: { |
3 |
flex: 10, |
4 |
flexDirection: 'column' |
5 |
},
|
6 |
body: { |
7 |
flex: 10, |
8 |
backgroundColor: '#ccc' |
9 |
},
|
10 |
footer_menu: { |
11 |
position: 'absolute', |
12 |
width: 600, |
13 |
height: 350, |
14 |
bottom: -300, |
15 |
backgroundColor: '#1fa67a', |
16 |
alignItems: 'center' |
17 |
},
|
18 |
tip_menu: { |
19 |
flexDirection: 'row' |
20 |
},
|
21 |
button: { |
22 |
backgroundColor: '#fff' |
23 |
},
|
24 |
button_label: { |
25 |
fontSize: 20, |
26 |
fontWeight: 'bold' |
27 |
}
|
28 |
});
|
Обратите внимание на стиль 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, то отправная точка находится в самом низу экрана.
1 |
return ( |
2 |
<View style={styles.container}> |
3 |
<View style={styles.body}></View> |
4 |
<Animated.View |
5 |
style={[ |
6 |
styles.footer_menu, |
7 |
{
|
8 |
transform: [ |
9 |
{
|
10 |
translateY: menu_moveY |
11 |
}
|
12 |
]
|
13 |
}
|
14 |
]}
|
15 |
>
|
16 |
|
17 |
...
|
18 |
|
19 |
</Animated.View> |
20 |
</View> |
21 |
);
|
Внутри <Animated.View> находятся tip_menu и полное меню. Если меню расширено, мы не хотим, чтобы отображалось меню подсказок, поэтому мы отображаем его только в том случае, если для параметра menu_expanded установлено значение false.
1 |
{
|
2 |
!this.state.menu_expanded && |
3 |
<View style={styles.tip_menu}> |
4 |
<ScalingButton onPress={this.openMenu.bind(this)} noDefaultStyles={true}> |
5 |
<Icon name="ellipsis-h" size={50} color="#fff" /> |
6 |
</ScalingButton> |
7 |
</View> |
8 |
}
|
С другой стороны, мы хотим отобразить полное меню, если для параметра menu_expanded будет установлено значение true. Каждая из кнопок вернет меню обратно в исходное положение.
1 |
{
|
2 |
!this.state.menu_expanded && |
3 |
<View style={styles.tip_menu}> |
4 |
<ScalingButton onPress={this.openMenu.bind(this)} noDefaultStyles={true}> |
5 |
<Icon name="ellipsis-h" size={50} color="#fff" /> |
6 |
</ScalingButton> |
7 |
</View> |
8 |
}
|
При открытии меню сначала необходимо обновить состояние, чтобы отображались скрытые меню. Только после этого можно выполнить анимацию перевода. Это использует Animated.spring, а не Animated.timing, чтобы добавить немного анимации. Чем выше значение, которое вы придаете friction, тем меньше будет отскок. Помните, что не переусердствуйте с анимацией, потому что вместо того, чтобы помогать пользователю, они могут оказаться раздражающими.
1 |
openMenu() { |
2 |
this.setState({ |
3 |
menu_expanded: true |
4 |
}, () => { |
5 |
this.y_translate.setValue(0); |
6 |
Animated.spring( |
7 |
this.y_translate, |
8 |
{
|
9 |
toValue: 1, |
10 |
friction: 3 |
11 |
}
|
12 |
).start(); |
13 |
});
|
14 |
}
|
HideMenu() делает противоположное showMenu(), поэтому мы просто отменяем то, что он делает:
1 |
hideMenu() { |
2 |
this.setState({ |
3 |
menu_expanded: false |
4 |
}, () => { |
5 |
this.y_translate.setValue(1); |
6 |
Animated.spring( |
7 |
this.y_translate, |
8 |
{
|
9 |
toValue: 0, |
10 |
friction: 4 |
11 |
}
|
12 |
).start(); |
13 |
});
|
14 |
}
|
Страница привлечения внимания
Последней, но не менее важной является страница привлечения внимания (src/pages/AttentionSeekerPage.js). Я знаю, что этот учебник уже довольно длинный, поэтому, чтобы немного сократить, давайте использовать пакет react-native-animatable для реализации анимаций для этой страницы.
1 |
import React, { Component } from 'react'; |
2 |
|
3 |
import { |
4 |
StyleSheet, |
5 |
Text, |
6 |
View
|
7 |
} from 'react-native'; |
8 |
|
9 |
import * as Animatable from 'react-native-animatable'; |
10 |
import ScalingButton from '../components/ScalingButton'; |
Создайте массив, содержащий тип анимации и цвет фона, который будет использоваться для каждого окна:
1 |
var animations = [ |
2 |
['bounce', '#62B42C'], |
3 |
['flash', '#316BA7'], |
4 |
['jello', '#A0A0A0'], |
5 |
['pulse', '#FFC600'], |
6 |
['rotate', '#1A7984'], |
7 |
['rubberBand', '#435056'], |
8 |
['shake', '#FF6800'], |
9 |
['swing', '#B4354F'], |
10 |
['tada', '#333333'] |
11 |
];
|
Создайте компонент:
1 |
export default class AttentionSeekerPage extends Component { |
2 |
|
3 |
...
|
4 |
}
|
Функция render() использует функцию renderBoxes() для создания трех строк, каждая из которых будет отображать по три окна.
1 |
render() { |
2 |
return ( |
3 |
<View style={styles.container}> |
4 |
<View style={styles.row}> |
5 |
{ this.renderBoxes(0) } |
6 |
</View> |
7 |
|
8 |
<View style={styles.row}> |
9 |
{ this.renderBoxes(3) } |
10 |
</View> |
11 |
|
12 |
<View style={styles.row}> |
13 |
{ this.renderBoxes(6) } |
14 |
</View> |
15 |
</View> |
16 |
);
|
17 |
}
|
Функция renderBoxes() отображает анимированные поля. Она использует начальный индекс, предоставленный в качестве аргумента, для извлечения определенной части массива и визуализации их по отдельности.
Здесь мы используем <Animatable.View> вместо <Animated.View>. Он принимает animation и iterationCount в качестве свойств. animation определяет тип анимации, которую вы хотите выполнить, и iterationCount указывает, сколько раз вы хотите выполнить анимацию. В этом случае мы просто хотим отключить пользователя, пока не нажмем на него.
1 |
renderBoxes(start) { |
2 |
var selected_animations = animations.slice(start, start + 3); |
3 |
return selected_animations.map((animation, index) => { |
4 |
return ( |
5 |
|
6 |
<ScalingButton |
7 |
key={index} |
8 |
onPress={this.stopAnimation.bind(this, animation[0])} |
9 |
noDefaultStyles={true} |
10 |
>
|
11 |
<Animatable.View |
12 |
ref={animation[0]} |
13 |
style={[styles.box, { backgroundColor: animation[1] }]} |
14 |
animation={animation[0]} |
15 |
iterationCount={"infinite"}> |
16 |
<Text style={styles.box_text}>{ animation[0] }</Text> |
17 |
</Animatable.View> |
18 |
</ScalingButton> |
19 |
|
20 |
);
|
21 |
});
|
22 |
}
|
stopAnimation() просто прекращает анимацию на боксе. Она использует «refs», чтобы однозначно идентифицировать каждый бокс, чтобы их можно было остановить индивидуально.
1 |
stopAnimation(animation) { |
2 |
this.refs[animation].stopAnimation(); |
3 |
}
|
Наконец, добавьте стили:
1 |
const styles = StyleSheet.create({ |
2 |
container: { |
3 |
flex: 1, |
4 |
flexDirection: 'column', |
5 |
padding: 20 |
6 |
},
|
7 |
row: { |
8 |
flex: 1, |
9 |
flexDirection: 'row', |
10 |
justifyContent: 'space-between' |
11 |
},
|
12 |
box: { |
13 |
alignItems: 'center', |
14 |
justifyContent: 'center', |
15 |
height: 100, |
16 |
width: 100, |
17 |
backgroundColor: '#ccc' |
18 |
},
|
19 |
box_text: { |
20 |
color: '#FFF' |
21 |
}
|
22 |
});
|
Заключение
В этом уроке вы узнали, как реализовать некоторые анимации, обычно используемые в мобильных приложениях. В частности, вы узнали, как реализовать анимацию, обеспечивающую визуальную обратную связь, отображение текущего состояния системы, визуальное связывание состояний перехода и привлечение внимания пользователя.
Как всегда, еще многое предстоит узнать, когда дело касается анимации. Например, мы до сих пор не затронули следующие области:
- Как выполнять анимацию для конкретных жестов пользователя, таких как перетаскивание, щелканье, сужение и расширение. Например, когда пользователь использует жест расширения, вы должны использовать анимацию масштаба, чтобы показать, как этот элемент становится больше.
- Как анимировать переход нескольких элементов из одного состояния в другое. Например, при отображении списка фотографий вы можете захотеть выполнить анимацию шага, чтобы задержать показ всех фотографий.
- Intro-анимация для первых пользователей приложения. В качестве альтернативы можно использовать видео, но это также хорошее место для реализации анимаций.
Возможно, я расскажу о некоторых из этих тем в следующем учебнике. Тем временем, ознакомьтесь с некоторыми из наших других курсов и учебников по React Native!



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



Начало работы с макетами React Native



Анимация вашего React Native приложения



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



