In this guide we'll be creating a liquid/warping/morphing animation where an element - in this case an Avatar - smoothly transitions in and out of the Dynamic Island. It's a nice touch you may have seen before on applications such as Telegram on iOS. Here's a quick demo of what I mean:
To implement the animation we'll make use of these two libraries:
-
react-native-reanimated
: For creating smooth animations and gestures. -
@shopify/react-native-skia
: For implementing more complex 2D graphics and effects.
Prerequisites
First off make sure you've got a project setup. If you need to setup a project npx create-expo-app@latest
is a good choice. In terms of installing the required modules, if using Expo it's as simple as running:
npx expo install react-native-reanimated @shopify/react-native-skia
If you've got another setup (e.g. raw React Native) refer to the setup on the Skia Docs and the Reanimated Docs as there are additional setup instructions.
1. Setting Up the Basic Structure
Once you've got the project setup and running, let's add some starter code. These are the helper functions and constants we'll be using along the way:
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",
},
});
The code also sets up a ScrollView
with placeholder cards for testing the animation. The constants defined at the top (AVATAR_SIZE
, BLUR_HEIGHT
, etc.) control various aspects of the animation.
2. Adding the Avatar
Quick heads up: Throughout the guide I've used numbered markers (e.g. ℹ️ 1. ADDED START
[...]ℹ️ 1. ADDED END
) to highlight the new code being added in each step. This should make it easier to spot the changes as we build up our animation.
Next, let's add the avatar image to the canvas:
// 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...
This code uses Skia's Image
component to render the avatar. The react-native-reanimated
shared values (avatarSize
, avatarX
, avatarY
) will be used to enable smooth animations.
The useDerivedValue
hook is where we'll be putting most of the animation logic. We make use of the interpolate function to map the scroll position (scrollY.value
) to the avatar's size and position. Adjusting the interpolation ranges will change how quickly the avatar shrinks or moves.
3. Adding the Dynamic Island
Next up we need a mock Dynamic Island placed directly behind the Dynamic Island to use as a "center of gravity" and to make the join more fluid:
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>
);
}
This code uses Skia's RoundedRect
component to create a shape mimicking the Dynamic Island. The shape is positioned at the top of the screen. I got the values such as DYNAMIC_ISLAND_WIDTH
and DYNAMIC_ISLAND_HEIGHT
, and the y
positioning through trial and error, feel free to adjust them if your device's Dynamic Island positioning is any different.
4. Adding an Animated Container for the Canvas
To keep the mock Dynamic Island in place during animation, wrap the Canvas in an Animated.View
(replacing the previous View
) and apply some animations with 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>
);
}
This step creates the illusion that the avatar is merging with a fixed point at the top of the screen. The animatedCanvasStyle
uses interpolate to reduce the canvas height and move it upwards as the user scrolls. Adjust the interpolation ranges to change the rate at which the canvas shrinks and moves. At this point you should start to see how this is going to work out, but there's still some effects missing...
5. Adding a Blur Effect
Let's add a blur effect to enhance the transition:
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>
);
}
The blur effect smooths the transition as the avatar approaches the Dynamic Island. It uses Skia's Blur
component, with its intensity animated based on the scroll position. The blurIntensity
interpolation increases as the user scrolls, reaching its maximum at BLUR_HEIGHT
, then decreasing. Adjust these values to change the timing and intensity of the blur effect.
6. Adding a Color Filter
Next up let's use a color matrix filter to create a liquid-like effect:
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>
);
}
This step adds a liquid-like effect to the animation. The color matrix is a 5x4 grid of numbers that transforms the image colors. The last two numbers in the matrix (-8 * (1 - progress)
and 25 * (1 - progress))
are what really create the stretching effect as the avatar moves.
These numbers might seem random and overwhelming at first. To better understand and experiment with color matrices, you can use tools like the color matrix playground at https://fecolormatrix.com.
7. Adding a Black Overlay
To complete the effect, we'll add a black filter to blend the avatar seamlessly into the Dynamic Island:
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>
);
}
As you can see this helps the avatar blend smoothly into the Dynamic Island (which itself is black). Here we're using Skia's Circle
component with its color animated from transparent to black as the user scrolls. Adjust the color values or the interpolation range to change how and when this darkening effect occurs.
Wrapping up
That's it for now! I may create a part 2 which turns this from a starting point into fully production ready code, but in the meantime feel free to tweak values and do other improvements. For example, one thing you can do is add a snap back effect so the animation is never just paused halfway (which looks odd):
onScrollEndDrag={() => {
if (scrollY.value < SNAP_THRESHOLD) {
scrollRef.current?.scrollTo({
y: 0,
animated: true,
});
}
}}
Top comments (1)
Great post, will defo be trying this