DEV Community

Cover image for Dynamic Island Liquid Animation with React Native Skia
Will
Will

Posted on

Dynamic Island Liquid Animation with React Native Skia

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:

Full animation demo

To implement the animation we'll make use of these two libraries:

  1. react-native-reanimated: For creating smooth animations and gestures.
  2. @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
Enter fullscreen mode Exit fullscreen mode

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",
  },
});
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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.

Avatar demo

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Dynamic island

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Animated 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Blur

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Color filter

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Black overlay

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,
    });
  }
}}
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
kari_codes profile image
KariCodes

Great post, will defo be trying this