Search code examples
androidandroid-jetpack-composeandroid-jetpack-navigation

How to contribute to AppBar from Screen in jetpack compose


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("...") {
                        // ...
                    }
                }
            }
        }
    }
}

Solution

  • 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 MenuConfiguration)
    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))
                }
            }
        )
    

    ** Alternative Approach ** In this git repo, there is another slightly different approach to solve the problem The main difference is, that there is not need to do the removal of screen specific menu items via onDispose. Instead the screen specific menu will registered to a menu manager via the target route of the navGraph and depending on the currently active rout the correct screen specific route will be loaded.