React Native Skia: Динамическая плавная островная анимация
В этом проекте мы займемся созданием анимации текучести / деформации / морфинга элемента. В нашем пример элементом будет выступать аватар, который плавно будет переходить в островок и обратно. Возможно, таким эффектом анимации вы уже сталкивались при использовании приложения Telegram на iOS. Продемонстрируем эффект, к которому мы будем стремится.
Для достижения эффекта анимации, мы воспользуемся двумя библиотеками:
react-native-reanimated
: позволит создать плавную анимацию и движение;@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,
});
}
}}