DEV Community

Željko Šević
Željko Šević

Posted on • Originally published at sevic.dev on

Android app development with React Native

This post covers the main notes needed, from bootstrapping the app to publishing to the Play store.

Prerequisites

  • experience with React
  • installed Android studio

Bootstrapping the app

Run the following commands

npx react-native init <app_name>
cd <app_name>
Enter fullscreen mode Exit fullscreen mode

Running the app on the device via USB

Enable developer options, USB debugging, and USB file transfer on the device. Run the following commands

npx react-native start
npx react-native run-android
Enter fullscreen mode Exit fullscreen mode

Install the app via the following command

npx react-native run-android --variant=release
Enter fullscreen mode Exit fullscreen mode

App name

It can be changed in android/app/src/main/res/values/strings.xml file

Logo

Icon Kitchen can be used for generating images. Downloaded images should be stored in mipmap (android/app/src/main/res/mipmap-*hdpi/ic_launcher.png) folders.

Splash screen

A splash screen is the first thing user sees after opening the app, and it usually shows an app logo with optional animations. More details are covered in Splash screen with React Native post

Bottom navigation bar

react-native-paper provides a bottom navigation bar component, and route keys are mapped with the components. react-native-vector-icons is needed for the proper vector rendering, and a list of available icons can be found here

npm i react-native-paper react-native-vector-icons
Enter fullscreen mode Exit fullscreen mode
// App.js
import React, { useState } from 'react';
import { StyleSheet } from 'react-native';
import { BottomNavigation, Text } from 'react-native-paper';

const HomeRoute = () => <Text style={style.text}>Home</Text>;
const SettingsRoute = () => <Text style={style.text}>Settings</Text>;

const style = StyleSheet.create({
  text: {
    textAlign: 'center'
  }
});

const App = () => {
  const [index, setIndex] = useState(0);
  const [routes] = useState([
    {
      key: 'home',
      title: 'Home',
      icon: 'home'
    },
    {
      key: 'settings',
      title: 'Settings',
      icon: 'settings-helper'
    }
  ]);

  const renderScene = BottomNavigation.SceneMap({
    home: HomeRoute,
    settings: SettingsRoute
  });

  return (
    <BottomNavigation
      navigationState={{ index, routes }}
      onIndexChange={setIndex}
      renderScene={renderScene}
    />
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

file: android/app/build.gradle

apply plugin: "com.android.application"

import com.android.build.OutputFile
import org.apache.tools.ant.taskdefs.condition.Os

apply from: "../../node_modules/react-native-vector-icons/fonts.gradle" // <-- ADD THIS
// ...
Enter fullscreen mode Exit fullscreen mode

Forms

formik and yup libraries can handle custom forms and complex form validations (including the case when one field validation depends on other fields' value). Values inside nested components can be set with setFieldValue function.

const FormSchema = Yup.object().shape({
  rentOrSale: Yup.string().required(customErrorMessage),
  furnished: Yup.array()
    .of(Yup.string())
    .when('rentOrSale', (rentOrSale, schema) => {
      if (rentOrSale === 'rent') {
        return schema.min(1, customErrorMessage);
      }
      return schema;
    })
});

export const CustomForm = () => {
  const handleCustomSubmit = async (values) => {
    // ...
  };

  return (
    <Formik
      initialValues={
        {
          // ...
        }
      }
      validationSchema={FormSchema}
      onSubmit={handleCustomSubmit}
    >
      {({ errors, touched, handleSubmit, setFieldValue }) => (
        <View>
          {/* */}
          {touched.furnished && errors.furnished && (
            <Text style={style.errorMessage}>{errors.furnished}</Text>
          )}
          <Button style={style.submitButton} onPress={handleSubmit}>
            Submit
          </Button>
        </View>
      )}
    </Formik>
  );
};
Enter fullscreen mode Exit fullscreen mode

Lists

FlatList component can handle list data. It shouldn't be nested inside the ScrollView component. Its header and footer should be defined in ListHeaderComponent and ListFooterComponent props.

// ...
return (
  <FlatList
    // ...
    renderItem={({ item }) => <ApartmentCard apartment={item} />}
    ListHeaderComponent={/* */}
    ListFooterComponent={/* */}
  />
);
// ...
Enter fullscreen mode Exit fullscreen mode

Its child component should be wrapped as a higher-order component with memo to optimize rendering.

import React, { memo } from 'react';

const ApartmentCard = ({ apartment }) => {
  /* */
};

export default memo(ApartmentCard);
Enter fullscreen mode Exit fullscreen mode

Loading data

FlatList can show a refresh indicator (loader) when data is loading. progressViewOffset prop sets the vertical position of the loader.

import { Dimensions, FlatList, RefreshControl } from 'react-native';
// ...
<FlatList
  // ...
  refreshControl={
    <RefreshControl
      colors={['#3366CC']}
      progressViewOffset={Dimensions.get('window').height / 2}
      onRefresh={() => {
        console.log('loading data...');
      }}
      refreshing={isLoading}
    />
  }
/>;
Enter fullscreen mode Exit fullscreen mode

Scrolling

FlatList also provides a scrolling (to its items) feature when its size changes. Specify its reference and fallback function for scrolling (onScrollToIndexFailed).

import React, { useRef } from 'react';
// ...
const listRef = useRef();
// ...
return (
  <FlatList
    // ...
    ref={listRef}
    onContentSizeChange={() => {
      // some custom logic
      listRef?.current?.scrollToIndex({ index, animated: false });
    }}
    onScrollToIndexFailed={(info) => {
      console.error('scrolling failed', info);
    }}
  />
);
// ...
Enter fullscreen mode Exit fullscreen mode

One of the additional scrolling functions is based on the offset.

import { Dimensions } from 'react-native';

// ...
listRef?.current?.scrollToOffset({
  offset: Dimensions.get('window').height + 250
});
Enter fullscreen mode Exit fullscreen mode

Scrolling to the top can be done with offset 0.

// ...
listRef?.current?.scrollToOffset({
  offset: 0,
  animated: false
});
Enter fullscreen mode Exit fullscreen mode

Links

Linking.openURL(url) method opens a specific link in an external browser. A webview can open a link inside the app, and it can also override the back button handler.

// ...
import { BackHandler /* */ } from 'react-native';
import { WebView } from 'react-native-webview';

const handleClosingWebview = () => {
  // some custom logic
};

useEffect(() => {
  const backHandler = BackHandler.addEventListener(
    'hardwareBackPress',
    function() {
      handleClosingWebview();
      return true;
    }
  );

  return () => backHandler.remove();
}, []);

// ...
if (isWebviewOpen) {
  return (
    <SafeAreaView style={style.webview}>
      <TouchableOpacity onPress={handleClosingWebview}>
        <Icon
          style={style.webviewCloseButton}
          size={25}
          color={theme.colors.primary}
          name="close-circle-outline"
        />
      </TouchableOpacity>
      <WebView
        source={{ uri: webviewUrl }}
        style={style.webview}
        startInLoadingState
        renderLoading={() => (
          <View style={style.webviewLoader}>
            <ActivityIndicator color={theme.colors.primary} />
          </View>
        )}
      />
    </SafeAreaView>
  );
}
// ...
Enter fullscreen mode Exit fullscreen mode

SVG files

react-native-svg library can be used for handling SVG files.

import React from 'react';
import { SvgXml } from 'react-native-svg';

export function Logo() {
  const xml = `<svg>...</svg>`;

  return <SvgXml xml={xml} />;
}
Enter fullscreen mode Exit fullscreen mode

State management

React provides Context to deal with state management without external libraries.

Context setup with app wrapper

// src/context/index.js
import { createContext, useContext, useMemo, useReducer } from 'react';
import { appReducer, initialState } from './reducer';

const appContext = createContext(initialState);

export function AppWrapper({ children }) {
  const [state, dispatch] = useReducer(appReducer, initialState);

  const contextValue = useMemo(() => {
    return { state, dispatch };
  }, [state, dispatch]);

  return (
    <appContext.Provider value={contextValue}>{children}</appContext.Provider>
  );
}

export function useAppContext() {
  return useContext(appContext);
}
Enter fullscreen mode Exit fullscreen mode

Reducer setup

// src/context/reducer.js
import { INCREMENT_COUNTER } from './constants';

export const initialState = {
  counter: 0
};

export const appReducer = (state, action) => {
  switch (action.type) {
    case INCREMENT_COUNTER: {
      return {
        ...state,
        counter: state.counter + 1
      };
    }
    default:
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode
// src/context/constants.js
export const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
Enter fullscreen mode Exit fullscreen mode

Wrapped app with Context and its usage

// App.js
import React, { useEffect, useState } from 'react';
import { StyleSheet } from 'react-native';
import SplashScreen from 'react-native-splash-screen';
import { BottomNavigation, Button, Text } from 'react-native-paper';
import { AppWrapper, useAppContext } from './src/context';
import { INCREMENT_COUNTER } from './src/context/constants';

const HomeRoute = () => {
  const { state } = useAppContext();

  return <Text style={style.text}>counter: {state.counter}</Text>;
};

const SettingsRoute = () => {
  const { dispatch } = useAppContext();

  const onPress = () => {
    dispatch({ type: INCREMENT_COUNTER });
  };

  return <Button onPress={onPress}>Increment counter</Button>;
};

const style = StyleSheet.create({
  text: {
    textAlign: 'center'
  }
});

const App = () => {
  const [index, setIndex] = useState(0);
  const [routes] = useState([
    {
      key: 'home',
      title: 'Home',
      icon: 'home'
    },
    {
      key: 'settings',
      title: 'Settings',
      icon: 'settings-helper'
    }
  ]);

  const renderScene = BottomNavigation.SceneMap({
    home: HomeRoute,
    settings: SettingsRoute
  });

  useEffect(() => SplashScreen.hide(), []);

  return (
    <AppWrapper>
      <BottomNavigation
        navigationState={{ index, routes }}
        onIndexChange={setIndex}
        renderScene={renderScene}
      />
    </AppWrapper>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Custom events

React Native provides NativeEventEmitter for handling custom events so components can communicate with each other in that way.

import { NativeEventEmitter } from 'react-native';
const eventEmitter = new NativeEventEmitter();

eventEmitter.emit('custom-event', { data: 'test' });

eventEmitter.addListener('custom-event', (event) => {
  console.log(event); // { data: 'test' }
});
Enter fullscreen mode Exit fullscreen mode

Local storage

@react-native-async-storage/async-storage can handle storage system in asynchronous way

import AsyncStorage from '@react-native-async-storage/async-storage';

export async function getItem(key) {
  try {
    const value = await AsyncStorage.getItem(key);
    if (value) {
      return JSON.parse(value);
    }

    return value;
  } catch (error) {
    console.error(`Failed getting the item ${key}`, error);
    return null;
  }
}

export async function setItem(key, value) {
  try {
    await AsyncStorage.setItem(key, JSON.stringify(value));
  } catch (error) {
    console.error(`Failed setting the item ${key}`, error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Error tracing

Sentry can be used for it

Prerequisites

  • React Native project created

Setup

Run the following commands

npm i @sentry/react-native
npx @sentry/wizard -i reactNative -p android
Enter fullscreen mode Exit fullscreen mode

Analytics

It is helpful to have more insights about app usage, like custom events, screen views, numbers of installations/uninstallations, etc. React Native Firebase provides analytics as one of the services.

Prerequisites

  • created Firebase project

Setup

Create an Android app within created Firebase project. The package name should be the same as the one specified in the Android manifest (android/app/src/main/AndroidManifest.xml). Download google-service.json file and place it inside android/app folder.

Extend the following files

file: android/app/build.gradle

apply plugin: "com.android.application"
apply plugin: "com.google.gms.google-services" <-- ADD THIS
Enter fullscreen mode Exit fullscreen mode

file: android/build.gradle

buildscript {
  // ...
  dependencies {
    // ...
    classpath("com.google.gms:google-services:4.3.14") <-- ADD THIS
  }
}
// ...
Enter fullscreen mode Exit fullscreen mode

Run the following commands

npm i @react-native-firebase/app @react-native-firebase/analytics
Enter fullscreen mode Exit fullscreen mode

Usage

The following change can log screen views.

// App.js
import analytics from '@react-native-firebase/analytics';
// ...
const App = () => {
  // ...
  const onIndexChange = (i) => {
    if (index === i) {
      return;
    }

    setIndex(i);
    analytics()
      .logScreenView({
        screen_class: routes[i].key,
        screen_name: routes[i].key
      })
      .catch(() => {});
  };
  // ...
  return (
    <AppWrapper>
      <BottomNavigation
        navigationState={{ index, routes }}
        onIndexChange={onIndexChange}
        renderScene={renderScene}
      />
    </AppWrapper>
  );
};
// ...
Enter fullscreen mode Exit fullscreen mode

The following code can log custom events.

// src/utils/analytics.js
import analytics from '@react-native-firebase/analytics';

export const trackCustomEvent = async (eventName, params) => {
  analytics()
    .logEvent(eventName, params)
    .catch(() => {});
};
Enter fullscreen mode Exit fullscreen mode

Publishing to the Play store

Prerequisites

  • Verified developer account on Google Play Console
  • Paid one-time fee (25$)

Internal testing

Internal testing on Google Play Console is used for testing app versions before releasing them to the end users. Read more about it on Internal testing React Native apps post

Screenshots

Screenshots.pro can be used for creation of screenshots

Boilerplate

Here is the link to the boilerplate I use for the development.

Top comments (0)