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>
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 startnpx 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
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
// App.jsimport 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 (<BottomNavigationnavigationState={{ index, routes }}onIndexChange={setIndex}renderScene={renderScene}/>);};export default App;
file: android/app/build.gradle
apply plugin: "com.android.application"import com.android.build.OutputFileimport org.apache.tools.ant.taskdefs.condition.Osapply 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 (<FormikinitialValues={{// ...}}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={<RefreshControlcolors={['#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 logiclistRef?.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});
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}><Iconstyle={style.webviewCloseButton}size={25}color={theme.colors.primary}name="close-circle-outline"/></TouchableOpacity><WebViewsource={{ uri: webviewUrl }}style={style.webview}startInLoadingStaterenderLoading={() => (<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.jsimport { 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.jsimport { 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.jsexport const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
Wrapped app with Context and its usage
// App.jsimport 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><BottomNavigationnavigationState={{ 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-nativenpx @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.jsimport 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><BottomNavigationnavigationState={{ index, routes }}onIndexChange={onIndexChange}renderScene={renderScene}/></AppWrapper>);};// ...
The following code can log custom events.
// src/utils/analytics.jsimport 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.