Kotlin Jetpack Compose BottomNavigation selected page is not been updated - android

I have BottomNavigation wrapped around as a BottomBar composable and inside my MainScreen composable.
The current implementation renders properly and I am able to navigate between different Pages. However, the issue I am facing is that the selected property in the BottomNavigationItem is not been updated correctly when changing the current page. This causes the UI to be located in the "Profile" page, but the bottom bar highlights the "Home" page.
I am almost sure it is something regarding the state of the composable, and I tried passing the NavHostController all the way down to the BottomBar, but that didn't solved the issue.
enum class Page(
#StringRes val title: Int,
val isCorePage: Boolean,
val icon: ImageVector) {
Profile(
title = R.string.profile_page_title,
isCorePage = true,
icon = Icons.Filled.Person),
Home(
title = R.string.home_page_title,
isCorePage = true,
icon = Icons.Filled.Home),
... etc
}
#Composable
fun BottomBar(
currentPage: Page,
onClick: (page: Page) -> Unit = { }) {
BottomNavigation {
Page.values().forEach { page ->
if(page.isCorePage) {
val title = stringResource(page.title);
BottomNavigationItem(
icon = { Icon(
imageVector = page.icon,
contentDescription = title) },
label = { Text(text = title) },
selected = page == currentPage,
onClick = { onClick(page) }
)
}
}
}
}
#Composable
fun MainScreen(
sessionState: State<Session>,
navController: NavHostController = rememberNavController()) {
val backStackEntry by navController.currentBackStackEntryAsState()
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
Scaffold(
scaffoldState = scaffoldState,
topBar = {
TopBar(
title = backStackEntry?.destination?.route ?: "Title",
menuClick = { scope.launch { scaffoldState.drawerState.open() } },
searchClick = { navController.navigate(Page.Search) },
notificationsClick = { navController.navigate(Page.Notifications) })
},
drawerContent = { SideDrawer(sessionState = sessionState) },
bottomBar = {
BottomBar(
currentPage = navController.currentPageOrDefault(Page.Home),
onClick = { page -> navController.navigate(page) })
}
) {
PageNavHost(navController)
}
}
fun NavHostController.navigate(page: Page) {
val navController = this
navController.navigate(page.name) {
// 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
// re-selecting the same item
launchSingleTop = true
// Restore state when re-selecting a previously selected item
restoreState = true
}
}
fun NavHostController.currentPageOrDefault(default: Page): Page {
return this.currentPage() ?: default
}
fun NavHostController.currentPage(): Page? {
val currentRoute = this.currentDestination?.route ?: return null;
Page.values().forEach { page ->
if(page.name == currentRoute) {
return page
}
}
return null
}

Related

How navigate back with new argument in Jetpack Compose?

I want to navigate back with different input argument.
I have a ParentScreen and it is using input argument arg (="first"). Code navigates from parent to child by sending newArg (="firstsecond") on button press. When navigating back from child to parent I want parent's input arg to be "fistsecond".
Below I wrote the code (it is not working) to show what I want.
#Composable
fun ParentScreen(
nav: NavHostController,
arg: String = "first"
) {
val newArg = arg + "second"
Button(onClick = {
nav.navigate("child/$newArg") {
popUpTo("parent/$newArg") {
saveState = true
}
}
}) {
Text(text = arg)
}
}
What you may want to do is launch the parent composable as single top, and when navigating from child composable, you can send the new argument and launch it as single top again. Here is how you can achieve if you are using compose navigation;
You can launch ParentScreen for the first time like this;
navController.navigate("parent/first") {
launchSingleTop = true
}
Then navigate to ChildScreen as usual;
navController.navigate("child/firstSecond")
In ChildScreen navigate to ParentSceen like this, this will remove the ChildScreen from the nav stack and launch the ParentScreen as single top, resulting only ParentScreen in the stack with new parameter;
navController.navigate("parent/$newArg") {
launchSingleTop = true
popUpTo("child/{someArg}") {
inclusive = true
}
}
Full code here;
#Composable
fun SampleNavHost(
modifier: Modifier = Modifier,
navController: NavHostController,
onBackClick: () -> Unit,
startDestination: String = "start_dest",
) {
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable("start_dest") {
Button(onClick = {
navController.navigate("parent/first") {
launchSingleTop = true
}
}) {
Text(text = "move to parent")
}
}
composable("parent/{someArg}") {
val arg = it.arguments?.getString("someArg").orEmpty()
ParentScreen(
title = arg,
navStackSize = navController.backQueue.size.toString(),
onButtonClick = {
navController.navigate("child/firstSecond")
}
)
}
composable("child/{someArg}") {
val arg = it.arguments?.getString("someArg").orEmpty()
val newArg = arg + "second"
ChildScreen(
title = arg,
onButtonClick = {
navController.navigate("parent/$newArg") {
launchSingleTop = true
// this will navigate current composable from stack when navigating
popUpTo("child/{someArg}") {
inclusive = true
}
}
}
)
}
}
}
#Composable
fun ParentScreen(
title: String,
navStackSize: String,
onButtonClick: () -> Unit,
) {
Column {
Text(text = "Parent Screen, stack size: $navStackSize")
Button(onClick = onButtonClick) {
Text(text = title)
}
}
}
#Composable
fun ChildScreen(
title: String,
onButtonClick: (String) -> Unit,
) {
Column {
Text(text = "Child Screen")
Button(onClick = { onButtonClick(title) }) {
Text(text = title)
}
}
}

How to change title for scaffold top bar when using popBackStack from jetpack compose navigation?

I am migrating my multiple activity app to single activity app for compose.
I have created a composable Home which contains a Top app bar with a title as shown below:
#Composable
fun Home() {
val navController = rememberNavController()
var actionBarTitle by rememberSaveable { mutableStateOf("Home") }
var actionBarSubtitle by rememberSaveable { mutableStateOf("") }
Scaffold(topBar = {
Header(title = actionBarTitle, subTitle = actionBarSubtitle,
onBackPress = { navController.popBackStack() },
showInfo = true, onActionClick = {
navController.navigate(Screen.Info.route)
}, modifier = Modifier.fillMaxWidth())
}) {
AppNavigation(navController = navController, onNavigate = { title, subtitle ->
actionBarTitle = title
actionBarSubtitle = subtitle
})
}
onNavigate is triggered whenever I use navController.navigate for any screen as shown below:
onNavigate("Top up", "Please topm up with minimum of X amount")
navController.navigateTo(Screen.TopUp.route)
My question is when I use backpress I don't know to which screen composable I will be navigated to, so how can I call onNavigate to change the title.
You can observe the navigation changes using the currentBackstackEntryFlow.
#Composable
fun Home() {
val context = LocalContext.current
val navController = rememberNavController()
...
LaunchedEffect(navController) {
navController.currentBackStackEntryFlow.collect { backStackEntry ->
// You can map the title based on the route using:
actionBarTitle = getTitleByRoute(context, backStackEntry.destination.route)
}
}
...
}
Of course, you would need write this getTitleByRoute() to get the correct title in according to the navigation route.
It would be something like:
fun getTitleByRoute(context: Context, route:String): String {
return when (route) {
"Screen1" -> context.getString(R.string.title_screen_1)
// other cases
else -> context.getString(R.string.title_home)
}
}
1. Use LiveData to change the Screen Title while using Composable
implementation "androidx.compose.runtime:runtime-livedata:1.2.0-beta02"
2. Create ViewModel Class
class MainViewModel: ViewModel() {
private var _screenTitle = MutableLiveData("")
val screenTitle: LiveData<String>
get() = _screenTitle
fun setTitle(newTitle: String) {
_screenTitle.value = newTitle
}
}
3. In Your Activity Class
setContent {
Surface(color = MaterialTheme.colors.onPrimary) {
LoadMainScreen()
}
}
// Observe ScreenTitle
#Composable
fun LoadMainScreen(mainViewModel: MainViewModel = viewModel()){
val title: String by mainViewModel.screenTitle.observeAsState("")
Scaffold(
topBar = {
TopAppBar(title = { title?.let { Text(it) } },
navigationIcon = {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Menu",
tint = Color.White
)
}
)
}
)
}
4. Change the Title Value from Screen
#Composable
fun ScreenOne(mainViewModel: MainViewModel) {
LaunchedEffect(Unit){
mainViewModel.setTitle("One")
}
}
#Composable
fun ScreenTwo(mainViewModel: MainViewModel) {
LaunchedEffect(Unit){
mainViewModel.setTitle("Two")
}
}
You can get the label of the current destination from navHostcontrollor, just use it as the title
val navController = rememberNavController()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val title = currentBackStackEntry?.destination?.label
The default composable function is implemented as follows
/**
* Add the [Composable] to the [NavGraphBuilder]
*
* #param route route for the destination
* #param arguments list of arguments to associate with destination
* #param deepLinks list of deep links to associate with the destinations
* #param content composable for the destination
*/
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: #Composable (NavBackStackEntry) -> Unit
) {
addDestination(
ComposeNavigator.Destination(provider[ComposeNavigator::class], content).apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
}
)
}
overload it:
fun NavGraphBuilder.composable(
route: String,
label: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: #Composable (NavBackStackEntry) -> Unit
) {
addDestination(
ComposeNavigator.Destination(provider[ComposeNavigator::class], content).apply {
this.route = route
this.label = label
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
}
)
}
You can use it this way:
composable("route", "title") {
...
}

How manage Navigation in Jetpack Compose application

I was developing an App with Jetpack Compose, and I try to implement the Navigation Compose component.
My use of case is based on 3 Screens without fragments:
List Screen
Detail Screen
Bought Screen
I also implement a BottomTab where I have List Screen and Bought Screen. the Detail Screen is access from List Screen.
BottomNavigation.kt
#Composable
fun BottomNavigationBar(heightBottomBar: Int, navController: NavController) {
Log.d("navigation", "navigatorName: ${navController.currentBackStackEntry?.destination?.navigatorName}")
Log.d("navigation", "displayName: ${navController.currentBackStackEntry?.destination?.displayName}")
Log.d("navigation", "arguments: ${navController.currentBackStackEntry?.arguments}")
val items = listOf(
BottomNavItem.ListScreen,
BottomNavItem.BoughtScreen
)
BottomNavigation(
Modifier.height(heightBottomBar.dp),
backgroundColor = MaterialTheme.colors.background,
contentColor = Color.White)
{
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
items.forEach { item ->
BottomNavigationItem(
icon = { Icon(painterResource(id = item.icon), contentDescription = item.title) },
label = {
Text(
text = item.title,
fontSize = 9.sp
)
},
selectedContentColor = androidx.compose.ui.graphics.Color.Black,
unselectedContentColor = androidx.compose.ui.graphics.Color.Black.copy(0.4f),
alwaysShowLabel = true,
selected = currentRoute == item.screen_route,
onClick = {
//Added to manage navigate to List screen, instead of detail screen when user pop List tab from BoughtScreen.
// if(item.screen_route == "pokemon_list_screen" && navBackStackEntry?.destination?.parent?.route == "pokemon_list_screen"){
// navController.popBackStack()
// }else{
navController.navigate(item.screen_route) {
navController.graph.startDestinationRoute?.let { screen_route ->
popUpTo(screen_route) {
saveState = true
}
}
launchSingleTop = true
restoreState = true
}
}
//}
)
}
}
}
And ButtonNav.kt file
sealed class BottomNavItem(var title:String, var icon:Int, var screen_route:String){
object BoughtScreen : BottomNavItem("My Pokemons", R.drawable.ic_all_inbox,"pokemon_bought_screen")
object ListScreen: BottomNavItem("List",R.drawable.ic_list,"pokemon_list_screen")
}
the issue is when I try to navigate from Bought Screen to List screen, pop in the List tab, which take me to detail screen, when it should take to List Screen.
The navigation implementation is the following:
#Composable
fun NavigationGraph(navController: NavHostController) {
NavHost(navController = navController, startDestination = Screens.List.route) {
composable(Screens.List.route) {
PokemonListScreen(navController = navController)
}
composable(
Screens.Detail.route,
// use as URL
//which pokemon you want to see in detail with colour in background pass arguments
arguments = listOf(
navArgument("dominantColor") {
type = NavType.IntType
},
navArgument("pokemonName") {
type = NavType.StringType
},
navArgument("number") {
type = NavType.IntType
}
),
) {
val dominantColor = remember {
val color = it.arguments?.getInt("dominantColor")
color?.let { Color(it) } ?: Color.White
}
val pokemonName = remember {
it.arguments?.getString("pokemonName")
}
val pokemonNumber = remember {
it.arguments?.getInt("number")
}
if (pokemonName != null) {
PokemonDetailScreen(
activity = this#MainActivity,
context = applicationContext,
dominantColor = dominantColor,
pokemonName = pokemonName.toLowerCase(Locale.ROOT) ?: " ",
pokemonNumber = pokemonNumber!!,
navController = navController
)
}
}
composable(Screens.Bought.route) {
MyPokemonsScreen(
context = applicationContext,
dominantColor = MaterialTheme.colors.background,
navController = navController
)
}
}
}
And my Screens.kt file:
sealed class Screens(val route: String) {
object List : Screens("pokemon_list_screen")
object Bought : Screens("pokemon_bought_screen")
object Detail : Screens("pokemon_detail_screen/{dominantColor}/{pokemonName}/{number}")
}
As I comment, with this implementation the apps works fine, except when I navigate to Bought Screen, and I click in the List Tab, the app take me to Detail Screen.
So my question is: Should I modify NavController to remove DetailScreen from currentBackStackEntry, or I should manage this from Bottomavigation::class.
I just start with Jetpack Compose, and I a bit undevelop on this topics.
If you have some knowledge about Jetpack Compose Navigation, and are able to help, take thanks in advance !

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

Scaffold with TopAppBar integration with Navigation

How to show navigation icon (BackArrow or Menu) in TopAppBar using Scaffold based on actual position in NavController? I am using Navigating with Compose 1.0.0-alpha02. Below is a sample code with a description of how it should work
#Composable
fun App()
{
val navController = rememberNavController()
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "App title") },
navigationIcon = {
/*
Check if navController back stack has more
than one element. If so show BackButton.
Clicking on that button will move back
*/
val canMoveBack = true
if (canMoveBack)
{
IconButton(onClick = {
// Move back
navController.popBackStack()
}) {
Icon(asset = Icons.Outlined.ArrowBack)
}
}
else
{
IconButton(onClick = {
// show NavDrawer
}) {
Icon(asset = Icons.Outlined.Menu)
}
}
},
)
},
bodyContent = {
AppBody(navController)
}
)
}
I thought about something like navController.backStack.size but I got error NavController.getBackStack can only be called from within the same library group (groupId=androidx.navigation).
And the second question, if I wanted to change the TopAppBar text do I have to hoist this text and give every "screen" possibility to change this text, or is there any easy built-in way to do this like in the standard View System?
Thanks to Abdelilah El Aissaoui I have got an idea of how to do it with one Scaffold and just changing bodyContent. In this method, we don't have to pass navController to any body element, everything is done in base App composable. Below is code which enables to navigate between two bodies (Lesson -> Student)
App:
#Composable
fun App(
viewModel: MainViewModel
)
{
val navController = rememberNavController()
val baseTitle = "" // stringResource(id = R.string.app_name)
val (title, setTitle) = remember { mutableStateOf(baseTitle) }
val (canPop, setCanPop) = remember { mutableStateOf(false) }
val scaffoldState: ScaffoldState = rememberScaffoldState()
navController.addOnDestinationChangedListener { controller, _, _ ->
setCanPop(controller.previousBackStackEntry != null)
}
// check navigation state and navigate
if (viewModel.navigateToStudents.value)
{
navController.navigate(route = STUDENT_SCREEN_ROUTE)
viewModel.studentsNavigated()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = title) },
navigationIcon = {
if (canPop)
{
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(asset = Icons.Outlined.ArrowBack)
}
}
else
{
IconButton(onClick = {
scaffoldState.drawerState.open()
}) {
Icon(asset = Icons.Outlined.Menu)
}
}
},
)
},
scaffoldState = scaffoldState,
drawerContent = {
DrawerContent()
},
bodyContent = {
AppBody(
viewModel = viewModel,
navController = navController,
setTitle = setTitle
)
}
)
}
AppBody
#Composable
fun AppBody(
viewModel: MainViewModel,
navController: NavHostController,
setTitle: (String) -> Unit,
)
{
NavHost(
navController,
startDestination = LESSON_SCREEN_ROUTE
) {
composable(route = LESSON_SCREEN_ROUTE) {
LessonBody(
viewModel = viewModel,
setTitle = setTitle
)
}
composable(
route = STUDENT_SCREEN_ROUTE
) {
StudentBody(
viewModel = viewModel,
setTitle = setTitle
)
}
}
}
In the ViewModel I use this pattern to navigate:
private val _navigateToStudents: MutableState<Boolean> = mutableStateOf(false)
val navigateToStudents: State<Boolean> = _navigateToStudents
fun studentsNavigated()
{
// here we can add any logic after doing navigation
_navigateToStudents.value = false
}
So when I want to navigate to the next fragment I just set _navigateToStudents.value = true
I was just trying to achieve exactly the same today. I think this code will answer both questions:
#Composable
fun NavigationScaffold(
title: String? = null,
navController: NavController? = null,
bodyContent: #Composable (PaddingValues) -> Unit
) {
val navigationIcon: (#Composable () -> Unit)? =
if (navController?.previousBackStackEntry != null) {
{
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(Icons.Filled.ArrowBack)
}
}
} else {
// this can be null or another component
// If null, the navigationIcon won't be rendered at all
null
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = title.orEmpty())
},
navigationIcon = navigationIcon,
)
},
bodyContent = bodyContent
)
}
As you can see, you can provide a title as a String so you don't have to worry about passing a Text composable.
This Scaffold is the base of all my screens and to use it a simply have to write something like that:
#Composable
fun Home(navController: NavController) {
NavigationScaffold(
title = "Home",
navController = navController
) {
// Screen's content...
}
}

Categories

Resources