- 1. Overview
- 2. Get started
- 3. Start from scratch
- 3.1. Prerequisites
- 3.2. Initialize a new Expo app
- 3.3. Download assets
- 3.4. Run reset-project script
- 3.5. Run the app on mobile and web
- 3.6. Make your first change
- 3.7. Add navigation
- 3.8. Build a screen
- 3.9. Use an image picker
- 3.10. Create a modal
- 3.11. Add gestures
- 3.12. Development build using EAS
- 3.12.1. Understanding development builds
- 3.12.2. Install expo-dev-client library
- 3.12.3. Initialize a development build
- 3.12.4. Configure project for EAS Build
- 3.12.5. Create a build for Android
- 3.12.6. Install and run the build on Android device
- 3.12.7. Install and run the build on Android Emulator
- 3.13. Back to the app: Take a screenshot
This is a React Native with Expo project that runs on Android, iOS, and web; all with a single codebase.
It covers the following topics:
- Create an app using the default template with TypeScript enabled
- Implement a two-screen bottom tabs layout with Expo Router
- Break down the app layout and implement it with flexbox
- Use each platform's system UI to select an image from the media library
- Create a sticker modal using the
<Modal>and<FlatList>components from React Native - Add touch gestures to interact with a sticker
- Use third-party libraries to capture a screenshot and save it to the disk
- Handle platform differences between Android, iOS, and web
- Finally, go through the process of configuring a status bar, a splash screen, and an icon to complete the app
-
Install dependencies
npm install
-
Start the app
npx expo start
In case, you might run:
npx expo start --tunnelIn the output, you'll find options to open the app in a
- development build
- Android emulator
- iOS simulator
- Expo Go, a limited sandbox for trying out app development with Expo
- Install Expo Go on a physical Android or iOS device.
- Install Node.js (LTS version) on your machine.
- VS Code or any code editor
- A macOS, Linux, or Windows (PowerShell and WSL2) with a terminal window open.
- Familiarity with TypeScript and React.
Run create-expo-app to initialize a new Expo app. This will create a new React Native project:
npx create-expo-app@latest StickerSmash
cd StickerSmashDuring the installation process, CLI will prompt your to choose a template. Select SDK 57.
This command will create a new project directory named StickerSmash, using the default template.
Benefits of using default template:
- Creates a new React Native project with expo package installed
- Includes recommended tools such as Expo CLI
- Includes a tab navigator from Expo Router to provide a basic navigation system
- Automatically configured to run a project on multiple platforms: Android, iOS, and web
- TypeScript configured by default
-
Download assets archive from this link
-
Unzip the archive and replace the default assets in the
your-project-name/assets/imagesdirectory. -
Open the project directory in a code editor or IDE.
Let's run the reset-project script to remove the boilerplate code:
npm run reset-projectreset-project script resets the src/app directory structure and moves the previous boilerplate files from the src directory to another sub-directory called example. We can delete it.
In the project directory, run the following command to start the development server:
npx expo startAfter running the above command:
- The development server will start, and you'll see a QR code inside the terminal window.
- Scan that QR code to open the app on the device. On Android, use the
Expo Go > Scan QRcode option. On iOS, use the default camera app. - To run the web app, press
Win the terminal. It will open the web app in the default web browser.
Once it is running on all platforms, the app should look like this:
Tip
Make sure you are on the same Wi-Fi network on your computer and your device. If it still doesn't work, it may be due to the router configuration — this is common for public networks.
You can choose the Tunnel connection type when starting the development server, then scanning the QR code again.
npx expo start --tunnelUsing the Tunnel connection type will make the app reloads considerably slower than on LAN or Local, so it's best to avoid tunnel when possible. You may want to install and use an emulator or simulator to speed up development if Tunnel is required to access your machine from another device on your network.
The src/app/index.tsx file is the entry point of our app and executes when the development server starts. It uses core React Native components such as <View> and <Text> to display background and text.
Styles applied to these components use JavaScript objects rather than CSS. Most React Native components accept a style prop that accepts a JavaScript object as its value.
Let's modify src/app/index.tsx screen:
- Add a
styles.container.backgroundColorproperty to<View>with the value of#25292eto change the background color. - Replace the default value of
<Text>with "Home screen". - Add a
styles.text.colorproperty to<Text>with the value of#fff(white) to change the text color.
src/app/index.tsx
import { Text, View, StyleSheet } from "react-native"
export default function Index() {
return (
<View style={styles.container}>
<Text style={styles.text}>Home screen</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#25292e", // new
},
// new
text: {
color: "#fff",
},
})Once you save your changes, they're applied to the running apps connected to the development server:
Commit changes.
In this section, we'll see Expo Router's fundamentals to create stack navigation and a bottom tab bar with two tabs.
Expo Router is a file-based routing framework. To get started, we need to know about the following conventions:
appdirectory: A special directory containing only routes and their layouts. Any files added to this directory become a screen inside our native app and a page on the web. In the default template, it is located atsrc/app.- Root layout: The
src/app/_layout.tsxfile. It defines shared UI elements such as headers and tab bars so they are consistent between different routes. - File name conventions: Index file names, such as
index.tsxfile in thesrc/appdirectory matches/route.
create a new file named about.tsx inside the src/app directory. It displays the screen name when the user navigates to the /about route.
import { Text, View, StyleSheet } from "react-native"
export default function AboutScreen() {
return (
<View style={styles.container}>
<Text style={styles.text}>About screen</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#25292e",
justifyContent: "center",
alignItems: "center",
},
text: {
color: "#fff",
},
})Then, inside src/app/_layout.tsx:
- Add a
<Stack.Screen />component and anoptionsprop to update the title of the/aboutroute. - Update the
/indexroute's title toHomeby addingoptionsprop.
src/app/_layout.tsx
import { Stack } from "expo-router"
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Home" }} />
<Stack.Screen name="about" options={{ title: "About" }} />
</Stack>
)
}A stack navigator is the foundation for navigating between different screens in an app. On Android, a stacked route animates on top of the current screen. On iOS, a stacked route animates from the right.
We'll use Expo Router's Link component to navigate from the /index route to the /about route.
src/app/index.tsx
import { Text, View, StyleSheet } from "react-native"
import { Link } from "expo-router"
export default function Index() {
return (
<View style={styles.container}>
<Text style={styles.text}>Home screen</Text>
<Link href="/about" style={styles.button}>
Go to About screen
</Link>
</View>
)
}
const styles = StyleSheet.create({
// ...
button: {
fontSize: 20,
textDecorationLine: "underline",
color: "#fff",
},
})Take a look at the changes in our app. Click on Link to navigate to the /about route:
When a route doesn't exist, we can use a +not-found route to display a custom fallback screen. Expo Router uses a special +not-found.tsx file to handle this case.
Create a new file named +not-found.tsx inside the src/app directory to add the NotFoundScreen component.
Add options prop from the Stack.Screen to display a custom screen title for this route.
Add a Link component to navigate to the / route.
src/app/+not-found.tsx
import { Link, Stack } from "expo-router"
import { StyleSheet, View } from "react-native"
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops! Not Found" }} />
<View style={styles.container}>
<Link href={"/"} style={styles.button}>
Go back to Home screen!
</Link>
</View>
</>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#25292e",
},
button: {
fontSize: 20,
textDecorationLine: "underline",
color: "#fff",
},
})To test this, navigate to http://localhost:8081/123 URL in the web browser since it is easy to change the URL path there.
We'll add a bottom tab navigator to our app.
- Inside the
src/appdirectory, add a(tabs)subdirectory. This special directory is used to group routes together and display them in a bottom tab bar. - Create a
(tabs)/_layout.tsxfile inside the directory. It will be used to define the tab layout, which is separate from Root layout. - Move the existing
index.tsxandabout.tsxfiles inside the(tabs)directory. The structure ofsrc/appdirectory will look like this:
src/app
├── _layout.tsx
├── +not-found.tsx
└── (tabs)
├── _layout.tsx
├── index.tsx
└── about.tsx
Update the Root layout file to add a (tabs) route:
src/app/_layout.tsx
import { Stack } from "expo-router"
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
)
}Inside (tabs)/_layout.tsx, add a Tabs component to define the bottom tab layout:
src/app/(tabs)/_layout.tsx
import { Tabs } from "expo-router"
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: "Home" }} />
<Tabs.Screen name="about" options={{ title: "About" }} />
</Tabs>
)
}Let's take a look at our app now to see the new bottom tabs:
Warning
Do not use @expo/vector-icons as it will be deprecated. Rather, use @react-native-vector-icons/*. The old @react-native-vector-icons is also deprecated. Now the correct way to use them is to install per icon family
For now, we will install ionicons.
To install it, stop the development server by pressing Ctrl + C in the terminal, then run the following command:
npx expo install @react-native-vector-icons/ioniconsNote
We use npx expo install instead of npm install when we use expo framework/cli.
After the installation completes, start the development server again by running npx expo start or in case, npx expo start --tunnel.
Right now, the tab bar or header doesn't display a custom icon, and the bottom tab background color doesn't match the app's background color.
Modify the src/app/(tabs)/_layout.tsx file to add tab bar icons:
- Import
{Ionicons}icons set from@react-native-vector-icons/ionicons. - Add the
tabBarIconto both theindexandaboutroutes. This function takesfocusedandcoloras params and renders the icon component. From the icon set, we can provide custom icon names. - Add
screenOptions.tabBarActiveTintColorto theTabscomponent and set its value to#ffd33d. This will change the color of the tab bar icon and label when active.
src/app/(tabs)/_layout.tsx
import { Tabs } from "expo-router"
import { Ionicons } from "@react-native-vector-icons/ionicons" //new
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: "#ffd33d",
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? "home-sharp" : "home-outline"}
color={color}
size={24}
/>
),
}}
/>
<Tabs.Screen
name="about"
options={{
title: "About",
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={
focused ? "information-circle" : "information-circle-outline"
}
color={color}
size={24}
/>
),
}}
/>
</Tabs>
)
}Let's also change the background color of the tab bar and header using screenOptions prop:
src/app/(tabs)/_layout.tsx
<Tabs
screenOptions={{
tabBarActiveTintColor: "#ffd33d",
tabBarInactiveTintColor: "#fff8",
tabBarStyle: {
backgroundColor: "#25292e",
},
headerStyle: {
backgroundColor: "#25292e",
},
headerShadowVisible: false,
headerTintColor: "#fff",
}}
>Our app now has a custom bottom tabs navigator:
In this section, we'll create the first screen of the StickerSmash app:
The screen above displays an image and two buttons. The first button allows the user to select an image from their device. The second button allows the user to continue with a default image provided by the app.
Once the user selects an image, they can add a sticker to it.
We'll use expo-image library which is already included in the default project template. It provides a cross-platform <Image> component.
The Image component takes the source as its value. The source uses require when the image is static and comes from assets/images directory. It can also come from Network as a uri property.
Replace everything in src/app/index.tsx file with the following:
import { Image } from "expo-image"
import { StyleSheet, View } from "react-native"
const PlaceholderImage = require("@/assets/images/background-image.png")
export default function Index() {
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<Image source={PlaceholderImage} style={styles.image} />
</View>
</View>
)
}
const styles = StyleSheet.create({
// `View` is already a flex container
container: {
flex: 1,
alignItems: "center",
backgroundColor: "#25292e",
},
imageContainer: {
flex: 1,
},
image: {
// width and height/aspect ratio required
width: "85%",
aspectRatio: 320 / 440,
borderRadius: 18,
},
})Let's divide the code into multiple files as we add more components to this screen.
Create a components directory inside src, and inside it, create the image-viewer.tsx file.
Move the code to display the image in this file along with the image styles:
src/components/image-viewer.tsx
import { Image } from "expo-image"
import { ImageSourcePropType, StyleSheet } from "react-native"
type Props = {
imgSource: ImageSourcePropType
}
export default function ImageViewer({ imgSource }: Props) {
return <Image source={imgSource} style={styles.image} />
}
const styles = StyleSheet.create({
image: {
// width and height/aspect ratio required
width: "85%",
aspectRatio: 320 / 440,
borderRadius: 18,
},
})Import ImageViewer and use it in the src/app/(tabs)/index.tsx:
import ImageViewer from "@/components/image-viewer"
import { StyleSheet, View } from "react-native"
const PlaceholderImage = require("@/assets/images/background-image.png")
export default function Index() {
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<ImageViewer imgSource={PlaceholderImage} />
</View>
</View>
)
}
const styles = StyleSheet.create({
// `View` is already a flex container
container: {
flex: 1,
alignItems: "center",
backgroundColor: "#25292e",
},
imageContainer: {
flex: 1,
},
})React Native includes a few different components for handling touch events, but <Pressable> is recommended. It can detect single taps, long presses, trigger separate events when the button is pushed in and released, and more.
There are two buttons we will create. Each has a different style and label. Let's start by creating a reusable component for these buttons. Create a button.tsx file inside the src/components directory with the following code:
import { StyleSheet, View, Pressable, Text } from "react-native"
type Props = {
label: string
}
export default function Button({ label }: Props) {
return (
<View style={styles.buttonContainer}>
<Pressable
style={styles.button}
onPress={() => alert("You pressed a button.")}
>
<Text style={styles.buttonLabel}>{label}</Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
buttonContainer: {
width: 320,
height: 68,
marginHorizontal: 20,
alignItems: "center",
justifyContent: "center",
padding: 3,
},
button: {
borderRadius: 10,
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
},
buttonLabel: {
color: "#fff",
fontSize: 16,
},
})Let's import this component into src/app/(tabs)/index.tsx file and add styles for the <View> that encapsulates these buttons:
import { View, StyleSheet } from "react-native"
import Button from "@/components/button"
import ImageViewer from "@/components/image-viewer"
const PlaceholderImage = require("@/assets/images/background-image.png")
export default function Index() {
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<ImageViewer imgSource={PlaceholderImage} />
</View>
<View style={styles.footerContainer}>
<Button label="Choose a photo" />
<Button label="Use this photo" />
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#25292e",
alignItems: "center",
},
imageContainer: {
flex: 1,
},
footerContainer: {
flex: 1 / 3,
alignItems: "center",
},
})Let's take a look at our app on Android, iOS and the web:
The app displays an alert when the user taps any of the buttons on the screen. It happens because <Pressable> calls alert() on its onPress prop.
We'll keep the second button with the label "Use this photo" as it is. However, we'll add more styling to the first button.
To add different styling to the "Choose a photo" button, we will add a new theme prop that will allow us to apply a primary theme. This button also has an icon before the label. We will use a FontAwesome icon from the @react-native-vector-icons library. So, first install the library:
npx expo install @react-native-vector-icons/fontawesomeThen, modify src/components/button.tsx:
import { StyleSheet, View, Pressable, Text } from "react-native"
import { FontAwesome } from "@react-native-vector-icons/fontawesome"
type Props = {
label: string
theme?: "primary"
}
export default function Button({ label, theme }: Props) {
if (theme === "primary") {
return (
<View
style={[
styles.buttonContainer,
{ borderWidth: 4, borderColor: "#ffd33d", borderRadius: 18 },
]}
>
<Pressable
style={[styles.button, { backgroundColor: "#fff" }]}
onPress={() => alert("You pressed a button.")}
>
<FontAwesome
name="picture-o"
size={18}
color="#25292e"
style={styles.buttonIcon}
/>
<Text style={[styles.buttonLabel, { color: "#25292e" }]}>
{label}
</Text>
</Pressable>
</View>
)
}
return (
<View style={styles.buttonContainer}>
<Pressable
style={styles.button}
onPress={() => alert("You pressed a button.")}
>
<Text style={styles.buttonLabel}>{label}</Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
buttonContainer: {
width: 320,
height: 68,
marginHorizontal: 20,
alignItems: "center",
justifyContent: "center",
padding: 3,
},
button: {
borderRadius: 10,
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
},
buttonIcon: {
paddingRight: 8,
},
buttonLabel: {
color: "#fff",
fontSize: 16,
},
})Now, modify the src/app/(tabs)/index.tsx file to use the theme="primary" prop on the first button:
<View style={styles.footerContainer}>
<Button theme="primary" label="Choose a photo" />
<Button label="Use this photo" />
</View>Let's take a look at our app on Android, iOS and the web:
Now, we will build a feature to select an image from the device's media gallery. This isn't possible with the core components and we'll need a library.
We'll use expo-image-picker, a library from Expo SDK that provides access to the system's UI to select images and videos from the phone's library.
Stop the development server, then run:
npx expo install expo-image-pickerTip
Any time we install a new library, stop the development server. After the installation completes, start the development server again.
expo-image-picker provides launchImageLibraryAsync() method to display the system UI by choosing an image or a video from the device's media library. We'll use the primary themed button created in the previous chapter to select an image from the device's media library and create a function to launch the device's image library to implement this functionality.
In src/app/(tabs)/index.tsx, import expo-image-picker library and create a pickImageAsync() function inside the Index component:
// ...rest of the import statements remain unchanged
import * as ImagePicker from "expo-image-picker"
export default function Index() {
const pickImageAsync = async () => {
let result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
quality: 1,
})
if (!result.canceled) {
console.log(result)
} else {
alert("You did not select any image.")
}
}
// ...rest of the code remains same
}When allowsEditing is set to true, the user can crop the image during the selection process on Android and iOS.
On pressing the primary button, we'll call the pickImageAsync() function on the Button component. Update the onPress prop of the Button component in src/components/button.tsx:
// existing imports...
type Props = {
// existing props...
onPress?: () => void;
};
export default function Button({ label, theme, onPress }: Props) {
if (theme === 'primary') {
return (
// ...
<Pressable style={[styles.button, { backgroundColor: '#fff' }]} onPress={onPress}>
// existing code...In src/app/(tabs)/index.tsx, add the pickImageAsync() function to the onPress prop on the first <Button>:
<Button theme="primary" label="Choose a photo" onPress={pickImageAsync} />The pickImageAsync() function invokes ImagePicker.launchImageLibraryAsync() and then handles the result object containing information about the selected image.
Here is an example of the result object for Android (see the terminal output after selecting an image):
{
"assets": [
{
"assetId": null,
"base64": null,
"duration": null,
"exif": null,
"fileName": "ea574eaa-f332-44a7-85b7-99704c22b402.jpeg",
"fileSize": 4513577,
"height": 4570,
"mimeType": "image/jpeg",
"rotation": null,
"type": "image",
"uri": "file:///data/user/0/host.exp.exponent/cache/ExperienceData/%2540anonymous%252FStickerSmash-13f21121-fc9d-4ec6-bf89-bf7d6165eb69/ImagePicker/ea574eaa-f332-44a7-85b7-99704c22b402.jpeg",
"width": 2854
}
],
"canceled": false
}The result object provides the assets array, which contains the uri of the selected image. Let's take this value and use it to show the selected image in the app.
Modify the src/app/(tabs)/index.tsx file:
- Declare a state variable called
selectedImage. We'll use it to hold the URI of the selected image. - Update the
pickImageAsync()function to save the image URI in theselectedImagestate variable. - Pass the
selectedImageas a prop to theImageViewercomponent.
// existing imports...
import { useState } from "react"
const PlaceholderImage = require("@/assets/images/background-image.png")
export default function Index() {
const [selectedImage, setSelectedImage] = useState<string | undefined>(
undefined,
)
const pickImageAsync = async () => {
// existing code...
if (!result.canceled) {
setSelectedImage(result.assets[0].uri)
} else {
alert("You did not select any image.")
}
}
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<ImageViewer
imgSource={PlaceholderImage}
selectedImage={selectedImage}
/>
</View>
{/* ...existing code... */}
</View>
)
}
// existing code...Pass the selectedImage prop to the ImageViewer component to display the selected image instead of a placeholder image.
- Modify the
src/components/image-viewer.tsxfile to accept theselectedImageprop. - The
sourceof the image is getting long, so let's also move it to a separate variable calledimageSource. - Pass
imageSourceas the value of thesourceprop on theImagecomponent.
// existing imports...
type Props = {
imgSource: ImageSourcePropType
selectedImage?: string
}
export default function ImageViewer({ imgSource, selectedImage }: Props) {
const imageSource = selectedImage ? { uri: selectedImage } : imgSource
return <Image source={imageSource} style={styles.image} />
}
// existing code...The picked image is a uri string, not a local asset like the placeholder image.
Let's take a look at our app now. We can select an image from the device's media gallery and see it in the app.
A modal component displays an overlay to draw a user's attention toward critical information or guide them to take action.
React Native provides a <Modal> component that presents content above the rest of the app.
In this section, we'll create a modal that shows an emoji picker list.
Before implementing the modal, we are going to add three new buttons. These buttons are visible after the user picks an image. One of these buttons will trigger the emoji picker modal.
In src/app/(tabs)/index.tsx:
- Declare a boolean state variable,
showAppOptions, to show or hide the buttons that open the modal, alongside a few other options. We'll set it to false by default. When the user picks an image or uses the placeholder image, we'll set it to true. - Update the
pickImageAsync()function to set the value ofshowAppOptionstotrueafter the user picks an image. - Update the button with no theme by adding an
onPressprop.
// existing codes...
export default function Index() {
const [selectedImage, setSelectedImage] = useState<string | undefined>(
undefined,
)
const [showAppOptions, setShowAppOptions] = useState<boolean>(false)
const pickImageAsync = async () => {
let result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
quality: 1,
})
if (!result.canceled) {
setSelectedImage(result.assets[0].uri)
setShowAppOptions(true)
} else {
alert("You did not select any image.")
}
}
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<ImageViewer
imgSource={PlaceholderImage}
selectedImage={selectedImage}
/>
</View>
{showAppOptions ? (
// show an empty view
<View />
) : (
<View style={styles.footerContainer}>
<Button
theme="primary"
label="Choose a photo"
onPress={pickImageAsync}
/>
<Button
label="Use this photo"
onPress={() => setShowAppOptions(true)}
/>
</View>
)}
</View>
)
}
// existing styles...In the above snippet, when the value of showAppOptions is true, we render an empty <View> component. We'll address this state in the next step.
Now, we can remove the alert on the Button component and update the onPress prop when rendering the second button in the src/components/button.tsx:
<Pressable style={styles.button} onPress={onPress}>Let's break down the layout of the option buttons we'll implement:
It contains a parent <View> with three buttons aligned in a row. The button in the middle with the plus icon (+) will open the modal and is styled differently.
We'll use MaterialIcons. So, first install the library:
npx expo install @react-native-vector-icons/material-iconsInside the src/components directory, create a new circle-button.tsx file with the following code:
import { MaterialIcons } from "@react-native-vector-icons/material-icons"
import { Pressable, StyleSheet, View } from "react-native"
type Props = {
onPress: () => void
}
export default function CircleButton({ onPress }: Props) {
return (
<View style={styles.circleButtonContainer}>
<Pressable style={styles.circleButton} onPress={onPress}>
<MaterialIcons name="add" size={38} color="#25292e" />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
circleButtonContainer: {
width: 84,
height: 84,
marginHorizontal: 60,
borderWidth: 4,
borderColor: "#ffd33d",
borderRadius: 42,
padding: 3,
},
circleButton: {
flex: 1,
justifyContent: "center",
alignItems: "center",
borderRadius: 42,
backgroundColor: "#fff",
},
})To render the plus icon, this button uses the <MaterialIcons> icon set.
The other two buttons also use <MaterialIcons> to display vertically aligned text labels and icons. Create a file named icon-button.tsx inside the src/components directory. This component accepts three props:
icon: the name corresponding to theMaterialIconslibrary icon.label: the text label displayed on the button.onPress: this function invokes when the user presses the button.
src/components/icon-button.tsx
import {
MaterialIcons,
MaterialIconsIconName,
} from "@react-native-vector-icons/material-icons"
import { Pressable, StyleSheet, Text } from "react-native"
type Props = {
icon: MaterialIconsIconName
label: string
onPress: () => void
}
export default function IconButton({ icon, label, onPress }: Props) {
return (
<Pressable onPress={onPress} style={styles.iconButton}>
<MaterialIcons name={icon} size={24} color={"#fff"} />
<Text style={styles.iconButtonLabel}>{label}</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
iconButton: {
justifyContent: "center",
alignItems: "center",
},
iconButtonLabel: {
color: "#fff",
marginTop: 12,
},
})Inside src/app/(tabs)/index.tsx:
- Import the
CircleButtonandIconButtoncomponents. - Add three placeholder functions for these buttons. The
onReset()function invokes when the user presses the reset button, causing the image picker button to appear again. We'll add the functionality for the other two functions later.
// Other imports...
import CircleButton from "@/components/circle-button"
import IconButton from "@/components/icon-button"
const PlaceholderImage = require("@/assets/images/background-image.png")
export default function Index() {
// existing codes...
const onReset = () => {
setShowAppOptions(false)
}
const onAddSticker = () => {
// we will implement this later
}
const onSaveImageAsync = () => {
// we will implement this later
}
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<ImageViewer
imgSource={PlaceholderImage}
selectedImage={selectedImage}
/>
</View>
{showAppOptions ? (
<View style={styles.optionsContainer}>
<View style={styles.optionsRow}>
<IconButton icon="refresh" label="Reset" onPress={onReset} />
<CircleButton onPress={onAddSticker} />
<IconButton
icon="save-alt"
label="Save"
onPress={onSaveImageAsync}
/>
</View>
</View>
) : (
<View style={styles.footerContainer}>
{/* existing buttons...
*/}
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
// Other existing styles...
optionsContainer: {
position: "absolute",
bottom: 80,
},
optionsRow: {
flexDirection: "row",
alignItems: "center",
},
})Let's take a look at our app on Android, iOS and the web:
The modal allows the user to choose an emoji from a list of available emoji. Create an emoji-picker.tsx file inside the src/components directory. This component accepts three props:
isVisible: a boolean to determine the state of the modal's visibility.onClose: a function to close the modal.children: used later to display a list of emoji.
src/components/emoji-picker.tsx
import { MaterialIcons } from "@react-native-vector-icons/material-icons"
import React from "react"
import { Modal, Pressable, StyleSheet, Text, View } from "react-native"
type Props = {
isVisible: boolean
onClose: () => void
children?: React.ReactNode
}
export default function EmojiPicker({ isVisible, onClose, children }: Props) {
return (
<View>
<Modal visible={isVisible} animationType="slide" transparent={true}>
<View style={styles.modalContent}>
<View style={styles.titleContainer}>
<Text style={styles.title}>Choose a sticker</Text>
<Pressable onPress={onClose}>
<MaterialIcons name="close" color={"#fff"} size={22} />
</Pressable>
</View>
{children}
</View>
</Modal>
</View>
)
}
const styles = StyleSheet.create({
modalContent: {
height: "25%",
width: "100%",
backgroundColor: "#25292e",
borderTopRightRadius: 18,
borderTopLeftRadius: 18,
position: "absolute",
bottom: 0,
},
titleContainer: {
height: "16%",
backgroundColor: "#464c55",
borderTopRightRadius: 10,
borderTopLeftRadius: 10,
paddingHorizontal: 20,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
title: {
color: "#fff",
fontSize: 16,
},
})What's going on here:
- The
<Modal>creates an overlay and the<View>withstyles.modalContentcomponent displays a title and a close button. - Its
visibleprop takes the value ofisVisibleand controls whether the modal is open or closed. - The
<EmojiPicker>invokes theonCloseprop when the user presses the close<Pressable>.
Now, let's modify the src/app/(tabs)/index.tsx:
- Import the
<EmojiPicker>component. - Create an
isModalVisiblestate variable. Its default value is false, which hides the modal until the user presses the button to open it. - Replace the comment in the
onAddSticker()function to update theisModalVisiblevariable totruewhen the user presses the button. - Create the
onModalClose()function to update theisModalVisiblestate variable. - Place the
<EmojiPicker>component at the bottom.
src/app/(tabs)/index.tsx
// Other existing imports...
import EmojiPicker from "@/components/emoji-picker"
const PlaceholderImage = require("@/assets/images/background-image.png")
export default function Index() {
const [selectedImage, setSelectedImage] = useState<string | undefined>(
undefined,
)
const [showAppOptions, setShowAppOptions] = useState<boolean>(false)
const [isModalVisible, setIsModalVisible] = useState<boolean>(false)
const pickImageAsync = async () => {
// existing code...
}
const onReset = () => {
setShowAppOptions(false)
}
const onAddSticker = () => {
setIsModalVisible(true)
}
//new
const onModalClose = () => {
setIsModalVisible(false)
}
const onSaveImageAsync = async () => {
// we will implement this later
}
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
{/* existing ImageViewer
*/}
</View>
{showAppOptions ? (
<View style={styles.optionsContainer}>
<View style={styles.optionsRow}>
{/* existing Option Buttons
*/}
</View>
</View>
) : (
<View style={styles.footerContainer}>
{/* existing Image picker buttons
*/}
</View>
)}
<EmojiPicker isVisible={isModalVisible} onClose={onModalClose}>
{/* Emoji list component will go here */}
</EmojiPicker>
</View>
)
}
// existing styles...Here is the result after this step:
Let's add a horizontal list of emoji in the modal's content. We'll use the <FlatList> component from React Native.
Create an emoji-list.tsx file inside the src/components directory:
import { Image } from "expo-image"
import { useState } from "react"
import {
FlatList,
ImageSourcePropType,
Platform,
Pressable,
StyleSheet,
} from "react-native"
type Props = {
onSelect: (image: ImageSourcePropType) => void
onCloseModal: () => void
}
export default function EmojiList({ onSelect, onCloseModal }: Props) {
const [emoji] = useState<ImageSourcePropType[]>([
require("@/assets/images/emoji1.png"),
require("@/assets/images/emoji2.png"),
require("@/assets/images/emoji3.png"),
require("@/assets/images/emoji4.png"),
require("@/assets/images/emoji5.png"),
require("@/assets/images/emoji6.png"),
])
return (
<FlatList
horizontal
showsHorizontalScrollIndicator={Platform.OS === "web"}
data={emoji}
contentContainerStyle={styles.listContainer}
renderItem={({ item, index }) => (
<Pressable
onPress={() => {
onSelect(item)
onCloseModal()
}}
>
<Image source={item} key={index} style={styles.image} />
</Pressable>
)}
/>
)
}
const styles = StyleSheet.create({
listContainer: {
borderTopRightRadius: 10,
borderTopLeftRadius: 10,
paddingHorizontal: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
image: {
// width and height/aspect ratio required
width: 100,
height: 100,
marginRight: 20,
},
})Let's see what the above code does:
- The
<FlatList>component renders all the emoji images using theImagecomponent, wrapped by a<Pressable>. - It takes an array of items provided by the
emojiarray variable as the value of thedataprop. TherenderItemprop takes theitemfrom thedata. Finally, we addedImagecomponent to display this item. - The
horizontalprop renders the list horizontally. TheshowsHorizontalScrollIndicatoruses React Native'sPlatformmodule to display the horizontal scroll bar on web.
Now, update the src/app/(tabs)/index.tsx to import the <EmojiList> component and replace the comments inside the <EmojiPicker> component with the following code snippet:
// Other existing imports...
import { ImageSourcePropType, View, StyleSheet } from "react-native"
import EmojiList from "@/components/emoji-list"
const PlaceholderImage = require("@/assets/images/background-image.png")
export default function Index() {
const [selectedImage, setSelectedImage] = useState<string | undefined>(
undefined,
)
const [showAppOptions, setShowAppOptions] = useState<boolean>(false)
const [isModalVisible, setIsModalVisible] = useState<boolean>(false)
const [pickedEmoji, setPickedEmoji] = useState<
ImageSourcePropType | undefined
>(undefined)
// existing functions...
return (
<View style={styles.container}>
{/* existing Views...
*/}
<EmojiPicker isVisible={isModalVisible} onClose={onModalClose}>
<EmojiList onSelect={setPickedEmoji} onCloseModal={onModalClose} />
</EmojiPicker>
</View>
)
}
// existing styles...In the EmojiList component, the onSelect prop selects the emoji and after selecting it, the onCloseModal closes the modal.
Let's take a look at our app on Android, iOS and the web:
Now, we'll put the emoji sticker on the image. Create a new file named emoji-sticker.tsx in the src/components:
import { ImageSourcePropType, View } from "react-native"
import { Image } from "expo-image"
type Props = {
imageSize: number
stickerSource: ImageSourcePropType
}
export default function EmojiSticker({ imageSize, stickerSource }: Props) {
return (
<View style={{ top: -350 }}>
<Image
source={stickerSource}
style={{ width: imageSize, height: imageSize }}
/>
</View>
)
}This component receives two props:
imageSize: a value defined inside theIndexcomponent. We will also use this value later to scale the image's size when tapped.stickerSource: thesourceof the selected emoji image.
Import this component in the src/app/(tabs)/index.tsx file and update the Index component to display the emoji sticker on the image. We will check if the pickedEmoji state is not undefined:
src/app/(tabs)/index.tsx
// Other existing imports...
import EmojiSticker from "@/components/emoji-sticker"
const PlaceholderImage = require("@/assets/images/background-image.png")
export default function Index() {
// existing codes...
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<ImageViewer
imgSource={PlaceholderImage}
selectedImage={selectedImage}
/>
{pickedEmoji && (
<EmojiSticker imageSize={40} stickerSource={pickedEmoji} />
)}
</View>
{/* existing codes...
*/}
</View>
)
}
// existing styles...Let's take a look at our app on Android, iOS and the web. You should see the emoji sticker on the image now.
In this section, we'll implement gestures from React Native Gesture Handler and Reanimated libraries.
We'll add two different gestures among others:
- Double tap to scale the size of the emoji sticker and reduce the scale when double tapped again.
- Pan to move the emoji sticker around the screen so that the user can place the sticker anywhere on the image.
We'll also use the Reanimated library to animate between gesture states.
To get gesture interactions to work in the app, we'll render <GestureHandlerRootView> from react-native-gesture-handler at the top of Index component. Replace the root level <View> component in the src/app/(tabs)/index.tsx with <GestureHandlerRootView>.
src/app/(tabs)/index.tsx
// ... rest of the import statements remain same
import { GestureHandlerRootView } from "react-native-gesture-handler"
export default function Index() {
return (
<GestureHandlerRootView style={styles.container}>
{/* ...rest of the code remains */}
</GestureHandlerRootView>
)
}An Animated component looks at the style prop of the component and determines which values to animate and apply updates to create an animation.
- Open the
src/components/emoji-sticker.tsxfile. Inside it, importAnimatedfrom thereact-native-reanimatedlibrary to use animated components. - Replace the
Imagecomponent with<Animated.Image>to make a double tap gesture work.
src/components/emoji-sticker.tsx
// existing imports...
import Animated from "react-native-reanimated"
// existing codes...
export default function EmojiSticker({ imageSize, stickerSource }: Props) {
return (
<View style={{ top: -350 }}>
<Animated.Image
source={stickerSource}
resizeMode="contain"
style={{ width: imageSize, height: imageSize }}
/>
</View>
)
}React Native Gesture Handler allows us to detect a double tap event.
In the src/components/emoji-sticker.tsx file:
- Import
GestureandGestureDetectorfromreact-native-gesture-handler. - import
useAnimatedStyle,useSharedValue, andwithSpringfromreact-native-reanimatedto animate the style of the<Animated.Image>. - Inside the
EmojiStickercomponent, create a reference calledscaleImageusing theuseSharedValue()hook. It will take the value ofimageSizeas its initial value.
src/components/emoji-sticker.tsx
// ...rest of the import statements remain same
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
export default function EmojiSticker({ imageSize, stickerSource }: Props) {
const scaleImage = useSharedValue(imageSize);
return (
// ...rest of the code remains same
)
}Creating a shared value using the useSharedValue() hook helps to mutate data and runs animations based on the current value. We can access and modify the shared value using the .value property.
Now, we'll create a doubleTap object to scale the initial value and use Gesture.Tap() to animate the transition.
src/components/emoji-sticker.tsx
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onStart(() => {
if (scaleImage.value !== imageSize * 2) {
scaleImage.value = scaleImage.value * 2
} else {
scaleImage.value = Math.round(scaleImage.value / 2)
}
})Let's use a spring-based animation. This will make it feel alive. We will use the withSpring() function provided by react-native-reanimated.
On the sticker image, we'll use the useAnimatedStyle() hook to create a style object. This will help us to update styles using shared values when the animation happens. We'll also scale the size of the image by manipulating the width and height properties. The initial values of these properties are set to imageSize.
Create an imageStyle variable inside the EmojiSticker component:
src/components/emoji-sticker.tsx
const imageStyle = useAnimatedStyle(() => {
return {
width: withSpring(scaleImage.value),
height: withSpring(scaleImage.value),
}
})Next, wrap the <Animated.Image> component with the <GestureDetector> and modify the style prop on the <Animated.Image> to pass the imageStyle.
src/components/emoji-sticker.tsx
// Other codes remain same...
return (
<View style={{ top: -350 }}>
<GestureDetector gesture={doubleTap}>
<Animated.Image
source={stickerSource}
resizeMode="contain"
style={[{ width: imageSize, height: imageSize }, imageStyle]}
/>
</GestureDetector>
</View>
)In the above snippet, the gesture prop takes the value of the doubleTap to trigger a gesture when a user double-taps the sticker image.
Let's take a look at our app on Android, iOS and the web.
To recognize a dragging gesture on the sticker and to track its movement, we'll use a pan gesture. In the src/components/emoji-sticker.tsx:
- Create two new shared values:
translateXandtranslateY. - Replace the
<View>with the<Animated.View>component.
src/components/emoji-sticker.tsx
export default function EmojiSticker({ imageSize, stickerSource }: Props) {
const scaleImage = useSharedValue(imageSize)
const translateX = useSharedValue(0)
const translateY = useSharedValue(0)
// ...rest of the code remains same
return (
<Animated.View style={{ top: -350 }}>
<GestureDetector gesture={doubleTap}>
{/* ...rest of the code remains same */}
</GestureDetector>
</Animated.View>
)
}Let's see what the above code does:
- The translation values defined will move the sticker around the screen along the X and Y axes.
- In the
useSharedValue()hooks, we have set both translation variables to have an initial position of0. This value sets the sticker's initial position when the gesture starts.
In the previous step, we triggered the onStart() callback for the tap gesture. For the pan gesture, specify an onChange() callback, which runs when the gesture is active and moving.
- Create a
dragobject to handle the pan gesture. TheonChange()callback acceptseventas a parameter.changeXandchangeYproperties hold the change in position since the last event and update the values stored intranslateXandtranslateY. - Define the
containerStyleobject using theuseAnimatedStyle()hook. It will return an array of transforms. For the<Animated.View>component, we need to set thetransformproperty to thetranslateXandtranslateYvalues. This will change the sticker's position when the gesture is active.
const drag = Gesture.Pan().onChange((event) => {
translateX.value += event.changeX
translateY.value += event.changeY
})
const containerStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateX: translateX.value,
},
{
translateY: translateY.value,
},
],
}
})Next, inside the JSX code:
- Make the
<GestureDetector>component becomes the top-level component. - Add the
containerStyleon the<Animated.View>component to apply the transform styles.
src/components/emoji-sticker.tsx
// Other codes remain same...
return (
<GestureDetector gesture={drag}>
<Animated.View style={[containerStyle, { top: -350 }]}>
<GestureDetector gesture={doubleTap}>
<Animated.Image
source={stickerSource}
resizeMode="contain"
style={[{ width: imageSize, height: imageSize }, imageStyle]}
/>
</GestureDetector>
</Animated.View>
</GestureDetector>
)Let's take a look at our app on Android, iOS and the web.
Commit changes.
Before using expo-media-library to save the image to device's media, we need to create and install a development build to run the app instead of Expo Go.
This section we will use EAS (Expo Application Service) Build to create and install a development build, then run it on a device, emulator, or simulator.
A development build is a debug version of our project. It is optimized for quick iterations when creating an app. It contains the expo-dev-client library, which offers a robust and complete development environment. This setup allows us to integrate any native library or change code inside the native directories as required.
You can think of a development build as a customizable version of Expo Go that is unique to the requirements of a project.
First stop the development server and then run the following command to install the library:
npx expo install expo-dev-clientStart the development server again using npx expo start or npx expo start --tunnel
After starting the development server, in the terminal window, we see the QR code followed by a Metro manifest URL.
Let's notice the changes installing the expo-dev-client library:
- The manifest URL contains
expo-development-clientalong with the app scheme - The development server now operates for a development build (instead of Expo Go).
Since we do not have a development build installed on one of our devices or an emulator/simulator, we can't run our project yet.
Install the EAS command-line interface (CLI) tool as a global dependency on our local machine:
npm install --global eas-cliIf you have an Expo account and are signed in using Expo CLI, skip this step. If you don't have an Expo account, sign up here and proceed with the login command below:
eas loginThis command asks for our Expo account email or username and password to complete the login.
For any new project, the first step is to initialize and link it to the EAS servers. Run the following command:
eas initOn running, this command:
- Requests verification of the account owner by entering our Expo account credentials and asks if we want to create a new EAS project:
✔ Which account should own this project? › your-username
✔ Would you like to create a project for @ttanvirr/StickerSmash? … yes
✔ Created @ttanvirr/StickerSmash
✔ Project successfully linked (ID: 09cae057-7801-4783-b0fb-bc5f7d0c23cd) (modified app.json)
✔ Set the project icon from icon in your app config
- Creates EAS project and provides a link to that project which we can open in the EAS dashboard:
- Generates a unique
projectIdand links this EAS project to the example app on our development machine. - Modifies
app.jsonto includeextra.eas.projectIdand updates its value with the unique ID created:
app.json
{
"extra": {
"eas": {
"projectId": "0cd3da2d-xxx-xxx-xxx-xxxxxxxxxx"
}
}
}To set up our project for EAS Build, run the following command:
eas build:configureOn running, this command:
- Prompts to select a platform: Android, iOS, or All. Since we are creating Android and iOS apps, let's select All.
- Creates
eas.jsonin the root of our project's directory with default configuration.
This default configuration does two things:
- Defines the current EAS CLI version.
- Adds three build profiles:
development,preview, andproduction.
Currently, our focus is on the development profile, which includes the following configuration:
developmentClient: Enabled (true) for creating a debug build. It loads the app using theexpo-dev-clientlibrary, which provides development tools and generates a build artifact for device or emulator/simulator installation.distribution: Configured asinternalto indicate that we want to share the build internally (instead of uploading it on app stores).
For Android, the development build must be in the .apk. While the default Android format is .aab, which is ideal for Google Play Store distribution, it cannot be installed on devices or emulators.
To create a .apk:
- In
eas.json, make sure thatdevelopmentClientis set to true underbuild.developmentprofile. - Then, run the
eas buildcommand withandroidas the platform anddevelopmentas the build profile:
eas build --platform android --profile developmentTIP: You can also use
-pinstead of--platform.
This command prompts us with the following questions:
- "What would you like your Android application id to be?" Press
Returnto select the default value provided for this prompt. This will addandroid.packageinapp.json. - "Generate a new Android Keystore?" Press
Y.
After responding, the build will queue up, and we can track its progress via a provided link by the EAS CLI in the EAS dashboard.
Note
What is an Android application ID?
Also known as the package name of our Android app, it stores the value in DNS reverse notation format (com.owner.appname), where com.owner is the domain and and in our case, stickersmash is our app name.
Once the build finishes, the Build artifact section in the EAS dashboard gets updated, indicating that the build is complete:
This section provides the methods available for running the development build on an Android device: Expo Orbit and Install button.
Expo Orbit allows for seamless installation of the development build on an Android device. To use this method:
- Install it on your local machine (not on the android device).
- Connect our Android device to our local machine using USB.
- Open the Orbit app on your local machine. You may also log in.
- Select the Device in the Orbit app.
Note
You may not see the device in the list. In this case you may need to configure your device to allow USB debugging. Go to Settings > About Phone > Software information. Find Build number and tap it quickly for 7 times to enable developer mode. Now you can see Developer options in Settings. Open it and enable USB debugging. Now open the Orbit app and you'll see the device in the list.
- On the EAS dashboard, under Build artifact, click the
Open with Orbit.
After the build is installed, the Orbit app launches the development build on the device.
The Install button in the Build artifact generates a QR code for installation:
- Click Install to display a popup with the QR code.
- Scan the QR code with our Android device's camera to open the build link in the default web browser.
- Tap the Install button on the webpage to download the .apk file.
- Once downloaded, open the .apk to start the installation process.
- If an Unsafe app blocked message appears, select Install anyway. This warning can safely be ignored as the source of the .apk (which we generated) is trusted.
After installation, we can disconnect the USB connection.
Start the development server by running npx expo start or in case, npx expo start --tunnel from the project directory. Once the server is running, press A in the terminal window to open the project. If pressing A doesn't work, use the QR code, scan it with the scanner provided by our StickerSmash app that is just installed.
To set up an Android emulator, Follow this guilde
Run the Android Emulator from Android Studio. Open the Orbit app and select the Emulator in the list. Now, from Build artifact on the EAS dashboard, click Open with Expo Orbit to install the development build on the Android Emulator.
The Install button in the Build artifact generates a QR code and a Link for installation. In this case, we'll use the link. Enter the link in the web browser inside the Emulator to the the installation link.
After installation has finished, open it on the Emulator. Start the development server by running npx expo start or in case, npx expo start --tunnel from the project directory. Once the server is running, we'll have an option to open our StickerSmash app in the emulator.
Once we have set up the development build, we can use libraries like expo-media-library to save images to device's media library.
In this section, we'll take a screenshot using react-native-view-shot library and save it on the device's media library using expo-media-library.
To install react-native-view-shot and expo-media-library, stop the development server and run the following commands:
npx expo install react-native-view-shot expo-media-libraryAn app that requires sensitive information, such as accessing a device's media library, has to prompt permission to allow or deny access. Using useMediaLibraryPermissions() hook from expo-image-picker, we can use the permission permissionResponse and requestPermission() method to ask for access. This hook requests both read and write permissions, which covers picking images from the library and saving screenshots to it.
When the app loads for the first time and the permission status is neither granted nor denied, the value of the permissionResponse is null. We can add a condition to check if it is not granted. If it is not granted, trigger the requestPermission() method. After getting the access, the value of the permissionResponse changes to granted.
Add the following code snippet inside the src/app/(tabs)/index.tsx:
import { useEffect, useState } from "react"
import * as ImagePicker from "expo-image-picker"
// ...rest of the code remains same
export default function Index() {
const [permissionResponse, requestPermission] =
ImagePicker.useMediaLibraryPermissions()
// ...rest of the code remains same
useEffect(() => {
if (!permissionResponse?.granted) {
requestPermission()
}
}, [])
// ...rest of the code remains same
}- Import
useReffrom React. - Create an
imageRefreference variable to store the reference of the screenshot image captured. - Wrap the
<ImageViewer>and<EmojiSticker>components inside a<View>and then pass the reference variable to it.
src/app/(tabs)/index.tsx
import { useState, useRef } from "react"
import { captureRef } from "react-native-view-shot"
export default function Index() {
const imageRef = useRef<View>(null)
// ...rest of the code remains same
return (
<GestureHandlerRootView style={styles.container}>
<View style={styles.imageContainer}>
<View ref={imageRef} collapsable={false}>
<ImageViewer
imgSource={PlaceholderImage}
selectedImage={selectedImage}
/>
{pickedEmoji && (
<EmojiSticker imageSize={40} stickerSource={pickedEmoji} />
)}
</View>
</View>
{/* ...rest of the code remains same */}
</GestureHandlerRootView>
)
}In the above snippet, the collapsable prop is set to false. This allows the <View> component to screenshot only of the background image and emoji sticker.
We can capture a screenshot of the <View> as an image by calling the captureRef() method from react-native-view-shot inside the onSaveImageAsync() function. It accepts an optional argument where we can pass the width and height of the screenshot capturing area.
The captureRef() method also returns a promise that fulfills with the screenshot's URI. We will pass this URI as a parameter to MediaLibrary.Asset.create() and save the screenshot to the device's media library.
Inside src/app/(tabs)/index.tsx:
- Import
captureReffromreact-native-view-shotandMediaLibraryfromexpo-media-library. - update the
onSaveImageAsync()function with the following code.
src/app/(tabs)/index.tsx
// Other imports remain same...
import * as MediaLibrary from "expo-media-library"
import { captureRef } from "react-native-view-shot"
export default function Index() {
// Other codes remain same...
const onSaveImageAsync = async () => {
try {
const localUri = await captureRef(imageRef, {
height: 440,
quality: 1,
})
await MediaLibrary.Asset.create(localUri)
alert("Saved!")
} catch (e) {
console.log(e)
}
}
// Other codes remain same...
}Now, choose a photo and add a sticker in the app. Then tap the "Save" button. We should see the saved image in the media library.
The End!













