DevGang
Авторизоваться

React Native Skia: Динамическая плавная островная анимация

В этом проекте мы займемся созданием анимации текучести / деформации / морфинга элемента. В нашем пример элементом будет выступать аватар, который плавно будет переходить в островок и обратно. Возможно, таким эффектом анимации вы уже сталкивались при использовании приложения Telegram на iOS. Продемонстрируем эффект, к которому мы будем стремится.

Для достижения эффекта анимации, мы воспользуемся двумя библиотеками:

  1. react-native-reanimated: позволит создать плавную анимацию и движение;
  2. @shopify/react-native-skia: позволит создать сложную 2D-графику и эффекты.

Начало работы

Самым первым шагом нам необходимо проверить настройки проекта. При отсутствии настроек нам поможет в этом команда npx create-expo-app@latest. Чтобы настроить необходимые модули, в нашем проекте это Expo, то запускаем его набирая код:

npx expo install react-native-reanimated @shopify/react-native-skia

Если вы используете другие настройки, вы можете обратиться к настройкам Skia Docs и Reanimated Docs, в которых также предоставлена дополнительная инструкция по настройке.

После настройки и запуска проекта, нужно добавить стартовый код. В котором пропишем вспомогательные функции и константы, используемые нами в проекте.

import React, { useRef } from "react";
import {
  Text,
  View,
  StyleSheet,
  ScrollView,
  NativeScrollEvent,
  useWindowDimensions,
  NativeSyntheticEvent,
} from "react-native";
import Animated, {
  withSpring,
  interpolate,
  useSharedValue,
  useDerivedValue,
  useAnimatedStyle,
  interpolateColor,
} from "react-native-reanimated";
import {
  Blur,
  rect,
  rrect,
  Paint,
  Group,
  Image,
  Canvas,
  Circle,
  useImage,
  Extrapolate,
  ColorMatrix,
  RoundedRect,
} from "@shopify/react-native-skia";

// Constants
const AVATAR_SIZE = 128;
const BLUR_HEIGHT = 30;
const DYNAMIC_ISLAND_HEIGHT = 28;
const DYNAMIC_ISLAND_WIDTH = 110;
const MAX_SCROLL_Y = 70;
const SNAP_THRESHOLD = MAX_SCROLL_Y / 2;
const CANVAS_HEIGHT = 220;
const AVATAR_IMAGE_URL =
  "https://png.pngtree.com/thumb_back/fh260/background/20230612/pngtree-man-wearing-glasses-is-wearing-colorful-background-image_2905240.jpg";

export default function Demo() {
  const scrollRef = useRef<ScrollView>(null);

  return (
    <View style={styles.container}>
      <ScrollView ref={scrollRef} scrollEventThrottle={16} bounces={false}>
        <View style={{ flex: 1, height: CANVAS_HEIGHT }}>
          <Canvas style={styles.canvas}>
            {/* We'll add content in here... */}
          </Canvas>
        </View>


        {Array.from({ length: 30 }).map((_, index) => (
          <View style={styles.card} key={index}>
            <Text style={styles.text}>{index + 1}</Text>
          </View>
        ))}
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#DDD",
  },
  canvas: {
    flex: 1,
  },
  card: {
    height: 40,
    borderRadius: 8,
    marginBottom: 16,
    marginHorizontal: 16,
    fontWeight: "bold",
    alignItems: "center",
    justifyContent: "center",
    backgroundColor: "#222",
  },
  text: {
    color: "#FFF",
    fontWeight: "bold",
  },
});

Код устанавливает ScrollView с карточками наполнителями для тестирования анимации. Различными аспектами анимации управляют константы, определенные в верхней части (AVATAR_SIZE, BLUR_HEIGHT и т.д. )

Добавляем аватар

на протяжении всей статьи будут использоваться пронумерованные маркеры (ℹ️ 1. ADDED START[...], ℹ️ 1. ADDED END), для выделения нового кода, добавленного на каждом шаге. Так будет легче обнаруживать изменения.в коде по мере создания анимации.

Добавим изображение аватара в холст:

// Imports and constants remain the same...

export default function Demo() {
  const scrollRef = useRef<ScrollView>(null);

  // ℹ️ 1. ADDED START
  const avatarImage = useImage(AVATAR_IMAGE_URL);
  const { width: windowWidth } = useWindowDimensions();

  // Shared values for animations
  const avatarSize = useSharedValue(AVATAR_SIZE);
  const avatarX = useSharedValue((windowWidth - AVATAR_SIZE) / 2);
  const avatarY = useSharedValue(MAX_SCROLL_Y);
  const scrollY = useSharedValue(0);

  const avatarRect = useDerivedValue(() =>
    rrect(
      rect(avatarX.value, avatarY.value, avatarSize.value, avatarSize.value),
      avatarSize.value / 2,
      avatarSize.value / 2
    )
  );

  useDerivedValue(() => {
    // Interpolate values based on scroll position
    avatarSize.value = interpolate(
      scrollY.value,
      [0, MAX_SCROLL_Y / 2],
      [AVATAR_SIZE, 0]
    );
    avatarX.value = (windowWidth - avatarSize.value) / 2;
    avatarY.value = interpolate(
      scrollY.value,
      [0, MAX_SCROLL_Y],
      [MAX_SCROLL_Y, 0]
    );
  });

  function handleScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
    scrollY.value = event.nativeEvent.contentOffset.y;
  }
  // ℹ️ 1. ADDED END

  return (
    <View style={styles.container}>
      <ScrollView
        ref={scrollRef}
        // ℹ️ 2. ADDED START
        onScroll={handleScroll}
        // ℹ️ 2. ADDED END
        scrollEventThrottle={16}
        bounces={false}
      >
        <View style={{ flex: 1, height: CANVAS_HEIGHT }}>
          <Canvas style={styles.canvas}>
            {/* ℹ️ 3. ADDED START */}
            <Group clip={avatarRect}>
              <Image
                image={avatarImage}
                height={avatarSize}
                width={avatarSize}
                fit="cover"
                x={avatarX}
                y={avatarY}
              />
            </Group>
            {/* ℹ️ 3. ADDED END */}
          </Canvas>
        </View>

        {Array.from({ length: 30 }).map((_, index) => (
          <View style={styles.card} key={index}>
            <Text style={styles.text}>{index + 1}</Text>
          </View>
        ))}
      </ScrollView>
    </View>
  );
}

// Styles remain the same...

В коде используется компонент Skia Image для рендеринга аватара. Общие значения react-native-reanimated (avatarSize, avatarX, avatarY) будут использоваться для включения плавной анимации.

Большую часть логики анимации будут помещены в хук useDerivedValue.Мы используем функцию interpolate для сопоставления положения прокрутки (scrollY.value) с размером и положением аватара. Настройка диапазонов интерполяции изменит скорость уменьшения или перемещения аватара.

Включаем в проект динамический остров

Далее нам будет необходим макет динамического острова, расположенного непосредственно за Dynamic Island, для использования его в качестве «центра тяжести» и сделать соединение более плавным:

export default function Demo() {
  // ...Code remains the same...

  return (
    <View style={styles.container}>
      <ScrollView
        ref={scrollRef}
        onScroll={handleScroll}
        scrollEventThrottle={16}
        bounces={false}
      >
        <View style={{ flex: 1, height: CANVAS_HEIGHT }}>
          <Canvas style={styles.canvas}>
            <Group clip={avatarRect}>
              <Image
                image={avatarImage}
                height={avatarSize}
                width={avatarSize}
                fit="cover"
                x={avatarX}
                y={avatarY}
              />
            </Group>

            {/* ℹ️ 1. ADDED START */}
            <RoundedRect
              r={28}
              width={DYNAMIC_ISLAND_WIDTH}
              height={DYNAMIC_ISLAND_HEIGHT}
              x={(windowWidth - DYNAMIC_ISLAND_WIDTH) / 2}
              y={18}
              />
            {/* ℹ️ 2. ADDED END */}
          </Canvas>
        </View>
        {/* ...code remains the same... */}
      </ScrollView>
    </View>
  );
}

В коде для получения создания формы, имитирующей динамический остров, использует компонент Skia RoundedRect. Мы получили такие значения, как DYNAMIC_ISLAND_WIDTH и DYNAMIC_ISLAND_HEIGHT, а также позиционирование по оси Y методом проб и ошибок. Вы сами  сможете свободно корректировать их, если позиционирование Dynamic Island вашего устройства отличается.

Разрабатываем контейнер для холста

Чтобы макет динамического острова оставался на месте при движении, обернем Canvas в   Animated.View (заменив предыдущий View) и применим несколько анимаций с помощью useAnimatedStyle:

export default function Demo() {
  // ...Code remains the same...

  const avatarRect = useDerivedValue(() =>
    // ...Code remains the same...
  );

  // ℹ️ 1. ADDED START
  const animatedCanvasStyle = useAnimatedStyle(() => ({
    height: interpolate(scrollY.value, [0, MAX_SCROLL_Y], [CANVAS_HEIGHT, 0]),
    transform: [
      {
        translateY: interpolate(
          scrollY.value,
          [0, MAX_SCROLL_Y],
          [0, MAX_SCROLL_Y]
        ),
      },
    ],
  }));
  // ℹ️ 1. ADDED END

  // ...Code remains the same...

  return (
    <View style={styles.container}>
      <ScrollView
        ref={scrollRef}
        onScroll={handleScroll}
        scrollEventThrottle={16}
        bounces={false}
      >
        {/* ℹ️ 1. REPLACE START */}
        <Animated.View style={animatedCanvasStyle}>
        {/* ℹ️ 1. REPLACE END */}
          <Canvas style={styles.canvas}>
            {/* ...code remains the same... */}
          </Canvas>
        {/* ℹ️ 2. REPLACE START */}
        </Animated.View>
        {/* ℹ️ 2. REPLACE END */}

        {/* ...code remains the same... */}
      </ScrollView>
    </View>
  );
}

Этот шаг позволяет аватару «‎прилипнуть»‎ к верхней части экрана, словно он сливается с ней. Для этого мы используем animatedCanvasStyle, который плавно изменяет высоту холста и поднимает его вверх по мере прокрутки. Вы можете настроить скорость этого движения, изменяя значения интерполяции. 

Добавляем размытие для идеального перехода

Чтобы сделать переход еще более плавным и естественным, добавим эффект размытия.

export default function Demo() {
  // ...code remains the same...

  // ℹ️ 1. ADDED START
  const blurIntensity = useSharedValue(0);
  // ℹ️ 1. ADDED END

  // ...code remains the same...

  useDerivedValue(() => {
    // Interpolate values based on scroll position
    // ...code remains the same...
    // ℹ️ 2. ADDED START
    blurIntensity.value = interpolate(
      scrollY.value,
      [0, BLUR_HEIGHT, 35],
      [0, 12, 0]
    );
    // ℹ️ 2. ADDED END
  });

  // ...code remains the same...

  return (
    <View style={styles.container}>
      <ScrollView
        ref={scrollRef}
        onScroll={handleScroll}
        scrollEventThrottle={16}
        bounces={false}
      >
        <Animated.View style={animatedCanvasStyle}>
          <Canvas style={styles.canvas}>
            {/* ℹ️ 1. ADDED START */}
            <Group
              layer={
                <Paint>
                  <Blur blur={blurIntensity} />
                </Paint>
              }
            >
              {/* ℹ️ 1. ADDED END */}
              <Group clip={avatarRect}>
                <Image
                  image={avatarImage}
                  height={avatarSize}
                  width={avatarSize}
                  fit="cover"
                  x={avatarX}
                  y={avatarY}
                />
              </Group>

              <RoundedRect
                r={28}
                width={DYNAMIC_ISLAND_WIDTH}
                height={DYNAMIC_ISLAND_HEIGHT}
                x={(windowWidth - DYNAMIC_ISLAND_WIDTH) / 2}
                y={18}
              />
            {/* ℹ️ 2. ADDED START */}
            </Group>
            {/* ℹ️ 2. ADDED END */}
          </Canvas>
        </Animated.View>
        {/* ...code remains the same... */}
      </ScrollView>
    </View>
  );
}

Эффект размытия сглаживает переход, когда аватар приближается к Динамичному острову. Он использует компонент Skia Blur, интенсивность которого анимирована на основе положения прокрутки. Интерполяция blurIntensity увеличивается по мере прокрутки пользователем, достигая максимума при BLUR_HEIGHT, а затем уменьшаясь. Отрегулируйте эти значения, чтобы изменить время и интенсивность эффекта размытия.

Устанавливаем цветные фильтры

Далее давайте используем фильтр цветовой матрицы, чтобы создать эффект плавности:

export default function Demo() {
  // ...code remains the same...

  const colorMatrix = useDerivedValue(() => {
    const progress = interpolate(scrollY.value, [0, MAX_SCROLL_Y], [0, 1], {
      extrapolateRight: Extrapolate.CLAMP,
    });

    return [
      1, 0, 0, 0, 0,
      0, 1, 0, 0, 0,
      0, 0, 1, 0, 0,
      0, 0, 0, 25 * (1 - progress), -8 * (1 - progress),
    ];
  });

  // ...code remains the same...

  return (
    <View style={styles.container}>
      <ScrollView
        ref={scrollRef}
        onScroll={handleScroll}
        scrollEventThrottle={16}
        bounces={false}
      >
        <Animated.View style={animatedCanvasStyle}>
          <Canvas style={styles.canvas}>
            <Group
              layer={
                <Paint>
                  <Blur blur={blurIntensity} />
                  {/* ℹ️ 1. ADDED START */}
                  <ColorMatrix matrix={colorMatrix.value} />
                  {/* ℹ️ 1. ADDED END */}
                </Paint>
              }
            >
              {/* ...code remains the same... */}
            </Group>
          </Canvas>
        </Animated.View>
        {/* ...code remains the same... */}
      </ScrollView>
    </View>
  );
}

Этот шаг добавляет плавный эффект к анимации. Цветовая матрица представляет собой сетку 5x4 чисел, которая преобразует цвета изображения. Последние два числа в матрице (-8 * (1 - progress)и 25 * (1 - progress))– это то, что действительно создает эффект растяжения при движении аватара.

Эти числа могут показаться случайными и ошеломляющими на первый взгляд. Чтобы лучше понять и поэкспериментировать с цветовыми матрицами, вы можете использовать такие инструменты, как игровая площадка цветовых матриц на https://fecolormatrix.com .

Используем черный фильтр

Для завершения эффекта мы добавим черный фильтр, чтобы аватар плавно вписался в динамический остров:

export default function Demo() {
  // ...code remains the same...

  const overlayColor = useSharedValue("transparent");

  useDerivedValue(() => {
    // ...code remains the same...
    overlayColor.value = interpolateColor(
      scrollY.value,
      [0, BLUR_HEIGHT],
      ["transparent", "#000"]
    );
  });

  return (
    <View style={styles.container}>
      <ScrollView
        ref={scrollRef}
        onScroll={handleScroll}
        scrollEventThrottle={16}
        bounces={false}
      >
        <Animated.View style={animatedCanvasStyle}>
          <Canvas style={styles.canvas}>
            <Group
              {/* ...code remains the same... */}
            >
              <Group clip={avatarRect}>
                <Image
                  image={avatarImage}
                  height={avatarSize}
                  width={avatarSize}
                  fit="cover"
                  x={avatarX}
                  y={avatarY}
                />
                {/* ℹ️ 1. ADDED START */}
                <Circle
                  r={avatarSize}
                  cx={avatarX.value + avatarSize.value / 2}
                  cy={avatarY.value + avatarSize.value / 2}
                  color={overlayColor}
                />
                {/* ℹ️ 1. ADDED END */}
              </Group>
              <RoundedRect
                {/* ...code remains the same... */}
              />
            </Group>
          </Canvas>
        </Animated.View>
        {/* ...code remains the same... */}
      </ScrollView>
    </View>
  );
}

Как вы видите, это помогает аватару плавно вписаться в Dynamic Island (который сам по себе черный). Здесь мы используем Circle компонент Skia с его анимированным цветом от прозрачного до черного по мере прокрутки пользователем. Отрегулируйте значения цвета или диапазон интерполяции, чтобы изменить, как и когда происходит этот эффект затемнения.

Завершение

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

onScrollEndDrag={() => {
  if (scrollY.value < SNAP_THRESHOLD) {
    scrollRef.current?.scrollTo({
      y: 0,
      animated: true,
    });
  }
}}
#JavaScript #React
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу