15

I have an Android Jetpack Compose application that uses BottomNavigation and TopAppBar composables. From the tab opened via BottomNavigation users can navigate deeper into the navigation graph.

The problem

The TopAppBar composable must represent the current screen, e.g. display its name, implement some options that are specific to the screen opened, the back button if the screen is high-level. However, Jetpack Compose seems to have no out-of-the-box solution to that, and developers must implement it by themselves.

So, obvious ideas come with obvious drawbacks, some ideas are better than others.

The baseline for tracking navigation, as suggested by Google (at least for BottomNavigation), is a sealed class containing objects that represent the current active screen. Specifically for my project, it's like this:

sealed class AppTab(val route: String, @StringRes val resourceId: Int, val icon: ImageVector) {
    object Events: AppTab("events_tab", R.string.events, Icons.Default.EventNote)
    object Projects: AppTab("projects_tab", R.string.projects, Icons.Default.Widgets)
    object Devices: AppTab("devices_tab", R.string.devices, Icons.Default.DevicesOther)
    object Employees: AppTab("employees_tab", R.string.employees, Icons.Default.People)
    object Profile: AppTab("profile_tab", R.string.profile, Icons.Default.AccountCircle)
}

Now the TopAppBar can know what tab is opened, provided we remember the AppTab object, but how does it know if a screen is opened from within a given tab?

Solution 1 - obvious and obviously wrong

We provide each screen its own TopAppBar and let it handle all the necessary logic. Aside from a lot of code duplication, each screen's TopAppBar will be recomposed on opening the screen, and, as described in this post, will flicker.

Solution 2 - not quite elegant

From now on I decided to have a single TopAppBar in my project's top level composable, that will depend on a state with current screen saved. Now we can easily implement logic for Tabs.

To solve the problem of screens opened from within a Tab, I extended Google's idea and implemented a general AppScreen class that represents every screen that can be opened:

// This class represents any screen - tabs and their subscreens.
// It is needed to appropriately change top app bar behavior
sealed class AppScreen(@StringRes val screenNameResource: Int) {
    // Employee-related
    object Employees: AppScreen(R.string.employees)
    object EmployeeDetails: AppScreen(R.string.profile)

    // Events-related
    object Events: AppScreen(R.string.events)
    object EventDetails: AppScreen(R.string.event)
    object EventNew: AppScreen(R.string.event_new)

    // Projects-related
    object Projects: AppScreen(R.string.projects)

    // Devices-related
    object Devices: AppScreen(R.string.devices)

    // Profile-related
    object Profile: AppScreen(R.string.profile)
}

I then save it to a state in the top-level composable in the scope of TopAppBar and pass currentScreenHandler as an onNavigate argument to my Tab composables:

    var currentScreen by remember { mutableStateOf(defaultTab.asScreen()) }

    val currentScreenHandler: (AppScreen) -> Unit = {navigatedScreen -> currentScreen = navigatedScreen}
// Somewhere in the bodyContent of a Scaffold
                when (currentTab) {
                    AppTab.Employees -> EmployeesTab(currentScreenHandler)
                // And other tabs
                // ...
                }

And from inside the Tab composable:

    val navController = rememberNavController()

    NavHost(navController, startDestination = "employees") {
        composable("employees") {
            onNavigate(AppScreen.Employees)
            Employees(it.hiltViewModel(), navController)
        }
        composable("employee/{userId}") {
            onNavigate(AppScreen.EmployeeDetails)
            Employee(it.hiltViewModel())
        }
    }

Now the TopAppBar in the root composable knows about higher-level screens and can implement necessary logic. But doing this for every subscreen of an app? A considerable amount of code duplication, and architecture of communication between this app bar and a composable it represents (how the composable reacts to actions performed on the app bar) is yet to be composed (pun intended).

Solution 3 - the best?

I implemented a viewModel for handling the needed logic, as it seemed like the most elegant solution:

@HiltViewModel
class AppBarViewModel @Inject constructor() : ViewModel() {
    private val defaultTab = AppTab.Events
    private val _currentScreen = MutableStateFlow(defaultTab.asScreen())
    val currentScreen: StateFlow<AppScreen> = _currentScreen

    fun onNavigate(screen: AppScreen) {
        _currentScreen.value = screen
    }
}

Root composable:

    val currentScreen by appBarViewModel.currentScreen.collectAsState()

But it didn't solve the code duplication problem of the second solution. First of all, I had to pass this viewModel to the root composable from MainActivity, as there appears to be no other way of accessing it from inside a composable. So now, instead of passing a currentScreenHandler to Tab composables, I pass a viewModel to them, and instead of calling the handler on navigate event, I call viewModel.onNavigate(AppScreen), so there's even more code! At least, I maybe can implement a communication mechanism mentioned in the previous solution.

The question

For now the second solution seems to be the best in terms of code amount, but the third one allows for communication and more flexibility down the line for some yet to be requested features. I may be missing something obvious and elegant. Which of my implementations you consider the best, and if none, what would you do to solve this problem?

Thank you.

Calamity
  • 700
  • 7
  • 23

1 Answers1

4

I use a single TopAppBar in the Scaffold and use a different title, drop-down menu, icons, etc by raising events from the Composables. That way, I can use just a single TopAppBar with different values. Here is an example:

    val navController = rememberNavController()
    var canPop by remember { mutableStateOf(false) }

    var appTitle by remember { mutableStateOf("") }
    var showFab by remember { mutableStateOf(false) }

    var showDropdownMenu by remember { mutableStateOf(false) }
    var dropdownMenuExpanded by remember { mutableStateOf(false) }
    var dropdownMenuName by remember { mutableStateOf("") }
    var topAppBarIconsName by remember { mutableStateOf("") }

    val scaffoldState = rememberScaffoldState()
    val scope = rememberCoroutineScope()

    val tourViewModel: TourViewModel = viewModel()
    val clientViewModel: ClientViewModel = viewModel()

    navController.addOnDestinationChangedListener { controller, _, _ ->
        canPop = controller.previousBackStackEntry != null
    }

    val navigationIcon: (@Composable () -> Unit)? =
        if (canPop) {
            {
                IconButton(onClick = { navController.popBackStack() }) {
                    Icon(
                        imageVector = Icons.Filled.ArrowBack,
                        contentDescription = "Back Arrow"
                    )
                }
            }
        } else {
            {
                IconButton(onClick = {
                    scope.launch {
                        scaffoldState.drawerState.apply {
                            if (isClosed) open() else close()
                        }
                    }
                }) {
                    Icon(Icons.Filled.Menu, contentDescription = null)
                }
            }
        }

    Scaffold(
        scaffoldState = scaffoldState,
        drawerContent = {
            DrawerContents(
                navController,
                onMenuItemClick = { scope.launch { scaffoldState.drawerState.close() } })
        },
        topBar = {
            TopAppBar(
                title = { Text(appTitle) },
                navigationIcon = navigationIcon,
                elevation = 8.dp,
                actions = {
                    when (topAppBarIconsName) {
                        "ClientDirectoryScreenIcons" -> {
                            // search icon on client directory screen
                            IconButton(onClick = {
                                clientViewModel.toggleSearchBar()
                            }) {
                                Icon(
                                    imageVector = Icons.Filled.Search,
                                    contentDescription = "Search Contacts"
                                )
                            }
                        }
                    }

                    if (showDropdownMenu) {
                        IconButton(onClick = { dropdownMenuExpanded = true }) {
                            Icon(imageVector = Icons.Filled.MoreVert, contentDescription = null)

                            DropdownMenu(
                                expanded = dropdownMenuExpanded,
                                onDismissRequest = { dropdownMenuExpanded = false }
                            ) {

                                // show different dropdowns based on different screens
                                when (dropdownMenuName) {
                                    "ClientDirectoryScreenDropdown" -> ClientDirectoryScreenDropdown(
                                        onDropdownMenuExpanded = { dropdownMenuExpanded = it })
                                }
                            }
                        }
                    }
                }
            )
        },
...
   ) { paddingValues ->

        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)

        ) {
            NavHost(
                navController = navController,
                startDestination = Screen.Tours.route
            ) {
                composable(Screen.Tours.route) {
                    TourScreen(
                        tourViewModel = tourViewModel,
                        onSetAppTitle = { appTitle = it },
                        onShowDropdownMenu = { showDropdownMenu = it },
                        onTopAppBarIconsName = { topAppBarIconsName = it }
                    )
                }

Then set the TopAppBar values from different screens like this:

@Composable
fun TourScreen(
    tourViewModel: TourViewModel,
    onSetAppTitle: (String) -> Unit,
    onShowDropdownMenu: (Boolean) -> Unit,
    onTopAppBarIconsName: (String) -> Unit
) {
    LaunchedEffect(Unit) {
        onSetAppTitle("Tours")
        onShowDropdownMenu(false)
        onTopAppBarIconsName("")
    }
...

Not probably the perfect way of doing it, but no duplicate code.

Raw Hasan
  • 1,096
  • 1
  • 9
  • 25
  • 1
    I can see that you are using somewhat of a combination of solution 2 (passing down event handlers) and solution 3 (view model that also implements communication). I generally don't like solution 2 because of passing a handler to a composable for it to just be passed down again and so on. But I now know that view model way of implementing communication is not cringe and also used. Thanks! – Calamity Oct 03 '21 at 08:40
  • You can pass viewModel, navigation, etc to the first level of your screens, but no need to pass them down farther. Use state hoisting instead and handle the actions of the lower-level screens from the top level. That way, you don't need to pass resources everywhere, which is the recommended way. – Raw Hasan Oct 03 '21 at 09:00
  • 1
    `.addOnDestinationChangedListener` should occur in a `LaunchedEffect(key1 = Unit, block = { ...addOnDestinationChangedListener(...) }` or similar to prevent it from being _added_ on each re-composition – William Reed May 03 '22 at 18:34