homeresume
 
   

Android app development with React Native

Published November 10, 2022Last updated October 23, 20249 min read

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>

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

Install the app via the following command

npx react-native run-android --variant=release

App name

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

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

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

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

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={/* */}
/>
);
// ...

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

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}
/>
}
/>;

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);
}}
/>
);
// ...

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

Scrolling to the top can be done with offset 0.

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

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>
);
}
// ...

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} />;
}

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

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;
}
};
// src/context/constants.js
export const INCREMENT_COUNTER = 'INCREMENT_COUNTER';

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;

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

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

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

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

file: android/build.gradle

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

Run the following commands

npm i @react-native-firebase/app @react-native-firebase/analytics

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>
);
};
// ...

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(() => {});
};

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.