How to contribute to AppBar from Screen in jetpack compose - android

I want to implement a simple user flow, where the user sees multiple screens to input data. The flow should share a common navbar where each screen can contribute its menu items to when it is active (e.g. add a "search" or a "next" button). The navbar also has buttons belonging conceptually to the user flow and not to individual screens (like the back button and a close button). Screens should be reusable in other contexts, so screens should not know about the flow they operate in.
Technically the user flow is implemented as a compose function defining the navbar and using compose navigation. Each screen is implemented as a separate compose function.
In fragment/view based Android this scenario was supported out of box with onCreateOptionsMenu and related functions. But how would I do this in compose? I could not find any guidance on that topic.
To illustrate the problem in code:
#Composable
fun PaymentCoordinator(
navController: NavHostController = rememberNavController()
) {
AppTheme {
Scaffold(
bottomBar = {
BottomAppBar(backgroundColor = Color.Red) {
IconButton(onClick = navController::popBackStack) {
Icon(Icons.Filled.ArrowBack, "Back")
}
Spacer(modifier = Modifier.weight(1f))
// 0..n IconButtons provided by the active Screen
// should be inserted here
// How can we do that, because state should never
// go up from child to parent
// this button (or at least its text and onClick action) should
// be defined by the currently visible Screen as well
Button(
onClick = { /* How to call function of screen? */ }
) {
Text("Next"))
}
}
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
NavHost(
navController = navController,
startDestination = "selectAccount"
) {
// screens that can contribute items to the menu
composable("selectAccount") {
AccountSelectionRoute(
onAccountSelected = {
navController.navigate("nextScreen")
}
)
}
composable("...") {
// ...
}
}
}
}
}
}

I came up with an approach leveraging side effects and lifecycle listener to achieve my goal. Basically whenever a screen becomes active (ON_START) it informs the parent (coordinator) about its menu configuration. The coordinator evaluates the configuration and updates the navbar accordingly.
The approach is based on Googles documentation on side effects (https://developer.android.com/jetpack/compose/side-effects#disposableeffect)
The approach feels complicated and awkward and I think the compose framework is missing some functionality to achieve this here. However, my implementation seems to be working fine in my test use case.
Helper classes
// currently I only need to configure a single button, however the approach
// can be easily extended now (you can put anything inside rightButton)
data class MenuConfiguration(
val rightButton: #Composable () -> Unit
)
#Composable
fun SimpleMenuConfiguration(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onRegisterMenuConfiguration: (MenuConfiguration?) -> Unit,
onUnregisterMenuConfiguration: () -> Unit,
rightButton: #Composable () -> Unit
) {
val currentOnRegisterMenuConfiguration by rememberUpdatedState(onRegisterMenuConfiguration)
val currentOnUnregisterMenuConfiguration by rememberUpdatedState(onUnregisterMenuConfiguration)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnRegisterMenuConfiguration(
MenuConfiguration(
rightButton = rightButton
)
)
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnUnregisterMenuConfiguration()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
Coordinator level
#Composable
fun PaymentCoordinator(
navController: NavHostController = rememberNavController()
) {
var menuConfiguration by remember { mutableStateOf<MenuConfiguration?>(null) }
AppTheme {
Scaffold(
bottomBar = {
BottomAppBar(backgroundColor = Color.Red) {
IconButton(onClick = navController::popBackStack) {
Icon(Icons.Filled.ArrowBack, "Back")
}
Spacer(modifier = Modifier.weight(1f))
menuConfiguration?.rightButton?.invoke()
}
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
PaymentNavHost(
navController = navController,
finishedHandler = finishedHandler,
onRegisterMenuConfiguration = { menuConfiguration = it },
onUnregisterMenuConfiguration = { menuConfiguration = null }
)
}
}
}
}
#Composable
fun PaymentNavHost(
navController: NavHostController = rememberNavController(),
onRegisterMenuConfiguration: (MenuConfiguration?) -> Unit,
onUnregisterMenuConfiguration:() -> Unit
) {
NavHost(
navController = navController,
startDestination = "selectAccount"
) {
composable("selectAccount") {
DemoAccountSelectionRoute(
onAccountSelected = {
navController.navigate("amountInput")
},
onRegisterMenuConfiguration = onRegisterMenuConfiguration,
onUnregisterMenuConfiguration = onUnregisterMenuConfiguration
)
}
composable("amountInput") {
AmountInputRoute(
onRegisterMenuConfiguration = onRegisterMenuConfiguration,
onUnregisterMenuConfiguration = onUnregisterMenuConfiguration,
onFinished = {
...
}
)
}
}
}
Screen level
#Composable
internal fun AmountInputRoute(
onRegisterMenuConfiguration: (MenuConfiguration?) -> Unit,
onUnregisterMenuConfiguration:() -> Unit,
onFinished: (Amount?) -> Unit
) {
SimpleMenuConfiguration(
onRegisterMenuConfiguration = onRegisterMenuConfiguration,
onUnregisterMenuConfiguration = onUnregisterMenuConfiguration,
rightButton = {
Button(
onClick = {
...
}
) {
Text(text = stringResource(id = R.string.next))
}
}
)

Related

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 !

Will navController.navigate() cause Android recomposition when I use Compose?

The Code A is from the end branch of the official sample project.
When I click the Tab of the UI, the App will show the corresponding UI, and the current Tab will be marked as a different style.
In Overview.name Tab UI page, if I click a account item, the code composable(Overview.name) {... onAccountClick = { name -> navigateToSingleAccount(navController, name) } will navigate it to composable( route = "$accountsName/{name}", ..) UI , and Accounts.name is marked as current Tab.
What make me confuse is why Android can make Accounts.name as current Tab, could you tell me? Will navController.navigate() cause Android recomposition? so is val currentScreen = RallyScreen.fromRoute(backstackEntry.value?.destination?.route) re-executed ?
Code A
#Composable
fun RallyApp() {
RallyTheme {
val allScreens = RallyScreen.values().toList()
val navController = rememberNavController()
val backstackEntry = navController.currentBackStackEntryAsState()
val currentScreen = RallyScreen.fromRoute(backstackEntry.value?.destination?.route)
Scaffold(
topBar = {
RallyTabRow(
allScreens = allScreens,
onTabSelected = { screen ->
navController.navigate(screen.name)
},
currentScreen = currentScreen
)
}
) { innerPadding ->
RallyNavHost(navController, modifier = Modifier.padding(innerPadding))
}
}
}
#Composable
fun RallyNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
NavHost(
navController = navController,
startDestination = Overview.name,
modifier = modifier
) {
composable(Overview.name) {
OverviewBody(
onClickSeeAllAccounts = { navController.navigate(Accounts.name) },
onClickSeeAllBills = { navController.navigate(Bills.name) },
onAccountClick = { name ->
navigateToSingleAccount(navController, name)
},
)
}
composable(Accounts.name) {
AccountsBody(accounts = UserData.accounts) { name ->
navigateToSingleAccount(navController = navController, accountName = name)
}
}
composable(Bills.name) {
...
}
val accountsName = Accounts.name
composable(
route = "$accountsName/{name}",
arguments = listOf(
navArgument("name") {
type = NavType.StringType
}
),
deepLinks = listOf(
navDeepLink {
uriPattern = "rally://$accountsName/{name}"
}
),
) { entry ->
val accountName = entry.arguments?.getString("name")
val account = UserData.getAccount(accountName)
SingleAccountBody(account = account)
}
}
}
private fun navigateToSingleAccount(navController: NavHostController, accountName: String) {
navController.navigate("${Accounts.name}/$accountName")
}
enum class RallyScreen(
val icon: ImageVector,
) {
Overview(
icon = Icons.Filled.PieChart,
),
Accounts(
icon = Icons.Filled.AttachMoney,
),
Bills(
icon = Icons.Filled.MoneyOff,
);
companion object {
fun fromRoute(route: String?): RallyScreen =
when (route?.substringBefore("/")) {
Accounts.name -> Accounts
Bills.name -> Bills
Overview.name -> Overview
null -> Overview
else -> throw IllegalArgumentException("Route $route is not recognized.")
}
}
}
Yes, navigate() will definitely cause recomposition as the UI is getting re-created.
Yes, val currentScreen = RallyScreen.fromRoute(backstackEntry.value?.destination?.route) will be re-executed as backstackEntry is being observed as state and its value will change when you navigate. That's why the RallyApp composable will also get recomposed. So when it is executed again, currentScreen value will get updated.
You can solve it using Effects as per your requirement. In this scenario, you can use LaunchedEffect. like,
LaunchedEffect(null) {
// code that you don't want to run again.
}
Another approach for now(Which I do not recommend), to avoid your composable code from re-running by checking the current route in the back-stack entry and your expected route. To do that you can use the following piece of code.
#Composable
fun YourComposableScreen(navController: NavController) {
if (yourExpectedRoute != navController.currentBackStackEntry?.destination?.route) {
return
}
// Your other composable(s) goes here.
}

Keeping the Google Maps state in Jetpack Compose

I am trying to build an app with navigation to multiple different screens (using Bottom Navigation).
One of the screens, the startDestination, is a Google Maps view, looking at Official compose example: Crane to get it to work, and it does.
However, when navigating to another screen, and back, the MapView gets recomposed and is slowly loading back in. We start back at the initial camera position, zoom level, and so on. There probably is a way to remember and re-apply those attributes, but I am more interested in keeping the complete state of the Google Maps view intact. (Looking at the current Google Maps app, for Android, it does exactly what I'm looking for, eventhough they aren't using Jetpack Compose)
Is there a way to achieve this?
I already remember the MapView
#Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
id = R.id.map
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle, mapView) {
// Make MapView follow the current lifecycle
val lifecycleObserver = getMapLifecycleObserver(mapView)
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
return mapView
}
private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
else -> throw IllegalStateException()
}
}
To give more context, the MapView is in the top of this screen
#ExperimentalMaterialApi
#Composable
fun MapsScreen(
modifier: Modifier = Modifier,
viewModel: EventViewModel = viewModel()
) {
...
val mapView = rememberMapViewWithLifecycle()
MapsScreenView(modifier, uiState, mapView)
}
A completely different approach I tried, is through a BackdropScaffold (in a Scaffold because I want the BottomBar..) where backLayerContent is the MapsScreen() and the frontLayerContent are the other screens, the "front" is configured so it will cover the entire screen when active. It feels really ugly and horrible, but it kinda works..
Scaffold(
topBar = {
SmallTopAppBar(
title = { Text(text = "Test") },
)
},
bottomBar = {
BottomBar(navController) { screen ->
showMaps = screen == Screen.MainMaps
coroutineScope.launch {
if (showMaps) scaffoldState.reveal() else scaffoldState.conceal()
}
}
},
content = { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
BackdropScaffold(
scaffoldState = scaffoldState,
appBar = { },
frontLayerContent = {
EventsScreen()
},
backLayerContent = {
MapsScreen()
},
peekHeight = if (showMaps) 300.dp else 0.dp,
headerHeight = if (showMaps) BackdropScaffoldDefaults.HeaderHeight else 0.dp,
gesturesEnabled = showMaps
)
}
}
)
Did anyone have this same problem and found an actual solution? (We really need Jetpack Compose support for this I guess, intead of the AndroidView approach)
Google just publish a library to handle the MapView state Android-Maps-Compose
val cameraPositionState: CameraPositionState = rememberCameraPositionState()
GoogleMap(cameraPositionState = cameraPositionState)
Button(onClick = { cameraPositionState.move(CameraUpdateFactory.zoomIn()) }) {
Text(text = "Zoom In")
}

Jetpack Compose call function from viewmodel in my bottom bar

I have a Bottom Bar, and in this composable function I want to call a function that i've set up in a ViewModel in my Apps Navigation's Nav Graph, but can't think of any way to do this? I've played with some interfaces however I'm not getting anywhere
#Composable
fun BottomNavBar(
currentRoute: String?,
navigateToBuild: () -> Unit,
navigateToSaved: () -> Unit
) {
Column() {
Row(modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.Gray)) {
}
BottomAppBar(
modifier = Modifier
.height(72.dp),
backgroundColor = Color.White
) {
navItems.forEach { item ->
val selected = currentRoute == item.route
BottomNavigationItem(
icon = {
Image(
painter = painterResource(
id = if (selected) item.selectedIcon else
item.unselectedIcon),
contentDescription = item.title
)
},
selected = selected,
onClick = {
when (item.route) {
NavigationItem.Build.route -> {
navigateToBuild()
}
NavigationItem.Saved.route -> {
navigateToSaved()
// I want to call viewmodel function here
}
}
}
)
}
}
}
}
My Bottom bar is part of the scaffold here, and my viewmodel is inside the AppNavigation composable, so they are both completely separate and I can't think of any way for them to communicate?
Scaffold(
bottomBar = {
BottomNavBar(
currentRoute = currentRoute,
navigateToBuild = { navController.navigate("build/0") },
navigateToSaved = { navController.navigate(DashboardScreens.Saved.route) })
}
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.background(Color.White)
) {
AppNavigation(navHostController = navController)
}
}
In Compose, the view model lifecycle is bound to the compose navigation route (if there is one) or to Activity / Fragment otherwise, depending on where setContent was called from.
The first viewModel() call creates a view model, all other calls to the same class from any composable function will return the same object to you. So as long as you are inside the same navigation route, you can safely call viewModel() from BottomNavBar:
#Composable
fun BottomNavBar(
currentRoute: String?,
navigateToBuild: () -> Unit,
navigateToSaved: () -> Unit
) {
val viewModel = viewModel<ViewModelClass>()
}
#Composable
fun AppNavigation(navHostController: NavController) {
val viewModel = viewModel<ViewModelClass>()
}

Jetpack Component screens overlaps, or partially rendered, when being navigated with Jetpack Compose navigation controller

Here is the symptom - Youtube
It looks like screens are partially rendered, or overlapped, when being navigated with Jetpack Compose navigation controller.
I try to find an answer one, but no one seems to meet this problem at all.
Here are related code snippets
#Composable
fun NavHostAuth(
navController: NavHostController,
) {
NavHost(
navController = navController,
startDestination = NavRoutesAuth.SignIn.route
) {
composable(NavRoutesAuth.SignIn.route) {
EnterAnimation {
ScreenSignInWithInputValidationDebounce(navController)
}
}
composable(NavRoutesAuth.SignUp.route) {
EnterAnimation {
ScreenSignUpWithInputValidationDebounce(navController)
}
}
}
}
enum class NavRoutesAuth(val route: String) {
SignIn("auth/signin"),
SignUp("auth/signup"),
}
#OptIn(ExperimentalAnimationApi::class)
#Composable
fun EnterAnimation(content: #Composable () -> Unit) {
val visible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = visible,
modifier = Modifier,
enter = slideInVertically(initialOffsetY = { -40 })
+ expandVertically(expandFrom = Alignment.Top)
+ fadeIn(initialAlpha = 0.3f),
exit = slideOutVertically()
+ shrinkVertically()
+ fadeOut()
) {
content()
}
}
What might be the cause?
Thanks.

Categories

Resources