Анимация может придать много лоска вашему приложению, но может быть сложной в исполнении. Reanimated v2 значительно упрощает эту задачу.
Эта статья служит введением в API Reanimated v2. Мы создадим анимированный прогресс-бар, как показано в следующем GIF.
Мы создадим компонент StatsCard
, внутри которого будет находиться индикатор прогресса (это та часть, в верхней части которой находятся «Pairs matched» и «Total moves» в приведенном выше GIF). При изменении числителя или знаменателя ширина индикатора выполнения изменяется.
// screens/MatchThePairs.tsx
<StatsCard
title="Pairs matched"
numerator={matchCount}
denominator={totalPairs}
/>
Начальный код
Давайте посмотрим на нашу начальную точку. Он имеет индикатор выполнения, который будет изменять свою ширину в зависимости от процента выполнения, но при изменении ширины нет анимации, он просто переходит на следующую ширину, как показано в следующем GIF.
Обратите внимание, что мы получаем ширину карточки через параметр onLayout
компонента View
. Таким образом, независимо от размера экрана и ширины карты, мы получаем точную ширину для расчетов.
// components/cards/Stats.tsx
import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import { Colors, Spacing, Theme } from "src/constants"
type StatsCardProps = {
title: string
numerator: number
denominator?: number
}
export const StatsCard = (props: StatsCardProps) => {
const { numerator, denominator } = props
const showProgressBar = denominator !== undefined
const [cardWidth, setCardWidth] = React.useState(0)
const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]
const progressBarStyles: ViewStyle[] = [styles.progressBar]
if (showProgressBar) {
progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
progressBarStyles.push({ width: (numerator / denominator) * cardWidth })
}
if (numerator === denominator) {
progressBarStyles.push({ borderBottomRightRadius: 0 })
}
return (
<View
style={styles.container}
onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
>
<View style={progressBarContainerStyles}>
<View style={progressBarStyles} />
</View>
<View style={styles.content}>
<Text style={styles.title}>{props.title}</Text>
<Text style={styles.numerator}>
{numerator}
{denominator && (
<Text style={styles.denominator}>{`/${denominator}`}</Text>
)}
</Text>
</View>
</View>
)
}
const borderRadius = Theme.radius
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.greyLight,
margin: Spacing.sm,
borderRadius,
},
content: {
padding: Spacing.sm,
},
title: {
fontWeight: "500",
fontSize: 16,
color: Colors.greyDarkest,
marginBottom: Spacing.xs,
},
numerator: {
color: Colors.greyDarkest,
fontSize: 20,
fontWeight: "600",
},
denominator: {
color: Colors.greyDark,
fontSize: 14,
fontWeight: "500",
},
progressBarContainer: {
backgroundColor: "transparent",
height: 8,
borderTopLeftRadius: borderRadius,
borderTopRightRadius: borderRadius,
marginBottom: Spacing.xs,
},
progressBar: {
height: 8,
width: 0,
backgroundColor: Colors.blueMedium,
borderTopLeftRadius: borderRadius,
borderTopRightRadius: borderRadius,
borderBottomRightRadius: borderRadius,
},
})
Переход на Reanimated 2
Если у вас еще не установлен Reanimated 2, вы должны сделать это, следуя официальным документам.
Чтобы использовать анимацию Reanimated 2, нам нужно использовать компоненты, которые понимают анимированные значения, предоставляемые Reanimated. Большинство/все компоненты, которые вы хотите анимировать, предоставляются по умолчанию для экспорта из библиотеки. Это означает, что у нас есть Animated.Text
, Animated.View
и т.д.
В приведенном ниже коде нам нужно импортировать Animated из "react-native-reanimated"
, а также переключить
<View style={progressBarStyles} />
на
<Animated.View style={progressBarStyles} />
в результате
// components/cards/Stats.tsx
import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"
import { Colors, Spacing, Theme } from "src/constants"
type StatsCardProps = {
/* ... */
}
export const StatsCard = (props: StatsCardProps) => {
const { numerator, denominator } = props
const showProgressBar = denominator !== undefined
const [cardWidth, setCardWidth] = React.useState(0)
const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]
const progressBarStyles: ViewStyle[] = [styles.progressBar]
if (showProgressBar) {
progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
progressBarStyles.push({ width: (numerator / denominator) * cardWidth })
}
if (numerator === denominator) {
progressBarStyles.push({ borderBottomRightRadius: 0 })
}
return (
<View
style={styles.container}
onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
>
<View style={progressBarContainerStyles}>
<Animated.View style={progressBarStyles} />
</View>
<View style={styles.content}>
<Text style={styles.title}>{props.title}</Text>
<Text style={styles.numerator}>
{numerator}
{denominator && (
<Text style={styles.denominator}>{`/${denominator}`}</Text>
)}
</Text>
</View>
</View>
)
}
const borderRadius = Theme.radius
const styles = StyleSheet.create({
/* ... */
})
Результат этого изменения выглядит и работает точно так же, как и раньше.
Создание анимированных стилей
Далее нам нужно настроить наши анимированные стили. Animated.View
поддерживает те же свойства стиля, что и обычный компонент View
(как показано выше) в дополнение к анимированным стилям, уникальным для Reanimated.
Для этого нам понадобится импортировать useAnimatedStyle
из react-native-reanimated
. Этот хук является одним из основных элементов API Reanimated v2.
Из хука useAnimatedStyle
мы хотим вернуть объект стиля со свойствами, которые должны быть анимированы. В нашем случае это width
прогресс-бара.
Также обратите внимание, что useAnimatedStyle
принимает второй аргумент, который служит массивом зависимостей (так же, как и useEffect
). Поскольку ширина столбика меняется при изменении numerator
, denominator
или cardWidth
, мы добавим их все в качестве зависимостей.
Ширина вычисляется точно так же, как и раньше. Единственное отличие в том, что мы добавим нашу проверку showProgressBar
внутри хука.
const progressBarWidthAnimated = useAnimatedStyle(() => {
if (!showProgressBar) {
return {
width: 0,
}
}
return { width: (numerator / denominator) * cardWidth }
}, [numerator, denominator, cardWidth])
Этот хук в контексте компонента:
// components/cards/Stats.tsx
import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"
import { Colors, Spacing, Theme } from "src/constants"
type StatsCardProps = {
/* ... */
}
export const StatsCard = (props: StatsCardProps) => {
const { numerator, denominator } = props
const showProgressBar = denominator !== undefined
const [cardWidth, setCardWidth] = React.useState(0)
const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]
if (showProgressBar) {
progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
}
const progressBarWidthAnimated = useAnimatedStyle(() => {
if (!showProgressBar) {
return {
width: 0,
}
}
return { width: (numerator / denominator) * cardWidth }
}, [numerator, denominator, cardWidth])
const progressBarStyles: ViewStyle[] = [
styles.progressBar,
progressBarWidthAnimated,
]
if (numerator === denominator) {
progressBarStyles.push({ borderBottomRightRadius: 0 })
}
return (
<View
style={styles.container}
onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
>
<View style={progressBarContainerStyles}>
<Animated.View style={progressBarStyles} />
</View>
<View style={styles.content}>
<Text style={styles.title}>{props.title}</Text>
<Text style={styles.numerator}>
{numerator}
{denominator && (
<Text style={styles.denominator}>{`/${denominator}`}</Text>
)}
</Text>
</View>
</View>
)
}
const borderRadius = Theme.radius
const styles = StyleSheet.create({
/* ... */
})
Взглянув на следующий GIF, вы увидите, что результат снова в точности соответствует тому, что мы имели все это время.
Управление анимацией
Наконец-то мы можем сделать анимацию реальной! Это происходит с помощью withSpring
из Reanimated (существует множество других драйверов анимации). Этот драйвер позволит нам сделать анимацию, подобную пружине, когда она «подпрыгивает» над/под целевым значением.
Все, что мы сделаем, это передадим целевое значение в качестве аргумента в withSpring
и вернем его в качестве ширины.
return {
width: withSpring((numerator / denominator) * cardWidth),
}
// components/cards/Stats.tsx
import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"
import { Colors, Spacing, Theme } from "src/constants"
type StatsCardProps = {
/* ... */
}
export const StatsCard = (props: StatsCardProps) => {
const { numerator, denominator } = props
const showProgressBar = denominator !== undefined
const [cardWidth, setCardWidth] = React.useState(0)
const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]
if (showProgressBar) {
progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
}
const progressBarWidthAnimated = useAnimatedStyle(() => {
if (!showProgressBar) {
return {
width: 0,
}
}
return {
width: withSpring((numerator / denominator) * cardWidth),
}
}, [numerator, denominator, cardWidth])
const progressBarStyles: ViewStyle[] = [
styles.progressBar,
progressBarWidthAnimated,
]
if (numerator === denominator) {
progressBarStyles.push({ borderBottomRightRadius: 0 })
}
return (
<View
style={styles.container}
onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
>
<View style={progressBarContainerStyles}>
<Animated.View style={progressBarStyles} />
</View>
<View style={styles.content}>
<Text style={styles.title}>{props.title}</Text>
<Text style={styles.numerator}>
{numerator}
{denominator && (
<Text style={styles.denominator}>{`/${denominator}`}</Text>
)}
</Text>
</View>
</View>
)
}
const borderRadius = Theme.radius
const styles = StyleSheet.create({
/* ... */
})
Теперь мы видим некоторые анимации! Но есть несколько проблем:
- Если полоска заполнена на 100%, то из-за анимации пружины она может переполниться за пределы карты.
- Если значение установлено на 0 после того, как оно было равно 8 (например, когда вы перезагружаете игру), это приводит к глюкам: полоса заполняется/не заполняется несколько раз.
- Если полоса близка к полной (например, 7/8), она все равно может переполниться за пределы карты.
Устранение проблем с переполнением
Проблемы, которые мы наблюдаем, являются результатом использования весенней анимации, но мы можем исправить их с помощью нескольких аргументов к withSpring
.
Для устранения проблемы проскакивания мы можем «зажать» анимацию, чтобы она не выходила за пределы целевого значения с помощью overshootClamping
. Однако это лишает анимацию пружины некоторого удовольствия, поэтому мы будем делать это только в том случае, если бар заполнен на 0% или 100%.
Чтобы исправить третий момент, когда заполненная на 90% полоса переполняет карту, мы можем уменьшить «упругость» пружины, настроив параметр stiffness
.
// components/cards/Stats.tsx
import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"
import { Colors, Spacing, Theme } from "src/constants"
type StatsCardProps = {
/* ... */
}
export const StatsCard = (props: StatsCardProps) => {
const { numerator, denominator } = props
const showProgressBar = denominator !== undefined
const [cardWidth, setCardWidth] = React.useState(0)
const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]
if (showProgressBar) {
progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
}
const progressBarWidthAnimated = useAnimatedStyle(() => {
if (!showProgressBar) {
return {
width: 0,
}
}
// We clamp at 0 and the last number so that the bar doesn't extend outside of
// the card. If we jump from 8 to 0 (reseting a game) the bar glitches and
// empties, refills, and empties again. Clamping fixes that.
const useClamping = numerator === 0 || numerator >= denominator
return {
width: withSpring((numerator / denominator) * cardWidth, {
overshootClamping: useClamping,
stiffness: 75,
}),
}
}, [numerator, denominator, cardWidth])
const progressBarStyles: ViewStyle[] = [
styles.progressBar,
progressBarWidthAnimated,
]
if (numerator === denominator) {
progressBarStyles.push({ borderBottomRightRadius: 0 })
}
return (
<View
style={styles.container}
onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
>
<View style={progressBarContainerStyles}>
<Animated.View style={progressBarStyles} />
</View>
<View style={styles.content}>
<Text style={styles.title}>{props.title}</Text>
<Text style={styles.numerator}>
{numerator}
{denominator && (
<Text style={styles.denominator}>{`/${denominator}`}</Text>
)}
</Text>
</View>
</View>
)
}
const borderRadius = Theme.radius
const styles = StyleSheet.create({
/* ... */
})
Намного лучше! Теперь у нас есть забавная и безглючная анимация для прогресс-бара благодаря Reanimated 2.
Окончательный вариант кода можно найти на Github.
Дальнейшее обучение
Reanimated 2 невероятно мощный. Друг React Native School, Адитья Пахилвани, написал фантастическую статью об использовании Reanimated 2 для создания анимированной панели вкладок.
Какая область в вашем приложении могла бы выиграть от тонкой анимации? Сообщите нам об этом в Twitter.