Skip to content

About

This repository is a note on Initializing a mobile application development using React Native and Expo

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Table of contents

1. Overview

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

2. Get started

  1. Install dependencies

    npm install
  2. Start the app

    npx expo start

In case, you might run:

npx expo start --tunnel

In the output, you'll find options to open the app in a

3. Start from scratch

3.1. Prerequisites

  • 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.

3.2. Initialize a new Expo app

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 StickerSmash

During 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

3.3. Download assets

  • Download assets archive from this link

  • Unzip the archive and replace the default assets in the your-project-name/assets/images directory.

  • Open the project directory in a code editor or IDE.

3.4. Run reset-project script

Let's run the reset-project script to remove the boilerplate code:

npm run reset-project

reset-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.

3.5. Run the app on mobile and web

In the project directory, run the following command to start the development server:

npx expo start

After running the above command:

  1. The development server will start, and you'll see a QR code inside the terminal window.
  2. Scan that QR code to open the app on the device. On Android, use the Expo Go > Scan QR code option. On iOS, use the default camera app.
  3. To run the web app, press W in 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:

alt text

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 --tunnel

Using 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.

3.6. Make your first change

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.backgroundColor property to <View> with the value of #25292e to change the background color.
  • Replace the default value of <Text> with "Home screen".
  • Add a styles.text.color property 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:

alt text

Commit changes.

3.7. Add navigation

In this section, we'll see Expo Router's fundamentals to create stack navigation and a bottom tab bar with two tabs.

3.7.1. Expo Router basics

Expo Router is a file-based routing framework. To get started, we need to know about the following conventions:

  • app directory: 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 at src/app.
  • Root layout: The src/app/_layout.tsx file. 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.tsx file in the src/app directory matches / route.

3.7.2. Add a new screen to the stack

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:

  1. Add a <Stack.Screen /> component and an options prop to update the title of the /about route.
  2. Update the /index route's title to Home by adding options prop.

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.

3.7.3. Navigate between screens

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:

3.7.4. Add a not-found 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.

3.7.5. Add a bottom tab navigator

We'll add a bottom tab navigator to our app.

  • Inside the src/app directory, 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.tsx file inside the directory. It will be used to define the tab layout, which is separate from Root layout.
  • Move the existing index.tsx and about.tsx files inside the (tabs) directory. The structure of src/app directory 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:

alt text

3.7.6. Install @react-native-vector-icons

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/ionicons

Note

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.

3.7.7. Update bottom tab navigator appearance

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 tabBarIcon to both the index and about routes. This function takes focused and color as params and renders the icon component. From the icon set, we can provide custom icon names.
  • Add screenOptions.tabBarActiveTintColor to the Tabs component 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:

alt text

3.8. Build a screen

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.

3.8.1. Display the image

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,
  },
})

3.8.2. Divide components into files

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,
  },
})

3.8.3. Create buttons using Pressable

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.

3.8.4. Enhance the reusable button component

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/fontawesome

Then, 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:

3.9. Use an image picker

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.

3.9.1. Install expo-image-picker

Stop the development server, then run:

npx expo install expo-image-picker

Tip

Any time we install a new library, stop the development server. After the installation completes, start the development server again.

3.9.2. Pick an image from the device's media library

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.

3.9.3. Update the button component

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
}

3.9.4. Use the selected image

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:

  1. Declare a state variable called selectedImage. We'll use it to hold the URI of the selected image.
  2. Update the pickImageAsync() function to save the image URI in the selectedImage state variable.
  3. Pass the selectedImage as a prop to the ImageViewer component.
// 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.

  1. Modify the src/components/image-viewer.tsx file to accept the selectedImage prop.
  2. The source of the image is getting long, so let's also move it to a separate variable called imageSource.
  3. Pass imageSource as the value of the source prop on the Image component.
// 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.

3.10. Create a modal

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.

3.10.1. Declare a state variable to show buttons

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:

  1. 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.
  2. Update the pickImageAsync() function to set the value of showAppOptions to true after the user picks an image.
  3. Update the button with no theme by adding an onPress prop.
// 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}>

3.10.2. Add buttons

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-icons

Inside 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:

  1. icon: the name corresponding to the MaterialIcons library icon.
  2. label: the text label displayed on the button.
  3. 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:

  1. Import the CircleButton and IconButton components.
  2. 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:

3.10.3. Create an emoji picker modal

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> with styles.modalContent component displays a title and a close button.
  • Its visible prop takes the value of isVisible and controls whether the modal is open or closed.
  • The <EmojiPicker> invokes the onClose prop when the user presses the close <Pressable>.

Now, let's modify the src/app/(tabs)/index.tsx:

  1. Import the <EmojiPicker> component.
  2. Create an isModalVisible state variable. Its default value is false, which hides the modal until the user presses the button to open it.
  3. Replace the comment in the onAddSticker() function to update the isModalVisible variable to true when the user presses the button.
  4. Create the onModalClose() function to update the isModalVisible state variable.
  5. 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:

3.10.4. Display a list of emoji

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 the Image component, wrapped by a <Pressable>.
  • It takes an array of items provided by the emoji array variable as the value of the data prop. The renderItem prop takes the item from the data. Finally, we added Image component to display this item.
  • The horizontal prop renders the list horizontally. The showsHorizontalScrollIndicator uses React Native's Platform module 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:

3.10.5. Display the selected emoji

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 the Index component. We will also use this value later to scale the image's size when tapped.
  • stickerSource: the source of 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.

3.11. Add gestures

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.

3.11.1. Add GestureHandlerRootView

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>
  )
}

3.11.2. Use animated components

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.tsx file. Inside it, import Animated from the react-native-reanimated library to use animated components.
  • Replace the Image component 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>
  )
}

3.11.3. Add a tap gesture

React Native Gesture Handler allows us to detect a double tap event.

In the src/components/emoji-sticker.tsx file:

  1. Import Gesture and GestureDetector from react-native-gesture-handler.
  2. import useAnimatedStyle, useSharedValue, and withSpring from react-native-reanimated to animate the style of the <Animated.Image>.
  3. Inside the EmojiSticker component, create a reference called scaleImage using the useSharedValue() hook. It will take the value of imageSize as 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.

3.11.4. Add a pan gesture

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:

  1. Create two new shared values: translateX and translateY.
  2. 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 of 0. 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.

  1. Create a drag object to handle the pan gesture. The onChange() callback accepts event as a parameter. changeX and changeY properties hold the change in position since the last event and update the values stored in translateX and translateY.
  2. Define the containerStyle object using the useAnimatedStyle() hook. It will return an array of transforms. For the <Animated.View> component, we need to set the transform property to the translateX and translateY values. 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:

  1. Make the <GestureDetector> component becomes the top-level component.
  2. Add the containerStyle on 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.

3.12. Development build using EAS

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.

3.12.1. Understanding development builds

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.

3.12.2. Install expo-dev-client library

First stop the development server and then run the following command to install the library:

npx expo install expo-dev-client

Start 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-client along 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.

3.12.3. Initialize a development build

3.12.3.1. Install EAS CLI

Install the EAS command-line interface (CLI) tool as a global dependency on our local machine:

npm install --global eas-cli

3.12.3.2. Log in or sign up for an Expo account

If 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 login

This command asks for our Expo account email or username and password to complete the login.

3.12.3.3. Initialize and link the project to EAS

For any new project, the first step is to initialize and link it to the EAS servers. Run the following command:

eas init

On 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 projectId and links this EAS project to the example app on our development machine.
  • Modifies app.json to include extra.eas.projectId and updates its value with the unique ID created:

app.json

{
  "extra": {
    "eas": {
      "projectId": "0cd3da2d-xxx-xxx-xxx-xxxxxxxxxx"
    }
  }
}

3.12.4. Configure project for EAS Build

To set up our project for EAS Build, run the following command:

eas build:configure

On 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.json in 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, and production.

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 the expo-dev-client library, which provides development tools and generates a build artifact for device or emulator/simulator installation.
  • distribution: Configured as internal to indicate that we want to share the build internally (instead of uploading it on app stores).

3.12.5. Create a build for Android

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 that developmentClient is set to true under build.development profile.
  • Then, run the eas build command with android as the platform and development as the build profile:
eas build --platform android --profile development

TIP: You can also use -p instead of --platform.

This command prompts us with the following questions:

  • "What would you like your Android application id to be?" Press Return to select the default value provided for this prompt. This will add android.package in app.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.

3.12.6. Install and run the build on Android device

3.12.6.1. Install development build

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.

3.12.6.1.1. Method-1: Expo Orbit

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.

3.12.6.1.2. Method-2: Use the Install button and QR code

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.

3.12.6.2. Run development build

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.

3.12.7. Install and run the build on Android Emulator

To set up an Android emulator, Follow this guilde

3.12.7.1. Install development build

3.12.7.1.1. Method-1: Expo Orbit

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.

3.12.7.1.2. Method-2: Use the Install button and Link

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.

3.12.7.2. Run development build

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.

3.13. Back to the app: Take a screenshot

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.

3.13.1. Install libraries

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-library

3.13.2. Prompt for permissions

An 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
}

3.13.3. Create a ref to save the current view

  1. Import useRef from React.
  2. Create an imageRef reference variable to store the reference of the screenshot image captured.
  3. 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.

3.13.4. Capture a screenshot and save it

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:

  1. Import captureRef from react-native-view-shot and MediaLibrary from expo-media-library.
  2. 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!

About

This repository is a note on Initializing a mobile application development using React Native and Expo

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages