John Au-Yeung I'm a web developer interested in JavaScript stuff.

Writing an Android app with Ionic React

8 min read 2286

Writing an Android App with Ionic React

If you know how to create React web apps, you can use those same skills to develop mobile apps using the Ionic framework. In this article, we’ll look at how to create an Android recipe app with the Ionic framework and React.

Getting started

Let’s get started by installing a few things. First, install the Ionic CLI globally by running:

npm install -g @ionic/cli native-run cordova-res

Next, let’s create our Ionic app project:

ionic start react-ionic-recipes-app blank  --type=react --capacitor

blank means we’re creating a blank app. A blank app has routing added — and that’s the only thing we need. type is set to react, which means we’ll create a React project. Lastly, --capacitor adds Capacitor, a cross-platform native runtime from Ionic that allows us to run and build a native app from our project files.

Then we’ll install the React Hooks for our project by running the following command in the ionic-app project folder:

npm install @ionic/react-hooks @ionic/pwa-elements

Finally, let’s run the app in the browser:

ionic serve

We should be able to see our app on http://localhost:8100.

Creating our recipe app with Ionic React

Now we should have a project that we can run on our Android mobile device or an emulator like Genymotion.

App.tsx is the entry point component for the project; this is also where the routing code is located. Let’s add more routes to it by writing:

// src/App.tsx

import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import { IonApp, IonRouterOutlet } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import Home from './pages/Home';

/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';

/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';

/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';

/* Theme variables */
import './theme/variables.css';
import RecipeForm from './pages/RecipeForm';

const App: React.FC = () => (
  <IonApp>
    <IonReactRouter>
      <IonRouterOutlet>
        <Route path="/home" component={Home} exact={true} />
        <Route exact path="/" render={() => <Redirect to="/home" />} />
        <Route exact path="/recipe-form/:id" component={RecipeForm} />
        <Route exact path="/recipe-form" component={RecipeForm} />
      </IonRouterOutlet>
    </IonReactRouter>
  </IonApp>
);

export default App;

Ionic comes with its own router, so we can use it as we like. To add our routes, we added the recipe-form routes to the IonRouterOutlet. The exact prop means the Ionic router does an exact match on the route. path, of course, defines the path of the route, and :id is a route parameter placeholder. component defines which component we want to mount when we navigate to the given path.

We made a custom demo for .
No really. Click here to check it out.

Building the recipes page

We want to display the recipes list on the homepage of our app. To do this, we add the Home.tsx file into the pages folder. Then, we write:

// src/pages/Home.tsx

import { IonButton, IonCol, IonContent, IonHeader, IonItem, IonLabel, IonList, IonPage, IonRow, IonTitle, IonToolbar } from '@ionic/react';
import React, { useEffect, useState } from 'react';
import './Home.css';
import { Plugins } from '@capacitor/core';
import { RouteComponentProps } from 'react-router';
import { Recipe } from '../interfaces/recipe';
const { Storage } = Plugins;

const Home: React.FC<RouteComponentProps> = ({ match }) => {
  const [recipes, setRecipes] = useState<Recipe[]>([])

  const getRecipes = async () => {
    const ret = await Storage.get({ key: 'recipes' });
    const recipes: Recipe[] = JSON.parse(ret.value as string) || [];
    setRecipes(recipes)
  }

  useEffect(() => {
    getRecipes()
  }, [match])

  const saveRecipes = async (data: Recipe[]) => {
    await Storage.set({
      key: 'recipes',
      value: JSON.stringify(data)
    });
  }

  const deleteRecipe = async (id: string) => {
    const ret = await Storage.get({ key: 'recipes' });
    const recipes: Recipe[] = JSON.parse(ret.value as string) || [];
    await saveRecipes(recipes.filter(r => r.id !== id))
    await getRecipes()
  }

  return (
    <IonPage>
      <IonHeader>
        <IonToolbar>
          <IonTitle>Recipe App</IonTitle>
        </IonToolbar>
      </IonHeader>
      <IonContent fullscreen>
        <IonRow>
          <IonCol size="12">
            <IonButton routerLink="/recipe-form">Add Recipe</IonButton>
          </IonCol>
        </IonRow>
        <IonRow>
          <IonCol>
            <IonList>
              {recipes.map(({ id, title }) => (
                <IonItem key={id}>
                  <IonLabel>{title}</IonLabel>
                  <IonButton routerLink={`/recipe-form/${id}`}>Edit</IonButton>
                  <IonButton onClick={deleteRecipe.bind(undefined, id)}>Delete</IonButton>
                </IonItem>
              ))}
            </IonList>
          </IonCol>
        </IonRow>
      </IonContent>
    </IonPage>
  );
};

export default Home;

In this file, we use the Capacitor storage library to let us manipulate storage asynchronously.

In our case, this is a better choice than local storage because local storage can only store a small amount of temporary data. Local storage also has a data eviction policy that frees up space if the data takes up more than 50 percent of the space of the storage device. Capacitor’s storage API is also async, so it won’t hold up the UI.

In Home.tsx, we get the data with key recipes via the getRecipes function. Then, we call setRecipes to set the recipes state.

We run the getRecipes function whenever the match prop is changed. The match prop contains the navigation data, like URL and URL parameters; thus, we get the latest data whenever navigation is done.

The saveRecipes function is called when we call deleteRecipe. We get the value with key recipes, parse that, then call saveRecipes without the item with the given id. This lets us delete the entry with the given id.

Since this app is so small, we can just get all the entries without fear of performance penalties — however, we should consider other storage solutions if our app stores a lot of data.

We call deleteRecipe when we click on the Delete button, and when we click on Edit, we go to the recipe-form URL with the ID of the recipe so we can edit the entry with the given ID. We also have the Add Recipe button to go to the recipe-form. The IonLabel has the title, which we display in the list.

Adding and editing recipes

Now we need to create the page to add or edit recipes. To do this, we create a RecipeForm.tsx file in the src/pages folder and write:

// src/pages/RecipeForm.tsx

import {
  IonBackButton,
  IonButton,
  IonButtons,
  IonContent,
  IonHeader,
  IonImg,
  IonInput,
  IonItem,
  IonLabel,
  IonList,
  IonPage,
  IonTextarea,
  IonTitle,
  IonToolbar
} from '@ionic/react';
import React, { useCallback, useEffect, useState } from 'react';
import { useCamera } from '@ionic/react-hooks/camera';
import { CameraResultType, CameraSource } from "@capacitor/core";
import { RouteComponentProps } from 'react-router';
import { Plugins } from '@capacitor/core';
import { v4 as uuidv4 } from 'uuid';
import { base64FromPath } from "@ionic/react-hooks/filesystem"
import { Recipe } from '../interfaces/recipe';
const { Storage } = Plugins;

function usePhotoGallery() {
  const { getPhoto } = useCamera();
  const [photo, setPhoto] = useState<string>('');

  const takePhoto = async () => {
    const cameraPhoto = await getPhoto({
      resultType: CameraResultType.Uri,
      source: CameraSource.Camera,
      quality: 100
    });

    const base64Data = await base64FromPath(cameraPhoto.webPath!);
    setPhoto(base64Data)
  };

  return {
    photo,
    takePhoto
  };
}

interface RecipeFormProps extends RouteComponentProps<{
  id?: string;
}> { }

const RecipeForm: React.FC<RecipeFormProps> = ({ match, history }) => {
  const { photo, takePhoto } = usePhotoGallery();
  const [title, setTitle] = useState<string>('')
  const [steps, setSteps] = useState<string>('')
  const [existingPhoto, setExistingPhoto] = useState<string>('')

  const saveRecipes = async (data: Recipe[]) => {
    await Storage.set({
      key: 'recipes',
      value: JSON.stringify(data)
    });
  }

  const save = async () => {
    if (!title) {
      alert('Title is required')
      return
    }

    if (!steps) {
      alert('Steps is required')
      return
    }
    const recipe: Recipe = {
      photo,
      title,
      steps,
      id: uuidv4()
    }

    const ret = await Storage.get({ key: 'recipes' });
    const recipes: Recipe[] = JSON.parse(ret.value as string) || [];
    if (!match.params.id) {
      await saveRecipes([...recipes, recipe])
      setExistingPhoto('')
      setTitle('')
      setSteps('')
    }
    else {
      const recipeIndex = recipes.findIndex(r => r.id === match.params.id)
      recipes[recipeIndex] = {
        photo,
        title,
        steps,
        id:match.params.id
      }
      await saveRecipes(recipes)
    }
    history.push('/');
  }

  const getRecipe = useCallback(async () => {
    if (match.params.id) {
      const ret = await Storage.get({ key: 'recipes' });
      const recipes: Recipe[] = JSON.parse(ret.value as string) || [];
      const recipe = recipes.find(r => r.id === match.params.id)
      if (match.params.id && recipe) {
        const { photo, title, steps } = recipe;
        setExistingPhoto(photo)
        setTitle(title)
        setSteps(steps)
      }
    }
  }, [match.params.id])

  useEffect(() => {
    getRecipe()
  }, [getRecipe, match.params.id])

  useEffect(() => {
    setExistingPhoto(photo)
  }, [photo])

  return (
    <IonPage>
      <IonHeader>
        <IonToolbar>
          <IonButtons slot="start">
            <IonBackButton defaultHref="/" />
          </IonButtons>
          <IonTitle>{match.params.id ? 'Edit' : 'Add'} Recipe Form</IonTitle>
        </IonToolbar>
      </IonHeader>
      <IonContent fullscreen>
        <IonHeader collapse="condense">
          <IonToolbar>
            <IonTitle size="large">Recipe Form</IonTitle>
          </IonToolbar>
        </IonHeader>
        <IonList>
          <IonItem>
            <IonButton onClick={takePhoto}>Take Recipe Photo</IonButton>
          </IonItem>
          <IonItem>
            <IonLabel>Recipe Photo</IonLabel>
            {existingPhoto && <IonImg src={existingPhoto} />}
          </IonItem>
          <IonItem lines="none">
            <IonInput value={title} placeholder="Title" onIonChange={e => setTitle(e.detail.value!)}></IonInput>
          </IonItem>
          <IonItem lines="none">
            <IonTextarea rows={10} value={steps} placeholder="Steps" onIonChange={e => setSteps(e.detail.value!)}></IonTextarea>
          </IonItem>
          <IonItem lines="none">
            <IonButton onClick={save}>Save</IonButton>
          </IonItem>
        </IonList>
      </IonContent>
    </IonPage >
  );
};

export default RecipeForm;

In this component, we create the usePhotoGallery Hook to let us take a photo and return the Base64 string version of it.

In the Hook, we call the useCamera hook to get the getPhoto function, which we call to take the photo. resultType is set to CameraResultType.Uri to return the URL to the photo. We set the photo source to the phone’s camera with CameraSource.Camera. quality is set to a number; 100 is high quality.

It returns a promise with the camera photo data. The base64FromPath lets us get the Base64 string version of the photo. We call setPhoto to get the photo, and we return the photo and takePhoto objects so we can use them in our RecipeForm component.

In the RecipeForm component, we have the match prop to get the ID of the recipe entry. history has the browser history object, which lets us navigate programmatically.

The saveRecipes function, if it wasn’t obvious, saves the recipes data; it’s used in the save function. In it, we check for the title and steps states. If they’re set, then we proceed to saving the entry.

title is the recipe title, and steps has the content for the recipe steps. We then create the recipe object with an ID. This is used for new entries. We call Storage.get to get all the entries with key recipes, then we parse it with JSON.parse.

If match.params.id is falsy, which means we’re adding an entry, we add the entry to the end of the array of recipes and call saveRecipes with it. We clear all the states after that. Otherwise, we find the index of the recipes array with the findIndex method, then we update the entry with our states.

We then call saveRecipes to save the data, then history.push to move back to the homepage. The getRecipe function is used to get the recipe data for an existing entry if match.params.id exists.

We set the photo, title, and steps state from the retrieved entry. We watch match.params.id, so we always call getRecipe if we get a new value for it.

Then we use the useEffect Hook to watch for match.params.id, and we call getRecipe when they change. match.params.id has the ID of the recipe, so we use it to get the recipe when we click Edit.

We also set the existingPhoto state when we get the photo state updated so that we can display it — it’s easier to only display existingPhoto rather than display both with different conditions. photo and existingPhoto are both Base64 strings of the recipe photo.

In the JSX, we have the IonList with a button to call takePhoto to take the photo, and we have inputs for the title and steps. We also added a Save button to save the entry by calling save.

Finally, we’ll build an interface for our components. Create the file src/interfaces/recipe.ts and add:

// src/interfaces/recipe.ts

export interface Recipe {
  photo: string;
  title: string;
  steps: string;
  id: string;
}

Running the app with Genymotion

To run our app with Genymotion, we need to do a few more things. First, we create an assets folder by running:

ionic build

We’ll need this to run and build our Android app. Next, add the Android dependencies by running:

npx cap add android
npx cap sync

Now we need to install Android Studio and Genymotion, then we install the Genymotion plugin for Android Studio. Once that’s complete, we run our Ionic React project with the following command:

ionic capacitor run android --livereload --external --address=0.0.0.0

When asked, we should choose ‘VirtualBox Virtual Adapter’. This way, we can use the internet from Genymotion. Then, in Android Studio, hit Alt+Shift+F10, and we should see our app running in Genymotion.

Conclusion

With Ionic, we can use our knowledge of React to create an Android mobile app that can access hardware and work with storage devices.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

John Au-Yeung I'm a web developer interested in JavaScript stuff.

Leave a Reply