Using Material Design / Material UI in a React Native App
This post originally appeared as Using Material UI in React Native on blog.logrocket.com
If you’re building a cross-platform mobile app, it’s a good idea to base your app’s UI/UX on Material Design, Google’s own design language, which it uses in all its mobile apps. The most popular mobile apps heavily use Material Design concepts: Whatsapp, Uber, Lyft, Google Maps, SpotAngels, etc. This means your users are already familiar with the look and feel of Material Design, and they will understand how to use your app more easily if you adhere to the design language of their favorite, most commonly used apps.
The heavy hitter of Material Design component libraries on React Native is react-native-paper, and this guide will focus on using react-native-paper to set up a starter app with the some of the most prominent and recognizable Material Design features: Hamburger Menu, Drawer Navigation, FAB (Floating Action Button), and Contextual Action Bar.
Sections
- Demo
- Setup
- Initial Screens
- Hamburger Menu / Drawer Navigation
- Floating Action Button (FAB)
- Contextual Action Bar
- Theming
- Conclusion
Demo
This is what the starter app I’m going to build will eventually look like. All the code for this demo is available in this GitHub repo: material-ui-in-react-native.
Setup
First, I’ll initialize my React Native app using Expo. You don’t have to use Expo, it just helps me get started, so I can focus on the UI in this example.
If you don’t have expo-cli
installed, then first run:
npm install -g expo-cli
Now run the following:
expo init material-ui-in-react-native -t expo-template-blank-typescript
cd material-ui-in-react-native
yarn add react-native-paper
I’m also adding react-navigation to this project. I recommend you use it as well. It’s the most popular navigation library for React Native, and there’s more support for running it alongside react-native-paper compared to other navigation libraries. Follow the installation instructions for react-navigation, since they are slightly different, depending on whether you use Expo or plain React Native.
Initial Screens
Create the following two files in your app’s main directory (if you want the styles used, remember, everything for this example is available in this GitHub repo):
MyFriends.tsx
import React from 'react';
import {View} from 'react-native';
import {Title} from 'react-native-paper';
import base from './styles/base';
interface IMyFriendsProps {}
const MyFriends: React.FunctionComponent<IMyFriendsProps> = (props) => {
return (
<View style={base.centered}>
<Title>MyFriends</Title>
</View>
);
};
export default MyFriends;
Profile.tsx
import React from 'react';
import {View} from 'react-native';
import {Title} from 'react-native-paper';
import base from './styles/base';
interface IProfileProps {}
const Profile: React.FunctionComponent<IProfileProps> = (props) => {
return (
<View style={base.centered}>
<Title>Profile</Title>
</View>
);
};
export default Profile;
Over the course of this guide, I’ll link these screens to each other using a Navigation Drawer (or Hamburger Menu) and add Material UI components to each of them.
Hamburger Menu / Drawer Navigation
Material Design promotes the usage of a Navigation Drawer, so I’ll use this type of UI to make the My Friends and Profile screens navigable to and from each other.
First, I’ll add React Navigation’s drawer
library:
yarn add @react-navigation/native @react-navigation/drawer
Now I’ll add the following into my App.tsx
to enable Drawer Navigation.
It should look like the following:
App.tsx
import React from 'react';
import {createDrawerNavigator} from '@react-navigation/drawer';
import {NavigationContainer} from '@react-navigation/native';
import {StatusBar} from 'expo-status-bar';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import MyFriends from './MyFriends';
import Profile from './Profile';
export default function App() {
const Drawer = createDrawerNavigator();
return (
<SafeAreaProvider>
<NavigationContainer>
<Drawer.Navigator>
<Drawer.Screen name='My Friends' component={MyFriends} />
<Drawer.Screen name='Profile' component={Profile} />
</Drawer.Navigator>
</NavigationContainer>
<StatusBar style='auto' />
</SafeAreaProvider>
);
}
This drawer also needs a button to open it. That button should look like the classic hamburger icon (β‘) and it should open the navigation drawer when pressed. Here’s what that button might look like:
components/MenuIcon.tsx
import React from 'react';
import {IconButton} from 'react-native-paper';
import {DrawerActions, useNavigation} from '@react-navigation/native';
import {useCallback} from 'react';
export default function MenuIcon() {
const navigation = useNavigation();
const openDrawer = useCallback(() => {
navigation.dispatch(DrawerActions.openDrawer());
}, []);
return <IconButton icon='menu' size={24} onPress={openDrawer} />;
}
A few things to notice here:
-
React-navigation’s
useNavigation
hook is how we are going to execute most navigation actions, from changing screens to opening drawers. -
The
<IconButton>
component is from react-native-paper. It supports all the Material Design icons by name and optionally supports any ReactNode that you want to pass in there, which allows one to add in any desired icon from any third-party library.
Now I’ll add my <MenuIcon>
to my Navigation Drawer by replacing this from
App.tsx
:
<Drawer.Navigator>
...
</Drawer.Navigator>
With the following:
import MenuIcon from './components/MenuIcon.tsx';
...
<Drawer.Navigator
screenOptions={{headerShown: true, headerLeft: () => <MenuIcon />}}
>
...
</Drawer.Navigator>
Lastly, I can customize my Navigation Drawer using the drawerContent
prop
of the same <Drawer.Navigator>
component I just altered.
I’ll show an example which adds a header image to the top of the drawer. Feel free to customize with whatever you want to put in the drawer:
components/MenuContent.tsx
import React from 'react';
import {
DrawerContentComponentProps,
DrawerContentScrollView,
DrawerItemList,
} from '@react-navigation/drawer';
import {Image} from 'react-native';
const MenuContent: React.FunctionComponent<DrawerContentComponentProps> = (
props
) => {
return (
<DrawerContentScrollView {...props}>
<Image
resizeMode='cover'
style={{width: '100%', height: 140}}
source={require('../assets/drawerHeaderImage.jpg')}
/>
<DrawerItemList {...props} />
</DrawerContentScrollView>
);
};
export default MenuContent;
Now I’ll pass <MenuContent>
into <Drawer.Navigator>
.
To do this, I’ll make the following change in App.tsx
from this:
import MenuIcon from './components/MenuIcon.tsx';
...
<Drawer.Navigator
screenOptions={{headerShown: true, headerLeft: () => <MenuIcon />}}
>
...
</Drawer.Navigator>
to this:
import MenuIcon from './components/MenuIcon.tsx';
import MenuContent from './components/MenuContent.tsx';
...
<Drawer.Navigator
screenOptions={{headerShown: true, headerLeft: () => <MenuIcon />}}
drawerContent={(props) => <MenuContent {...props} />}
>
...
</Drawer.Navigator>
And now, I have fully functioning Drawer Navigation with a custom image header. Here’s the result:
Next, I’ll flesh out the main screens with more Material Design concepts.
Floating Action Button (FAB)
One of the hallmarks of Material Design is the Floating Action Button
(or FAB). The <FAB>
and <FAB.Group>
components provide a useful
implementation of the Floating Action Button according to Material Design
principles. With minimal setup, I’ll add this to the My Friends screen
right now.
First, I’ll need to add the <Provider>
component from react-native-paper
and wrap that component around the <NavigationContainer
> in App.tsx
as
follows:
App.tsx
import {Provider} from 'react-native-paper';
...
<Provider>
<NavigationContainer>
...
</NavigationContainer>
</Provider>
Now I’ll add my Floating Action Button to the My Friends screen. I need
- The
<Portal>
and<FAB.Group>
components from react-native-paper - A state variable
fabIsOpen
to keep track of whether the FAB is open or closed - Some information about whether or not this screen is currently visible to
the user (
isScreenFocused
). I needisScreenFocused
because without it, I might end up with the FAB being visible on other screens than the My Friends screen
Hereβs what the My Friends screen looks like with all that added in:
MyFriends.tsx
import {useIsFocused} from '@react-navigation/native';
import React, {useState} from 'react';
import {View} from 'react-native';
import {FAB, Portal, Title} from 'react-native-paper';
import base from './styles/base';
interface IMyFriendsProps {}
const MyFriends: React.FunctionComponent<IMyFriendsProps> = (props) => {
const isScreenFocused = useIsFocused();
const [fabIsOpen, setFabIsOpen] = useState(false);
return (
<View style={base.centered}>
<Title>MyFriends</Title>
<Portal>
<FAB.Group
visible={isScreenFocused}
open={fabIsOpen}
onStateChange={({open}) => setFabIsOpen(open)}
icon={fabIsOpen ? 'close' : 'account-multiple'}
actions={[
{
icon: 'plus',
label: 'Add new friend',
onPress: () => {},
},
{
icon: 'file-export',
label: 'Export friend list',
onPress: () => {},
},
]}
/>
</Portal>
</View>
);
};
export default MyFriends;
Now the My Friends screen behaves like the following:
Next, I’ll add a Contextual Action Bar, which can be activated whenever an item in one of the screens is long-pressed.
Contextual Action Bar
Apps like Gmail and Google Photos make use of a Material Design concept called the Contextual Action Bar. I’ll implement a version of this quickly in the current app.
First, I’ll build the ContextualActionBar
component itself using the
Appbar component from react-native-paper.
It should look something like this, to start with:
./components/ContextualActionBar.tsx
import React from 'react';
import {Appbar} from 'react-native-paper';
interface IContextualActionBarProps {}
const ContextualActionBar: React.FunctionComponent<IContextualActionBarProps> = (
props
) => {
return (
<Appbar.Header {...props} style={{width: '100%'}}>
<Appbar.Action icon='close' onPress={() => {}} />
<Appbar.Content title='' />
<Appbar.Action icon='delete' onPress={() => {}} />
<Appbar.Action icon='content-copy' onPress={() => {}} />
<Appbar.Action icon='magnify' onPress={() => {}} />
<Appbar.Action icon='dots-vertical' onPress={() => {}} />
</Appbar.Header>
);
};
export default ContextualActionBar;
Now I want this component to render on top of the given screen’s header whenever an item is long pressed. Back in the My Friends screen, I’ve added some items for this purpose. On that screen, here’s how I’ll render the Contextual Action Bar over the screen’s header:
MyFriends.tsx
import {useNavigation} from '@react-navigation/native';
import ContextualActionBar from './components/ContextualActionBar';
...
const [cabIsOpen, setCabIsOpen] = useState(false);
const navigation = useNavigation();
const openHeader = useCallback(() => {
setCabIsOpen(!cabIsOpen);
}, [cabIsOpen]);
useEffect(() => {
if (cabIsOpen) {
navigation.setOptions({
// have to use props: any since that's the type signature
// from react-navigation...
header: (props: any) => (<ContextualActionBar {...props} />),
});
} else {
navigation.setOptions({header: undefined});
}
}, [cabIsOpen]);
...
return (
...
<List.Item
title='Friend #1'
description='Mar 18 | 3:31 PM'
style={{width: '100%'}}
onPress={() => {}}
onLongPress={openHeader}
/>
...
);
Above, I’m toggling a state boolean value (cabIsOpen
) whenever a given item
is long pressed. Based on that value, I either switch the React Navigation
header to render the <ContextualActionBar>
or switch back to render the
default React Navigation header.
Now I should have a Contextual Action Bar appear when I long-press the
“Friend #1” item. However, the title is still empty and I cannot do anything
in any of the actions because the <ContextualActionBar>
is unaware of any
of the state of either the “Friend #1” item or the larger My Friends screen
as a whole.
Thus, the next step is to add pass a title into the <ContextualActionBar>
and pass in a function which can close the bar and be triggered by one of the
buttons in the bar.
To do this, I have to add another state variable to the My Friends screen:
const [selectedItemName, setSelectedItemName] = useState('');
I also need to create a function which will close the header and reset the above state variable:
const closeHeader = useCallback(() => {
setCabIsOpen(false);
setSelectedItemName('');
}, []);
Then I need to pass both selectedItemName
and closeHeader
as props to
<ContextualActionBar>
:
useEffect(() => {
if (cabIsOpen) {
navigation.setOptions({
header: (props: any) => (
<ContextualActionBar
{...props}
title={selectedItemName}
close={closeHeader}
/>
),
});
} else {
navigation.setOptions({header: undefined});
}
}, [cabIsOpen, selectedItemName]);
Lastly, I need to set selectedItemName
to the title of the item that’s been
long pressed:
const openHeader = useCallback((str: string) => {
setSelectedItemName(str);
setCabIsOpen(!cabIsOpen);
}, [cabIsOpen]);
...
return (
...
<List.Item
title='Friend #1'
...
onLongPress={() => openHeader('Friend #1')}
/>
);
And now I can use the title
and close
props in <ContextualActionBar>
as
follows:
./components/ContextualActionBar.tsx
interface IContextualActionBarProps {
title: string;
close: () => void;
}
...
return (
...
<Appbar.Action icon='close' onPress={props.close} />
<Appbar.Content title={props.title} />
...
);
Now, I have a functional, Material Design-inspired Contextual Action Bar, utilizing react-native-paper and react-navigation, which looks like the following:
Theming
The last thing I want to do is theme my app so I can change the primary color, secondary color, text colors, etc.
Theming is a little tricky because both react-navigation and
react-native-paper have their own ThemeProvider
components, and they can
easily conflict with each other. Fortunately, there’s a great guide available
on
how to theme an app which uses both react-native-paper and react-navigation.
If you follow this, you should be all set to go.
I’ll add in a little extra help for those who use Typescript and would run into esoteric errors trying to follow the above guide.
First, Iβll create a theme file which looks like the following. A few things to note are:
- The return type of
combineThemes
encompasses bothReactNavigationTheme
andReactNativePaper.Theme
- I changed the
primary
andaccent
colors, which will affect the CAB and FAB respectively - I added a new color to the theme called
animationColor
. If you don’t want to add a new color, you don’t need to declare the global namespace
theme.ts
import {
DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme,
Theme,
} from '@react-navigation/native';
import {ColorSchemeName} from 'react-native';
import {
DarkTheme as PaperDarkTheme,
DefaultTheme as PaperDefaultTheme,
} from 'react-native-paper';
declare global {
namespace ReactNativePaper {
interface ThemeColors {
animationColor: string;
}
interface Theme {
statusBar: 'light' | 'dark' | 'auto' | 'inverted' | undefined;
}
}
}
interface ReactNavigationTheme extends Theme {
statusBar: 'light' | 'dark' | 'auto' | 'inverted' | undefined;
}
export function combineThemes(
themeType: ColorSchemeName
): ReactNativePaper.Theme | ReactNavigationTheme {
const CombinedDefaultTheme: ReactNativePaper.Theme = {
...NavigationDefaultTheme,
...PaperDefaultTheme,
statusBar: 'dark',
colors: {
...NavigationDefaultTheme.colors,
...PaperDefaultTheme.colors,
animationColor: '#2922ff',
primary: '#079c20',
accent: '#2922ff',
},
};
const CombinedDarkTheme: ReactNativePaper.Theme = {
...NavigationDarkTheme,
...PaperDarkTheme,
mode: 'adaptive',
statusBar: 'light',
colors: {
...NavigationDarkTheme.colors,
...PaperDarkTheme.colors,
animationColor: '#6262ff',
primary: '#079c20',
accent: '#2922ff',
},
};
return themeType === 'dark' ? CombinedDarkTheme : CombinedDefaultTheme;
}
Then, back in App.tsx
Iβll add my theme to both the react-native-paper
Provider
component and the NavigationContainer
component from
react-navigation as follows:
App.tsx
import {useColorScheme} from 'react-native';
import {NavigationContainer, Theme} from '@react-navigation/native';
import {combineThemes} from './theme';
...
const colorScheme = useColorScheme() as 'light' | 'dark';
const theme = combineThemes(colorScheme);
...
<Provider theme={theme as ReactNativePaper.Theme}>
<NavigationContainer theme={theme as Theme}>
</NavigationContainer>
</Provider>
...
I am using Expo, so I additionally need to add the following in app.json
to
enable dark mode. You may not need to:
"userInterfaceStyle": "automatic",
And now, a custom-themed, dark-mode-enabled, Material Design-inspired app! It looks great!
Conclusion
If you followed along to the end with me here, then you should have your own cross-platform app with Material Design elements from the react-native-paper library like Drawer Navigation (with custom designs in the drawer menu), Floating Action Buttons, and Contextual Action Bars. You should also have theming enabled which plays nicely with both the react-native-paper and react-navigation libraries. This setup should enable you to quickly and stylishly build out your next mobile app with ease.