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 theisNext
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 theprogressNext
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 updatesisNext
totrue
/false
using theupdateNext
function. Then, it animates theprogressPrev
variable from its current value to 0 using thewithTiming
function from React Native Reanimated. Once the animation completes, it triggers theprev
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. Ifassets
isnull
, indicating that assets haven’t been loaded yet, it returnsnull
. 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
istrue
) or the previous image (isNext.value
isfalse
). IfisNext.value
istrue
, it returnsprogressNext.value
; otherwise, it returnsprogressPrev.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
andimage2
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 thehandleClickPrevious
/handleClickNext
functions, - replace the static
assets[0]
/assets[1]
values in theImageShader
properties with theimage1
/image2
variables, - replace the hard-coded
uniform
s in theShader
component with theuniform
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:
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
If you enjoyed this article, you might be interested in joining the Tweag team.