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 object
s 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.