Анимированный прогресс-бар с помощью Reanimated 2

Анимация может придать много лоска вашему приложению, но может быть сложной в исполнении. 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({
  /* ... */
})
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы видим некоторые анимации! Но есть несколько проблем:

  1. Если полоска заполнена на 100%, то из-за анимации пружины она может переполниться за пределы карты.
  2. Если значение установлено на 0 после того, как оно было равно 8 (например, когда вы перезагружаете игру), это приводит к глюкам: полоса заполняется/не заполняется несколько раз.
  3. Если полоса близка к полной (например, 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.

Оцените статью
devanswers.ru
Добавить комментарий