Tweag
Technical groups
Dropdown arrow
Open source
Careers
Research
Blog
Contact
Consulting services
Technical groups
Dropdown arrow
Open source
Careers
Research
Blog
Contact
Consulting services

Building an Image Slider in React Native using Skia and Reanimated

4 July 2024 — by Omowunmi Sogunle

Making great animated graphics on mobile apps has always been challenging. While react-native-svg has served React Native developers well for basic vector graphics, it often falls short when it comes to replicating the more complex effects seen in web applications. We’ll be integrating Skia for rendering sharp, efficient 2D graphics and Reanimated for creating fluid, responsive animations. Step-by-step, we’ll explore the setup, key coding techniques, and best practices to implement a dynamic image slider that is both smooth and visually stunning.

Skia is an open source multiplatform 2D graphics engine which supports GPU-accelerated rendering on Android, iOS and others, and can be used in the React Native ecosystem. The @shopify/react-native-skia library brings Skia to the React Native ecosystem.

In this article, we’ll explore how to enhance React Native apps by using @shopify/react-native-skia for advanced graphics. We’ll guide you through creating a high-performance image slider, writing Skia Shader Language (SKSL) for deep graphical control, and integrating smooth animations with react-native-reanimated.

Project setup

To begin, ensure you have Node.js installed, as it’s necessary for running the expo CLI and npm commands. You’ll also need a code editor; Visual Studio Code is recommended for its excellent TypeScript and React Native support.

For this project, we’ll utilize Expo for rapid development, TypeScript for static typing, and leverage the power of @shopify/react-native-skia and react-native-reanimated for creating fluid animations.

Creating and running a new project

Let’s start by creating a new Expo project with TypeScript support. If you’re new to Expo, refer to the Expo documentation for detailed instructions on creating and running Expo projects.

When creating the new app, you’ll be prompted to enter a name. We’ll use “image-transition” for our project. After entering the name, please wait a moment as Expo sets up the project and installs the necessary dependencies. Once that’s complete, we will proceed to install the above-mentioned libraries needed for our application. Open your terminal and run:

npm install @shopify/react-native-skia react-native-reanimated

Implementing basic graphics with Skia

To dip our toes in the water that is Skia, we will update the auto-generated App.tsx file to display a simple red circle using Skia’s powerful rendering capabilities. Below is the complete code you should use. This snippet incorporates @shopify/react-native-skia for drawing and uses React Native components to set up the view.

import { Canvas, Circle } from "@shopify/react-native-skia"
import { StyleSheet, View } from "react-native"

export default function App() {
  return (
    <View style={styles.container}>
      <Canvas style={styles.container}>
        <Circle cx={100} cy={100} r={50} color="red" />
      </Canvas>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
})

If you encounter unexpected errors in the console when running your app, it might be due to mismatched dependency versions. To align all installed dependencies with the version of the Expo SDK you are using, you can run the following command:

npx expo install --fix

The Canvas component is the root of Skia with its own React renderer. Inside the Canvas, we draw a red Circle of radius 50 centered at (100, 100).

If all goes well, a red circle will appear in the top left corner of your screen. Congratulations, you’ve successfully rendered your first 2D shape using react-native-skia! If you don’t see the circle immediately, try restarting the server or reloading the app to ensure everything updates correctly. With that, let’s dive into the core of our project: implementing the image transition logic.

Implementing Image Transition

In its simplest form, our image transition app will smoothly switch between two images, similar to transitions you’ve likely seen on websites. However, instead of opting for conventional transitions, we’ll introduce an effect sourced from the GL Transition gallery. To achieve this effect, we’ll need to translate the GLSL (OpenGL Shading Language) code provided by the GL Transition gallery into SKSL (Skia Shader Language), which is compatible with @shopify/react-native-skia. SKSL serves as the language for defining shaders within the Skia graphics engine, enabling us to replicate awesome effects originally designed for OpenGL. If you’re curious about GLSL or OpenGL, you can find additional resources here.

Fetch demo images

Now, let’s fetch some demo images from Unsplash and integrate them into our app. We’ll start by creating a new hook called useAssets.ts to manage our assets efficiently. This file will be located in the src/ folder of our project.

import { useImage } from "@shopify/react-native-skia"

export const useAssets = () => {
  const image1 = useImage(
    "https://images.unsplash.com/photo-1586023492125-27b2c045efd7"
  )

  const image2 = useImage(
    "https://images.unsplash.com/photo-1606744837616-56c9a5c6a6eb"
  )

  const image3 = useImage(
    "https://images.unsplash.com/photo-1502005229762-cf1b2da7c5d6"
  )

  if (!image1 || !image2 || !image3) {
    return null
  }
  return [image1, image2, image3]
}

The provided code snippet defines a custom hook named useAssets, designed to correctly manage images within our project. The useImage hook provided by @shopify/react-native-skia fetches images from external sources and returns an instance of SkImage, a type representing images that can be used in the Image component of the library. It’s important to note that the useImage hook returns null until the image is fully loaded, so we always need to perform a type check for null before rendering the image. This ensures that we don’t attempt to display the image before it’s available, preventing potential errors or unexpected behavior in our application. Upon successful loading of all images, the hook returns an array containing the loaded images.

Convert GLSL code to SKSL

Next, we’ll head over to the GL Transition gallery to select an effect. For this tutorial, we’ll use the “DirectionalWarp” effect. We have to convert the GLSL code for our chosen effect to SKSL. To do this, we’ll create a new file named transition.ts and add the following code:

import { Skia } from "@shopify/react-native-skia"

export const source = Skia.RuntimeEffect.Make(`
uniform shader image1;
uniform shader image2;

uniform float2 resolution;
uniform float progress;

half4 getFromColor(float2 uv) {
  return image1.eval(uv * resolution);
}

half4 getToColor(float2 uv) {
  return image2.eval(uv * resolution);
}

// Copy & pasted with minimal changes from https://gl-transitions.com/editor/directionalwarp
// Author: pschroen
// License: MIT

const vec2 direction = vec2(-1.0, 1.0);

const float smoothness = 0.5;
const vec2 center = vec2(0.5, 0.5);

vec4 transition (vec2 uv) {
  vec2 v = normalize(direction);
  v /= abs(v.x) + abs(v.y);
  float d = v.x * center.x + v.y * center.y;
  float m = 1.0 - smoothstep(-smoothness, 0.0, v.x * uv.x + v.y * uv.y - (d - 0.5 + progress * (1.0 + smoothness)));
  return mix(getFromColor((uv - 0.5) * (1.0 - m) + 0.5), getToColor((uv - 0.5) * m + 0.5), m);
}
// End of copy and paste

half4 main(vec2 xy) {
  vec2 uv = xy / resolution;
  return transition(uv);
}`)!

if (!source) {
  throw new Error("Couldn't compile the shader")
}

The shader effect is defined using the Skia.RuntimeEffect.Make function. This function takes a GLSL-like shader code as input and compiles it into a shader that can be used within the Skia graphics engine. Uniforms are variables that can be passed into the shader from the calling code. In this shader, we have five uniform shader variables. The image1 and image2 represent the two images being transitioned between. We also have resolution, progress, and direction as uniform variables. However, note that we changed the direction uniform, from the original, to const vec2(-1.0, 1.0), because we will not be passing it from the code.

The transition function defines the actual transition effect. It calculates the color of each pixel in the transitioned image based on the progress of the transition. The main function is the entry point of the shader. It takes a 2D coordinate xy and returns the color of the pixel at that coordinate. It computes the uv coordinates (normalized screen coordinates) from the input coordinates and passes them to the transition function.

Render image assets

Next, let’s update the App.tsx file to render the (for now, static) assets using the ShaderImage component.

import { Canvas, Fill, ImageShader, Shader } from "@shopify/react-native-skia"
import { Dimensions, StyleSheet, View } from "react-native"
import { useAssets } from "./useAssets"
import { source } from "./transition"

export default function App() {
  const assets = useAssets()
  const { width, height } = Dimensions.get("window")

  if (!assets) {
    return null
  }
  return (
    <View style={styles.container}>
      <Canvas style={styles.container}>
        <Fill>
          <Shader
            source={source}
            uniforms={{ progress: 1, resolution: [width, height] }}
          >
            <ImageShader
              image={assets[0]}
              fit="cover"
              width={width}
              height={height}
            />
            <ImageShader
              image={assets[1]}
              fit="cover"
              width={width}
              height={height}
            />
          </Shader>
        </Fill>
      </Canvas>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
})

We can then restart the server to see the changes. We should now be able to see the image full screen.

Inside the Canvas, a shader is used for the transition effect. The shader source is imported from transition.ts, and progress and resolution uniforms control the transition progress and screen resolution. An ImageShader component renders the first image (assets[0]) to cover the entire canvas.

Add transition buttons

To enable switching between images, let’s add the code for transition buttons before the Canvas component. We’ll also import the required dependencies. Here’s how you can update the App.tsx file:

import { Canvas, Fill, ImageShader, Shader } from "@shopify/react-native-skia"
import { Dimensions, StyleSheet, View, Pressable, Alert } from "react-native"
import { AntDesign } from "@expo/vector-icons"
import { useAssets } from "./useAssets"
import { source } from "./transition"

export default function App() {
  // ...
  return (
    <View style={styles.container}>
      <View style={styles.buttons}>
        <Pressable
          onPress={() => Alert.alert("Previous button")}
          style={styles.button}
        >
          <AntDesign name="left" size={24} color="black" />
        </Pressable>
        <Pressable
          onPress={() => Alert.alert("Next button")}
          style={styles.button}
        >
          <AntDesign name="right" size={24} color="black" />
        </Pressable>
      </View>
      <Canvas style={styles.container}>// ...</Canvas>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  button: {
    backgroundColor: "white",
    alignItems: "center",
    justifyContent: "center",
    padding: 10,
    borderRadius: 16,
  },
  buttons: {
    position: "absolute",
    bottom: 80,
    right: 40,
    zIndex: 10,
    flexDirection: "row",
    gap: 16,
  },
})

// ... is a placeholder for otherwise repeated code from previous listings. Reload the app and the previous and next buttons should now be visible at the bottom right corner of the screen. Clicking one one of the buttons will show an alert, but we will change that later.

Implement image transition logic and controls

Next, we’ll implement the logic to handle the transition between images. @shopify/react-native-skia seamlessly integrates with react-native-reanimated (which updates the UI thread), allowing for smooth and efficient animations and transitions. Let’s define four variables that will be needed for implementing the transition logic:

import {
  runOnJS,
  useDerivedValue,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useCallback } from "react"
const offset = useSharedValue(0)
const progressPrev = useSharedValue(1)
const progressNext = useSharedValue(0)
const isNext = useSharedValue(false)
const [buttonDisabled, setButtonDisabled] = useState(false)

The offset variable tracks the index of the current image relative to the assets array. progressPrev and progressNext serve as cursors that toggle between 0 and 1, indicating the progress of the transition. These values are passed to the SKSL shader as uniforms. isNext is a boolean variable that becomes true when the “next” button is clicked and false when the “previous” button is clicked. This variable controls the direction of the transition. Shared values in React Native Reanimated are mutable values that can be accessed and modified from multiple components. It is important to note that updates made to shared values can trigger corresponding code execution on the UI thread.

The buttonDisabled state variable disables the “next” and “previous” buttons to prevent multiple simultaneous transitions and ensure a smooth user experience.

With these variables, we’ll now implement the actions that occur when the “next” and “previous” buttons are clicked.

const updateNext = useCallback((param: boolean) => {
  isNext.value = param
}, [])

const next = useCallback(() => {
  offset.value += 1
  progressNext.value = 0
  setButtonDisabled(false)
}, [])

const prev = useCallback(() => {
  offset.value -= 1
  progressPrev.value = 1
  setButtonDisabled(false)
}, [])

const handleClickNext = () => {
  setButtonDisabled(true)
  updateNext(true)

  progressNext.value = withTiming(
    1,
    {
      duration: 1000,
    },
    () => {
      runOnJS(next)()
    }
  )
}

const handleClickPrevious = () => {
  setButtonDisabled(true)
  runOnJS(updateNext)(false)
  progressPrev.value = withTiming(
    0,
    {
      duration: 1000,
    },
    () => {
      runOnJS(prev)()
    }
  )
}

What do these introduced functions do?

  • The updateNext() function updates the isNext variable, indicating whether the transition is moving to the next image (true) or the previous image (false).
  • The next / prev functions increment / decrement the offset variable by 1 and reset the progressNext variable to 0 / 1. It represents the action taken when transitioning to the next / previous image.
  • The handleClickNext / handleClickPrevious functions are called when the next / previous buttons are clicked. It first updates isNext to true / false using the updateNext function. Then, it animates the progressPrev variable from its current value to 0 using the withTiming function from React Native Reanimated. Once the animation completes, it triggers the prev function to update the offset and progress for the previous image.

In React Native Reanimated, operations modifying shared values (such as the updateNext function that modifies isNext) must run on the native UI thread for thread safety and performance.

The runOnJS function is used to call a JavaScript function from within a worklet. Since worklets execute on the native UI thread, they cannot directly call JavaScript functions, as those functions run on the JavaScript thread. Instead, runOnJS is used to bridge between the native UI thread and the JavaScript thread by executing the specified JavaScript function on the JavaScript thread.

Compute required uniform variables and assets

Next, we’ll compute the uniform variables and assets required for our canvas.

const getAsset = useCallback(
  (index: number) => {
    "worklet"
    if (assets === null) {
      return null
    }
    return assets[((index % assets.length) + assets.length) % assets.length]
  },
  [assets]
)
const progress = useDerivedValue(() => {
  return isNext.value ? progressNext.value : progressPrev.value
})
const uniform = useDerivedValue(() => {
  return {
    progress: progress.value,
    resolution: [width, height],
  }
})
const image1 = useDerivedValue(() => {
  return getAsset(isNext.value ? offset.value : offset.value - 1)
})
const image2 = useDerivedValue(() => {
  return getAsset(isNext.value ? offset.value + 1 : offset.value)
})

Here’s what these functions and variables do:

  • The getAsset() retrieves the asset (image) at a given index. It’s marked as a "worklet" to ensure it runs on the UI thread. Without the "worklet" directive, modifying shared values from a JavaScript function (running on the JavaScript thread) can cause synchronization issues and race conditions, as the native UI thread manages shared values. If assets is null, indicating that assets haven’t been loaded yet, it returns null. Otherwise, it calculates the correct index to access the asset from the assets array based on the current offset value and returns the corresponding asset.
  • The progress variable calculates the progress of the transition based on whether the transition is moving to the next image (isNext.value is true) or the previous image (isNext.value is false). If isNext.value is true, it returns progressNext.value; otherwise, it returns progressPrev.value.
  • The uniform variable computes the uniform variables required for the shader. It includes the progress value and the resolution of the canvas (width and height).
  • image1 and image2 variables determine which images to display in the shader based on the transition direction (isNext.value) and the current offset.

progress, uniform and image1 / image2 are derived values created Reanimated’s useDerivedValue because because they dynamically update based on other state or animated values, ensuring the shader and images reflect the latest changes efficiently.

With these variables and assets, we can now go ahead and replace the temporary placeholders introduced earlier with their real values. Specifically,

  • replace the Alert.alert event handlers in the buttons’ onPress property with the handleClickPrevious / handleClickNext functions,
  • replace the static assets[0] / assets[1] values in the ImageShader properties with the image1 / image2 variables,
  • replace the hard-coded uniforms in the Shader component with the uniform variable.

Putting it all together

Almost done! All we need to do now is to combine all the above code snippets into a final App.tsx, which should look like this Expo snack. The code is also available on GitHub.

Let’s run our app to see the results. If the app is already running, restart the server or reload the app to see the transitions. If everything works correctly, we should see a smooth transition between all three images in our assets file, like below:

Image Transition

Summary

In this blog post, we discovered how to elevate a React Native app’s visual experience using Skia and Reanimated. Through seamless integration, Skia empowers developers to create captivating image sliders and animations, while Reanimated provides powerful tools for implementing complex transition logic with smooth, native-like performance.

Thank you for reading!

About the author

Omowunmi Sogunle

If you enjoyed this article, you might be interested in joining the Tweag team.

This article is licensed under a Creative Commons Attribution 4.0 International license.

Company

AboutOpen SourceCareersContact Us

Connect with us

© 2024 Modus Create, LLC

Privacy PolicySitemap