Material 3 Navigation Drawer with Android Jetpack Compose
Interconnecting the Navigation Drawer and NavController together
In the Material 2 design, the navigation drawer is part of the Scaffold. With the progress to Material 3, the navigation drawer has become an independent composable and can be nicely implemented with NavController.
Photo by Maksym Kaharlytskyi on Unsplash
Firstly, let’s have a look at what we are going to build. It is a simple app with a serial introduction launched at the first start. After the intro, the user can use the app and can be navigated via the navigation drawer.
Navigation in the sample app
For an introduction to how to use NavController and NavHost, please read this article
App preview
Here is the foundation of the app. This is the main composable run at the start-up of the app.
/// initial remember statements to initialize the navigation and drawer
@Composable
fun MainCompose(
navController: NavHostController = rememberNavController(),
drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed),
vm: IntroViewModel = hiltViewModel() // logic can be implemented as you want
) {
AppTheme {
Surface { // the main drawer composable, which creates the actual drawer
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawerContent(
drawerState = drawerState,
menuItems = DrawerParams.drawerButtons,
defaultPick = MainNavOption.HomeScreen
) { onUserPickedOption ->
// when user picks, the path - navigates to new one
when (onUserPickedOption ) {
MainNavOption.HomeScreen -> {
navController.navigate(onUserPickedOption.name) {
// pops the route to root and places new screen
popUpTo(NavRoutes.MainRoute.name)
}
}
MainNavOption.SettingsScreen -> {
navController.navigate(onUserPickedOption.name) {
popUpTo(NavRoutes.MainRoute.name)
}
}
MainNavOption.AboutScreen -> {
navController.navigate(onUserPickedOption.name) {
popUpTo(NavRoutes.MainRoute.name)
}
}
}
}
}
) {
val isOnboarded = vm.isOnboarded.collectAsState()
NavHost(
navController,
startDestination = if (isOnboarded.value) NavRoutes.MainRoute.name else NavRoutes.IntroRoute.name
) {
introGraph(navController)
mainGraph(drawerState)
}
}
}
}
}
In the constructor, the NavController
and DrawerState
are initialized via their appropriate remember method to prevent rebuilding them in the future.
ModalNavigationDrawer
is parent composable of the app from where the user can make the drawer appear, make the action and hide it. Since the navigation drawer is a single component, it takes in its state as drawerState
. The state needs to be shared across other composables.
The DrawerState controls the visibility of the drawer. As you may assume DrawerValue.Closed
means that the drawer is closed and vice-versa, the DrawerValue.Open
that drawer is opened.
ModalNavigationDrawer takes in the drawerContent, the composable created by you with your resources, design and logic. After clicking one of the buttons, the enum value is obtained via callback, and the user is navigated to the new screen.
The children of the NavigationDrawer are the NavGraphs. The first navgraph is for the introduction. The second navgraph is for regular use. The focus is mainly on regular usage screens now (orange boxes on the upper image).
fun NavGraphBuilder.mainGraph(drawerState: DrawerState) {
navigation(startDestination = MainNavOption.HomeScreen.name, route = NavRoutes.MainRoute.name) {
composable(MainNavOption.HomeScreen.name){
HomeScreen(drawerState)
}
composable(MainNavOption.SettingsScreen.name){
SettingsScreen(drawerState)
}
composable(MainNavOption.AboutScreen.name){
AboutScreen(drawerState)
}
}
}
// available routes for the main route
enum class MainNavOption {
HomeScreen,
SettingsScreen,
AboutScreen
}
The main graph contains the routes to the home screen, setting screen and about the screen. Screens can invoke the drawer with the state. Every screen is defined by its unique enum value.
The screen contents are simplistic. It contains a scaffold and text describing the current screen. The Scaffold has a top bar with one menu button, which launches the drawer.
As you may see, the opening of the drawer takes only one method drawerState.open()
. This is the reason why it is passed to every screen. They can open the drawer independently of the current type of screen.
The open method is run as suspend function because of the animation to show the navigation drawer. It needs to be run asynchronously to other UI components on the main thread. I encourage to open the function and have a look under the hood.
/// passing the drawer state from the main parent composable
@Composable
fun HomeScreen(drawerState: DrawerState) {
Scaffold(
topBar = {
// to run the animation independently
val coroutineScope = rememberCoroutineScope()
TopAppBar(
title = {}, // no title
navigationIcon = {
IconButton(onClick = {
coroutineScope.launch {
// opens drawer
drawerState.open()
}
}) {
Icon(
// internal hamburger menu
Icons.Rounded.Menu,
contentDescription = "MenuButton"
)
}
},
)
}
) { paddingValues ->
Surface {
// padding of the scaffold is enforced to be used
Column(modifier = Modifier.padding(paddingValues)) {
Text("Home")
}
}
}
}
The main idea is to show the navigation drawer and let the navcontroller show the required composable. The drawer is not influenced by the UI change because it is on top of all composables. No manual triaging of the composables is needed underneath it.
Navigation drawer construction
Firstly, the definition of possible actions has to be declared via AppDrawerItemInfo
. It acts as a customizable container for every button presented in the drawer. It takes of generic type object to pass back for the callback and resolve the next screen. The title, image and description are for the UI. Afterwards, the buttons are defined as a list of items.
It is important to preserve easy extension capabilities, because who knows when the next screen will be required.
// base data container for the button creation
// takes in the resources IDs
data class AppDrawerItemInfo<T>(
val drawerOption: T,
@StringRes val title: Int,
@DrawableRes val drawableId: Int,
@StringRes val descriptionId: Int
)
// list of the buttons
object DrawerParams {
val drawerButtons = arrayListOf(
AppDrawerItemInfo(
MainNavOption.HomeScreen,
R.string.drawer_home,
R.drawable.ic_home,
R.string.drawer_home_description
),
AppDrawerItemInfo(
MainNavOption.SettingsScreen,
R.string.drawer_settings,
R.drawable.ic_settings,
R.string.drawer_settings_description
),
AppDrawerItemInfo(
MainNavOption.AboutScreen,
R.string.drawer_about,
R.drawable.ic_info,
R.string.drawer_info_description
)
)
}
The drawer itself takes in the drawer state and button specifications. To make the drawer adaptive, the drawer contains a callback function, which returns the picked value of the specified type. The default value prevents picking the same screen twice in a row from the beginning of the app.
The generic type T is important as the drawer should be able to manipulate with any class. In this case, it is reduced to an enum, so the app can use the when statement and name string of the enum. In advanced apps, these might be sealed classes to provide more context.
Do not forget to put a callback to your button, so if a user clicks on it, it passes the required enum value. The drawer needs to be closed via drawerState.close()
in separated coroutine scope.
Feel free to customize the UI of the drawer to your liking. The current drawer puts the app icon on top and the buttons in the column. To wrap the content you can use
ModalDrawerSheet
, which wraps and fits given content.
// T for generic type to be used for the picking
@Composable
fun <T: Enum<T>> AppDrawerContent(
drawerState: DrawerState,
menuItems: List<AppDrawerItemInfo<T>>,
defaultPick: T,
onClick: (T) -> Unit
) {
// default home destination to avoid duplication
var currentPick by remember { mutableStateOf(defaultPick) }
val coroutineScope = rememberCoroutineScope()
ModalDrawerSheet {
Surface(color = MaterialTheme.colorScheme.background) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
// header image on top of the drawer
Image(
painter = painterResource(id = R.drawable.app_icon),
contentDescription = "Main app icon"),
modifier = Modifier.size(150.dp)
)
// column of options to pick from for user
LazyColumn(
modifier = Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// generates on demand the required composables
items(menuItems) { item ->
// custom UI representation of the button
AppDrawerItem(item = item) { navOption ->
// if it is the same - ignore the click
if (currentPick == navOption) {
return@AppDrawerItem
}
currentPick = navOption
// close the drawer after clicking the option
coroutineScope.launch {
drawerState.close()
}
// navigate to the required screen
onClick(navOption)
}
}
}
}
}
}
}
The button is an icon with the following text and description. To obtain the splash effect on the button, use the Surface of Material 3. It will give you the animation for free for any shape. For rounded/clipped corners too.
Every button has its callback function, which returns the enum value belonging to the specified button to the drawer implementation. The drawer returns the choice to our custom logic to handle the navigation via NavController in the main composable.
@Composable
fun <T> AppDrawerItem(item: AppDrawerItemInfo<T>, onClick: (options: T) -> Unit) =
// making surface clickable causes to show the appropriate splash animation
Surface(
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.width(150.dp),
onClick = { onClick(item.drawerOption) },
shape = RoundedCornerShape(50),
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(16.dp)
) {
Icon(
painter = painterResource(id = item.drawableId),
contentDescription = stringResource(id = item.descriptionId),
modifier = Modifier
.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = stringResource(id = item.title),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}
Summary
The basic concept is to put the drawer on top of all the navigations, which should be moderated by the drawer. The drawer should build a custom UI for accommodating buttons. Following that, the user can invoke them from any screen.
If the user clicks the button, the specified screen is shown via NavController defined in NavGraph.
Example code can be found here at Gihub.