Skip to content

Instantly share code, notes, and snippets.

@benigumocom
Last active April 4, 2026 14:41
Show Gist options
  • Select an option

  • Save benigumocom/f5deaecb15273cf7f5caa0d5af413d09 to your computer and use it in GitHub Desktop.

Select an option

Save benigumocom/f5deaecb15273cf7f5caa0d5af413d09 to your computer and use it in GitHub Desktop.
Stop Fighting Multiple BackStacks in Jetpack Compose Navigation3 | by chanzmao | Mar, 2026 | ProAndroidDev https://medium.com/proandroiddev/stop-fighting-multiple-backstacks-in-jetpack-compose-navigation3-50f8cf063fff
package com.example.samplenavigation3
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.TagFaces
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.TagFaces
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import kotlinx.serialization.Serializable
import timber.log.Timber
import kotlin.collections.forEach
@Serializable
sealed interface TabRoot : NavKey {
val label: String
val selectedIcon: ImageVector
val unselectedIcon: ImageVector
companion object {
val entries = listOf(Home, Search, Profile)
}
}
@Serializable
data object Home : TabRoot {
override val label = "Home"
override val selectedIcon = Icons.Filled.Home
override val unselectedIcon = Icons.Outlined.Home
}
@Serializable
data object Search : TabRoot {
override val label = "Search"
override val selectedIcon = Icons.Filled.Search
override val unselectedIcon = Icons.Outlined.Search
}
@Serializable
data object Profile : TabRoot {
override val label = "Profile"
override val selectedIcon = Icons.Filled.TagFaces
override val unselectedIcon = Icons.Outlined.TagFaces
}
@Serializable
data class Result(val keyword: String) : NavKey
@Serializable
data class Detail(val id: String) : NavKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
var currentTab by rememberSerializable { mutableStateOf<TabRoot>(Home) }
// Map recreation is cheap; NavBackStack state persists.
val stacks = TabRoot.entries.associateWith { root ->
rememberNavBackStack(root)
}
// This re-evaluates automatically on currentTab changes.
val currentStack = stacks[currentTab]!!
Scaffold(
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = { Text(stringResource(R.string.app_name)) },
)
},
bottomBar = {
NavigationBar {
TabRoot.entries.forEach { tabRoot ->
val selected = currentStack.first() == tabRoot
NavigationBarItem(
selected = selected,
onClick = {
// If the tab is selected, reset its stack to the root;
// otherwise, navigate to that stack.
if (selected) {
currentStack.apply {
clear()
add(tabRoot)
}
} else {
currentTab = tabRoot
}
log(currentTab, stacks)
},
icon = {
Icon(
imageVector = if (selected) tabRoot.selectedIcon else tabRoot.unselectedIcon,
contentDescription = tabRoot.label
)
},
label = { Text(tabRoot.label) },
alwaysShowLabel = true
)
}
}
}
) { innerPadding ->
NavDisplay(
backStack = currentStack,
modifier = Modifier.padding(innerPadding),
onBack = {
// If at the root of a stack, exit the app;
// otherwise, pop the back stack.
currentStack.removeAt(currentStack.lastIndex)
log(currentTab, stacks)
},
entryProvider = entryProvider {
entry<Home> { HomeScreen() }
entry<Search> {
SearchScreen(
onSearch = { keyword: String ->
currentStack.add(Result(keyword))
log(currentTab, stacks)
}
)
}
entry<Result> { result ->
ResultScreen(
keyword = result.keyword,
onClick = { id: String ->
currentStack.add(Detail(id))
log(currentTab, stacks)
}
)
}
entry<Detail> { detail -> DetailScreen(detail.id) }
entry<Profile> { ProfileScreen() }
}
)
}
}
@Composable
fun HomeScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Home")
}
}
@Composable
fun SearchScreen(
onSearch: (String) -> Unit
) {
var text by rememberSaveable { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Search")
TextField(
value = text,
onValueChange = { new -> text = new },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onSearch(text) })
)
}
}
@Composable
fun ProfileScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Profile")
}
}
@Composable
fun ResultScreen(
keyword: String,
onClick: (String) -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Search Result")
repeat(3) {
Text(
text = "- ${keyword.reversed()}",
modifier = Modifier.clickable {
onClick(keyword.reversed())
}
)
}
}
}
@Composable
fun DetailScreen(id: String) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Detail")
repeat(5) {
Text(id.repeat(3))
}
}
}
fun log(
currentTabRoot: TabRoot,
stacks: Map<TabRoot, NavBackStack<NavKey>>
) {
Timber.d("current tab: $currentTabRoot")
Timber.d("map: ${System.identityHashCode(stacks)}")
stacks.forEach { (tab, stack) ->
Timber.d("stack ${System.identityHashCode(stack)} for $tab: ${stack.toList()}")
}
Timber.d("---")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment