Jetpack Compose BottomNavigation keep item selected when navigating in - android

I just started using Jetpack Compose and wanted to try out the BottomNavigation. A basic implementation with three items was no problem. Now, one of the three screens should navigate to a detail screen when clicking on a list item. The problem that occurs, is that on the detail screen the bottom navigation item is not selected anymore.
Here's my implementation:
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WhoHasItTheme {
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigationBar(
items = listOf(
BottomNavItem(
name = "Home",
route = Screen.Home.route,
icon = Icons.Default.Home
),
BottomNavItem(
name = "Search",
route = Screen.GameListScreen.route,
icon = Icons.Default.Search
),
BottomNavItem(
name = "Profile",
route = Screen.Profile.route,
icon = Icons.Default.Person
)
),
navController = navController,
onItemClick = {
navController.navigate(it.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
) {
Box(modifier = Modifier.padding(it)) {
Navigation(navController = navController)
}
}
}
}
}
}
#Composable
fun Navigation(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen()
}
composable(Screen.GameListScreen.route) {
GameListScreen(navController)
}
composable(
route = "${Screen.GameDetailScreen.route}/{gameId}",
arguments = listOf(navArgument("gameId") { type = NavType.IntType })
) {
GameDetailScreen()
}
composable(Screen.Profile.route) {
ProfileScreen()
}
}
}
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun BottomNavigationBar(
items: List<BottomNavItem>,
navController: NavController,
modifier: Modifier = Modifier,
onItemClick: (BottomNavItem) -> Unit
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
BottomNavigation(
modifier
) {
items.forEach { item ->
BottomNavigationItem(
selected = currentDestination?.hierarchy?.any { it.route == item.route } == true,
onClick = { onItemClick(item) },
selectedContentColor = MaterialTheme.colors.onSurface,
label = { Text(item.name) },
alwaysShowLabel = false,
icon = {
Column(horizontalAlignment = CenterHorizontally) {
if (item.badgeCount > 0) {
BadgeBox(
badgeContent = {
Text(item.badgeCount.toString())
}
) {
Icon(imageVector = item.icon, contentDescription = item.name)
}
} else {
Icon(imageVector = item.icon, contentDescription = item.name)
}
}
}
)
}
}
}
In my understanding currentDestination?.hierarchy should also include the root screen (GameListScreen). What am I understanding wrong here and how can I make it so screens below the root screen of each bottom navigation item still have their tab item as 'selected'?

Related

How to arrange BottomNavigationItems in Compose?

How can I arrange the two inner BottomNav Items so that they are not so close to the "+" FAB?
I tried surrounding the forEach which displays the Items with a Row and use the Arrangement modifier like so:
Row(horizontalArrangement = Arrangement.SpaceBetween) { //Not working :(
items.forEach { item ->
BottomNavigationItem(
icon = { Icon(painterResource(id = item.icon), contentDescription = item.title) },
label = { Text(text = item.title) },
selectedContentColor = Color.White,
unselectedContentColor = Color.White.copy(0.4f),
alwaysShowLabel = true,
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
Unfortunately thats not working
Arrangement.SpaceBetween works as expected - it adds a spacer between items:
Place children such that they are spaced evenly across the main axis, without free space before the first child or after the last child. Visually: 1##2##3
You need to let your Row know about FAB location. You can add a spacer with Modifier.weight in the middle of your row, for example like this:
items.forEachIndexed { i, item ->
if (i == items.count() / 2) {
Spacer(Modifier.weight(1f))
}
BottomNavigationItem(
// ...
You can use BottomAppBar & give it cutoutShape with a dummy item in the middle. It would give you your desired results.
Output:
Code Sample:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
BottomBarWithFabDem()
}
}
}
}
}
val items = listOf(
Screen.PickUp,
Screen.Explore,
Screen.Camera,
Screen.Favorites,
Screen.Profile
)
sealed class Screen(val route: String?, val title: String?, val icon: ImageVector?) {
object PickUp : Screen("pickup", "PickUp", Icons.Default.ShoppingCart)
object Explore : Screen("explore", "Explore", Icons.Default.Info)
object Camera : Screen("camera", null, null)
object Favorites : Screen("favorites", "Fav", Icons.Default.Favorite)
object Profile : Screen("profile", "Profile", Icons.Default.Person)
}
#Composable
fun BottomBarWithFabDem() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNav(navController)
},
floatingActionButtonPosition = FabPosition.Center,
isFloatingActionButtonDocked = true,
floatingActionButton = {
FloatingActionButton(
shape = CircleShape,
onClick = {
Screen.Camera.route?.let {
navController.navigate(it) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
Screen.Camera.route?.let { navController.navigate(it) }
},
contentColor = Color.White
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = "Add icon")
}
}
) {
MainScreenNavigation(navController)
}
}
#Composable
fun MainScreenNavigation(navController: NavHostController) {
NavHost(navController, startDestination = Screen.Profile.route!!) {
composable(Screen.Profile.route) {}
composable(Screen.Explore.route!!) {}
composable(Screen.Favorites.route!!) {}
composable(Screen.PickUp.route!!) {}
composable(Screen.Camera.route!!) {}
}
}
#Composable
fun BottomNav(navController: NavController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination
BottomAppBar(cutoutShape = CircleShape, modifier = Modifier.height(64.dp)) {
Row {
items.forEachIndexed { index, it ->
if (index != 2) {
// Main item
BottomNavigationItem(
icon = {
it.icon?.let {
Icon(
imageVector = it,
contentDescription = "",
modifier = Modifier.size(35.dp),
tint = Color.White
)
}
},
label = {
it.title?.let {
Text(
text = it,
color = Color.White
)
}
},
selected = currentRoute?.hierarchy?.any { it.route == it.route } == true,
selectedContentColor = Color(R.color.purple_700),
unselectedContentColor = Color.White.copy(alpha = 0.4f),
onClick = {}
)
} else {
// placeholder for center fab
BottomNavigationItem(
icon = {},
label = { },
selected = false,
onClick = { },
enabled = false
)
}
}
}
}
}

Jetpack Compose TopAppBar with dynamic actions

#Composable
fun TopAppBar(
title: #Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: #Composable (() -> Unit)? = null,
actions: #Composable RowScope.() -> Unit = {},
backgroundColor: Color = MaterialTheme.colors.primarySurface,
contentColor: Color = contentColorFor(backgroundColor),
elevation: Dp = AppBarDefaults.TopAppBarElevation
)
actions: #Composable RowScope.() -> Unit = {}
Usage Scenario:
Using Compose Navigation to switch to different "screens", so the TopAppBar actions will be changed accordingly. Eg. Share buttons for content screen, Filter button for listing screen
Tried passing as a state to the TopAppBar's actions parameter, but having trouble to save the lambda block for the remember function.
val (actions, setActions) = rememberSaveable { mutableStateOf( appBarActions ) }
Want to change the app bar actions content dynamically. Any way to do it?
This the approach I used but I'm pretty new on compose, so I cannot be sure it is the correct approach.
Let's assume I have 2 screens: ScreenA and ScreenB
They are handled by MainActivity screen.
This is our MainActivity:
#ExperimentalComposeUiApi
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
#OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CoolDrinksTheme {
val navController = rememberNavController()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
var appBarState by remember {
mutableStateOf(AppBarState())
}
Scaffold(
topBar = {
SmallTopAppBar(
title = {
Text(text = appBarState.title)
},
actions = {
appBarState.actions?.invoke(this)
}
)
}
) { values ->
NavHost(
navController = navController,
startDestination = "screen_a",
modifier = Modifier.padding(
values
)
) {
composable("screen_a") {
ScreenA(
onComposing = {
appBarState = it
},
navController = navController
)
}
composable("screen_b") {
ScreenB(
onComposing = {
appBarState = it
},
navController = navController
)
}
}
}
}
}
}
}
}
As you can see I'm using a mutable state of a class which represents the state of our MainActivity (where the TopAppBar is declared and composed), in this example there is the title and the actions of our TopAppBar.
This mutable state is set with a callback function called inside the composition of each screen.
Here you can see the ScreenA
#Composable
fun ScreenA(
onComposing: (AppBarState) -> Unit,
navController: NavController
) {
LaunchedEffect(key1 = true) {
onComposing(
AppBarState(
title = "My Screen A",
actions = {
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = null
)
}
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Default.Filter,
contentDescription = null
)
}
}
)
)
}
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Screen A"
)
Button(onClick = {
navController.navigate("screen_b")
}) {
Text(text = "Navigate to Screen B")
}
}
}
And the ScreenB
#Composable
fun ScreenB(
onComposing: (AppBarState) -> Unit,
navController: NavController
) {
LaunchedEffect(key1 = true) {
onComposing(
AppBarState(
title = "My Screen B",
actions = {
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null
)
}
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null
)
}
}
)
)
}
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Screen B"
)
Button(onClick = {
navController.popBackStack()
}) {
Text(text = "Navigate back to Screen A")
}
}
}
And finally this is the data class of our state:
data class AppBarState(
val title: String = "",
val actions: (#Composable RowScope.() -> Unit)? = null
)
In this way you have a dynamic appbar declared in the main activity but each screen is responsable to handle the content of the appbar.
First you need to add navigation dependency on you jetpack compose projects.
You can read the doc from this https://developer.android.com/jetpack/compose/navigation
def nav_version = "2.4.1"
implementation "androidx.navigation:navigation-compose:$nav_version"
Then define your screen in sealed class:
sealed class Screen(var icon: ImageVector, var route: String) {
object ContentScreen: Screen(Icons.Default.Home, "home")
object ListingScreen: Screen(Icons.Default.List, "list")
}
and this is the navigation function look like
#Composable
fun Navigation(paddingValues: PaddingValues, navController: NavHostController) {
NavHost(navController, startDestination = Screen.ContentScreen.route, modifier = Modifier.padding(paddingValues)) {
composable(Screen.ContentScreen.route) {
//your screen content
}
composable(Screen.ListingScreen.route) {
//your listing screen here
}
}
}
Finally in your mainactivity class
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TestAppTheme {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Scaffold(
topBar = {
TopAppBar(title = { Text(text = "main screen") }, actions = {
if (currentRoute == Screen.ContentScreen.route) {
//your share button action here
} else if (currentRoute == Screen.ListingScreen.route) {
//your filter button here
} else {
//other action
}
})
}
) {
Navigation(paddingValues = it, navController = navController)
}
}
}
}
I'm so sorry if the explanation to sort, because the limitation of my English

How to change icon if selected and unselected in android jetpack compose for NavigationBar like selector we use in xml for selected state?

I want to use outlined and filled icons based on selected state in NavigationBar just like google maps app, using jetpack compose. In case of xml we use selector so what do we use for compose ?
Here is my code ->
MainActivity.kt
#ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Handle the splash screen transition.
installSplashScreen()
setContent {
MyApp()
}
}
}
#ExperimentalMaterial3Api
#Composable
fun MyApp() {
MyTheme {
val items = listOf(
Screen.HomeScreen,
Screen.MusicScreen,
Screen.ProfileScreen
)
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
NavigationBarItem(
icon = {
Icon(
screen.icon_outlined,
contentDescription = screen.label.toString()
)
},
label = { Text(stringResource(screen.label)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(
navController,
startDestination = Screen.HomeScreen.route,
Modifier.padding(innerPadding)
) {
composable(route = Screen.HomeScreen.route) {
HomeScreen()
}
composable(route = Screen.MusicScreen.route) {
MusicScreen()
}
composable(route = Screen.ProfileScreen.route) {
ProfileScreen()
}
}
}
}
}
#ExperimentalMaterial3Api
#Preview(
showBackground = true, name = "Light mode",
uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL
)
#Preview(
showBackground = true, name = "Night mode",
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
)
#Composable
fun DefaultPreview() {
MyApp()
}
Screen.kt
sealed class Screen(
val route: String,
#StringRes val label: Int,
val icon_outlined: ImageVector,
val icon_filled: ImageVector
) {
object HomeScreen : Screen(
route = "home_screen",
label = R.string.home,
icon_outlined = Icons.Outlined.Home,
icon_filled = Icons.Filled.Home
)
object MusicScreen : Screen(
route = "music_screen",
label = R.string.music,
icon_outlined = Icons.Outlined.LibraryMusic,
icon_filled = Icons.Filled.LibraryMusic,
)
object ProfileScreen : Screen(
route = "profile_screen",
label = R.string.profile,
icon_outlined = Icons.Outlined.AccountCircle,
icon_filled = Icons.Filled.AccountCircle,
)
}
HomeScreen.kt
#Composable
fun HomeScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Surface(color = MaterialTheme.colorScheme.background) {
Text(
text = "Home",
color = Color.Red,
fontSize = MaterialTheme.typography.displayLarge.fontSize,
fontWeight = FontWeight.Bold
)
}
}
}
#Preview(
showBackground = true, name = "Light mode",
uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL
)
#Preview(
showBackground = true, name = "Night mode",
uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
)
#Composable
fun HomeScreenPreview() {
HomeScreen()
}
Do I still need to use selector xml or there is alternative way in jetpack compose?
Yeah, you just make a simple if statement based on selected state, like this:
items.forEach { screen ->
val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
NavigationBarItem(
icon = {
Icon(
if (selected) screen.icon_filled else screen.icon_outlined,
contentDescription = screen.label.toString()
)
},
selected = selected,
)
}

Navigating to a Bottom Tab Screen from a Single screen with a button Jetpack Compose

I have a auth page and after the auth page, I basically navigate to a Tabbed application.
The problem is that bottom bar disappears once I click on tab.
Below is what my code looks like
sealed class ScreenM(val route: String) {
object Landing: Screen("landingm")
object Tab: Screen("tabm")
}
sealed class Screen(val route: String) {
object PasswordLogin: Screen("passwordlogin")
object TabBar: Screen("tabbar")
}
sealed class TabbarItem(var route: String, var icon: Int, var title: String) {
object Home : TabbarItem("tabhome", R.drawable.ic_home_tab_icon, "Home")
object Profile : TabbarItem("tabprofile", R.drawable.ic_profile_tab_icon, "Profile")
}
my application entry point is
#Composable
fun App() {
val navController = rememberNavController()
NavHost(navController, startDestination = ScreenM.Landing.route) {
addLandingTopLevel(navController = navController)
addTabBarTopLevel(navController = navController)
}
}
private fun NavGraphBuilder.addLandingTopLevel(
navController: NavController,
) {
navigation(
route = ScreenM.Landing.route,
startDestination = Screen.Home.route
) {
addPasswordLogin(navController)
}
}
private fun NavGraphBuilder.addPasswordLogin(navController: NavController) {
composable(route = Screen.PasswordLogin.route) {
PasswordLoginView(navController)
}
}
private fun NavGraphBuilder.addTabBarTopLevel(
navController: NavController,
) {
navigation(
route = ScreenM.Tab.route,
startDestination = Screen.TabBar.route
) {
addTabBar(navController)
addHome(navController)
addProfile(navController)
}
}
private fun NavGraphBuilder.addTabBar(navController: NavController) {
composable(route = Screen.TabBar.route) {
TabBarView(navController)
}
}
private fun NavGraphBuilder.addHome(navController: NavController) {
composable(route = TabbarItem.Home.route) {
HomeView()
}
}
private fun NavGraphBuilder.addProfile(navController: NavController) {
composable(route = TabbarItem.Profile.route) {
ProfileView()
}
}
I am triggering the Tab like this
// ...
NavigationButton(buttonText = "Login", onBackPressed = {
navController.popBackStack()
}) {
navController.navigate(ScreenM.Tab.route)
}
// ...
Then my Tabbar is like
#Composable
fun TabBarView(navController: NavController) {
Scaffold(
bottomBar = { BottomNavigationBar(navController) }
) {
}
}
Then bottom navigation Bar is like this
#Composable
fun BottomNavigationBar(navController: NavController) {
val items = listOf(
TabbarItem.Home,
TabbarItem.Profile
)
BottomNavigation(
backgroundColor = colorResource(id = R.color.white),
contentColor = Color.Black
) {
items.forEach { item ->
BottomNavigationItem(
icon = { Icon(painterResource(id = item.icon), contentDescription = item.title) },
label = { Text(text = item.title) },
selectedContentColor = Color.Red,
unselectedContentColor = Color.Blue.copy(0.4f),
alwaysShowLabel = true,
selected = false,
onClick = {
navController.navigate(item.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route) {
saveState = true
}
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
You have two options.
Displaying BottomNavigationBar inside each tab
1.1. Not sure what's addTabBar navigation route in your code, I don't think it's need, as looks like you only have two tabs: TabbarItem.Home and TabbarItem.Profile.
1.2. You can add BottomNavigationBar inside each view, specifying the selected item. Inside HomeView it can look like this:
BottomNavigationBar(navController, selectedTab = TabbarItem.Home)
1.3. Depending on the selected tab, you need to select the needed item of BottomNavigationBar
#Composable
fun BottomNavigationBar(navController: NavController, selectedTab: TabbarItem) {
val items = listOf(
TabbarItem.Home,
TabbarItem.Profile
)
BottomNavigation(
// ...
) {
items.forEach { item ->
BottomNavigationItem(
// ...
selected = selectedTab == item,
// ...
Having a single navigation bar outside of NavHost, you can find an example in documentation

Jetpack Compose BottomNavBar label overlapping Icon

I was trying to implement jetpack compose bottomnavbar. But I encountered this problem. Whenever label don't get enough space it's overlapping the icon. Am I missing something? Is there any solution like truncating or shrinking text automatically?
compose_version = '1.0.0-beta09'
My Code
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
val items = listOf(
Screen.Profile,
Screen.FriendsList,
Screen.Notification,
Screen.Setting
)
Scaffold(
bottomBar = {
BottomNavigation(
backgroundColor = MaterialTheme.colors.surface,
elevation = 8.dp
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
items.forEachIndexed { index, screen ->
BottomNavigationItem(
icon = {
when (index) {
0 -> Icon(Icons.Filled.Person, "Profile")
1 -> Icon(
painterResource(id = R.drawable.ic_friends),
"Friends"
)
2 -> Icon(Icons.Filled.Notifications, "Notification")
else -> Icon(Icons.Filled.Settings, "Settings")
}
},
label = { Text(stringResource(screen.resourceId)) },
selected = currentRoute == screen.route,
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = Color.Black,
onClick = {
navController.navigate(screen.route) {
navController.graph.startDestinationRoute?.let {
popUpTo(it) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) {
NavHost(navController, startDestination = Screen.Profile.route) {
composable(Screen.Profile.route) { Profile(navController) }
composable(Screen.FriendsList.route) { FriendsList(navController) }
composable(Screen.Notification.route) { FriendsList(navController) }
composable(Screen.Setting.route) { FriendsList(navController) }
}
}
}
}
You can add the property maxLines = 1 to the Text used in the label:
BottomNavigationItem(
/* your code */
label = { Text("Notification",
maxLines = 1,
overflow = TextOverflow.Ellipsis) /* optional */
}
)

Categories

Resources