Source: https://collegeinfogeek.com/file-folder-organization/

Compose e Bottom Navigation

Nelson Glauber
7 min readJan 7, 2021

--

No artigo anterior eu fiz uma breve introdução à navegação de telas utilizando a biblioteca Navigation para Compose. Nesse post, demonstrarei como implementar uma das formas de navegação mais comuns atualmente em aplicações mobile: a abas inferiores (ou bottom tabs).

O intuito desse exemplo é simular o comportamento navegacional da aplicação do YouTube (especificamente a versão 15.50.35), que possui 4 abas iniciais: Início, Explorar, Inscrições e Biblioteca. Ao selecionar a aba "Biblioteca" e escolher uma opção ("Histórico", por exemplo), o conteúdo é exibido na própria aba. Ao pressionar a tecla back do aparelho, a tela anterior é exibida, e ao clicar em voltar novamente, a aba "Início" é selecionada.

Um detalhe importante na aplicação do YouTube, é que o back stack de cada aba é preservado quando o usuário alterna entre as abas. O que é um comportamento que eu particularmente gosto bastante.

Implementação da Documentação

A documentação oficial, disponibiliza um exemplo de uso do BottomNavigation com a biblioteca de Navigation similar ao que vou explicar nessa seção. Primeiramente são declaradas a informações relativas a cada aba utilizando uma sealed class.

sealed class TabItem(
val route: String,
val title: String,
val icon: ImageVector
) {
object ListInfo : TabItem(
"list", "Items", Icons.Filled.List
)
object ProfileInfo : TabItem(
"profile", "Profile", Icons.Filled.Settings
)
}

Na classe acima, foi definido o nome da rota (que será usada pelo Navigation), o título e o ícone da aba. Em seguida, é feita a definição da BottomNavigation.

val navController = rememberNavController()
val items = listOf(
TabItem.ListInfo,
TabItem.ProfileInfo,
)
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by
navController.currentBackStackEntryAsState()
val currentRoute =
navBackStackEntry?.arguments?.getString(KEY_ROUTE)
items.forEach { tabItem ->
BottomTab(
navController,
tabItem,
currentRoute == tabItem.route
)
}
}
}
) {
NavHost(
navController,
startDestination = TabItem.ListInfo.route
) {
composable(TabItem.ListInfo.route) { ListScreen() }
composable(TabItem.ProfileInfo.route) { ProfileScreen() }
}
}

Inicialmente é declarada uma lista com as informações das abas "List" e "Profile". O BottomNavigation é definido dentro do Scaffold, e para saber se a aba está selecionada, foi utilizada a função backStackEntryAsState() onde a rota atual é obtida por meio da propriedade arguments (que é um Bundle) e da chave KEY_ROUTE (da própria biblioteca de Navigation).

A função BottomTab foi definida apenas para facilitar a criação de cada BottomNavigationTab.

@Composable
private fun BottomTab(
navController: NavHostController,
screen: TabItem,
selected: Boolean
) {
BottomNavigationItem(
icon = { Icon(screen.icon) },
label = { Text(screen.title) },
selected = selected,
onClick = {
navController.navigate(screen.route) {
popUpTo = navController.graph.startDestination
launchSingleTop = true
}
}
)
}

Ao executar a aplicação, tudo funciona como esperado: as abas são exibidas; a troca de abas é realizada; a aba selecionada é preservada quando o aparelho muda de orientação; a primeira aba é exibida ao pressionar a tecla back quando a segunda aba está selecionada; … Enfim, tudo parece estar bem.

O problema começa quando é preciso fazer com que haja uma pilha de telas em cada aba. No exemplo acima, é possível observar que quando uma aba é selecionada, a tela volta para o destino inicial (passando a startDestination para o atributo popUpTo, o que não é o comportamento desejado quando comparado com a aplicação do YouTube.

Mantendo duas (ou mais) pilhas de tela

Para contornar o problema, é preciso modificar a forma como a mudança de abas é tratada fazendo algumas coisas manualmente…

var currentTab by savedInstanceState { TabItem.ListInfo.route }
val items = listOf(
TabItem.ListInfo,
TabItem.ProfileInfo,
)
Scaffold(
bottomBar = {
BottomNavigation {
items.forEach { tabItem ->
BottomNavigationItem(

icon = { Icon(tabItem.icon) },
label = { Text(tabItem.title) },
selected = tabItem.route == currentTab,
onClick = {
currentTab = tabItem.route
}
)

}
}
}
) {
TabContent(currentTab)

}

A primeira mudança realizada é que o controle da aba selecionada é feito por meio do estado currentTab. Perceba que foi usado o savedInstanceState para que essa informação seja mantida em caso de mudanças de configuração (como rotacionar a tela). Outra mudança é que o BottomNavigationItem está sendo declarado diretamente, então a função BottomTab criada anteriormente pode ser removida. Ao clicar na aba selecionada, o estado currentTab é atualizado. Por fim, a função TabContent descrita a seguir exibirá a aba selecionada.

@Composable
fun TabContent(tabItem: String) {
val tab1NavState =
savedInstanceState { Bundle() }
val tab2NavState =
savedInstanceState { Bundle() }

when (tabItem) {
TabItem.ListInfo.route -> {
TabWrapper(tab1NavState) { navController ->
TabList(navController)
}
}
TabItem.ProfileInfo.route -> {
TabWrapper(tab2NavState) { navController ->
TabProfile(navController)
}
}
}
}

Na função TabContent, os estados de navegação das duas abas são recuperados. Perceba que mais uma vez foi utilizada a função savedInstanceState para que essa informação seja preservada. A função TabWrapper ficará responsável por atualizar esse estado quando ocorrer uma navegação na aba selecionada.

Vejamos a implementação da função TabWrapper a seguir:

@Composable
fun TabWrapper(
navState: MutableState<Bundle>,
content: @Composable (NavHostController) -> Unit
) {
val navController = rememberNavController()
onCommit {
val callback = NavController.OnDestinationChangedListener {
navController, _, _ ->
navState.value =
navController.saveState() ?: Bundle()
}
navController.addOnDestinationChangedListener(callback)
navController.restoreState(navState.value)

onDispose {
navController
.removeOnDestinationChangedListener(callback)
navController.enableOnBackPressed(false)
}
}
content(navController)
}

Além do estado da navegação, essa função recebe a @Composable function content que vai exibir o conteúdo da aba. Afinal, a função TabWrapper (como o próprio nome diz) é apenas um wrapper para controlar o estado navegacional da aba. Como cada aba possui sua navegação, a função rememberNavController é chamada aqui.

Ao adicionar o componente à tela, a função onCommit é invocada, e neste momento, é declarado um callback que é chamado sempre quando o NavControler muda de destino, ou seja, quando uma outra rota é requisitada. Neste momento, o estado da navegação é salvo (usando a função saveState). Ainda no onCommit, o estado de navegação é recuperado utilizando a função restoreState.

Quando o componente é removido da tela, a função onDispose é invocada. Então o callback é removido do navController e a tecla back é desabilitada para quando o navigation não estiver sendo exibido (enableOnBackPressed).

A função TabWrapper envolverá (wraps) as abas TabList e TabProfile listadas a seguir:

@Composable
fun TabList(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "list"
) {
composable("list") { ListScreen(navController) }
composable("list_details") { DetailScreen() }
}
}

@Composable
fun TabProfile(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "profile"
) {
composable("profile") { ProfileScreen(navController) }
composable("profile_details") { ProfileDetailScreen() }
}
}

Como pode ser observado, cada aba contém um NavHost, que possui duas "telas" cada uma. Os conteúdos das telas são irrelevantes para esse post, mas vou listá-las a seguir:

@Composable
fun ListScreen(navController: NavHostController) {
Column(
modifier = Modifier
.background(Color(0xFF9575CD)).fillMaxSize()
) {
Text(text = "Tab 1")
Button(onClick = {
navController.navigate("list_details")
}) {
Text("Next")
}
}
}

@Composable
fun DetailScreen() {
Text(text = "Tab 1 - Details")
}

@Composable
fun ProfileScreen(navController: NavHostController) {
Column(
modifier = Modifier
.background(Color(0xFF90A4AE)).fillMaxSize()
) {
Text(text = "Tab 2")
Button(onClick = {
navController.navigate("profile_details")
}) {
Text("Next")
}
}
}

@Composable
fun ProfileDetailScreen() {
Text(text = "Tab 2 - Details")
}

Ao executar a aplicação agora teremos duas abas com fluxos de telas independentes. Na primeira aba podemos ter duas telas empilhadas e na segunda aba também!

BottomNavigation com Compose

Só tem um problema: quando a segunda aba estiver selecionada e estiver exibindo a primeira tela, ao pressionar a tecla voltar a primeira aba não é selecionada (que é o comportamento da aplicação do YouTube). Vejamos como resolver isso…

Resolvendo o problema do back press

Para controlar a tela back podemos utilizar a função BackHandler.

Na primeira tela da segunda aba (ProfileScreen) esse componente deve ser adicionado e utilizado da seguinte forma:

@Composable
fun ProfileScreen(
navController: NavHostController,
onBackPressed: () -> Unit
) {
...
BackHandler(onBack = {
onBackPressed()
})

}

O que é preciso fazer na ProfileScreen é atualizar a aba selecionada, mas esse estado é definido alguns níveis acima na hierarquia dessa tela, então apenas o callback onBack é passado como parâmetro, e essa função é chamada pelo BackHandler.

É preciso então passar o callback até chegar à ProfileScreen. Portando é necessário fazer o seguinte ajuste emTabProfile:

@Composable
fun TabProfile(
navController: NavHostController,
onBackPressed: () -> Unit
) {
NavHost(...) {
composable("profile") {
ProfileScreen(navController, onBackPressed)
}
...
}
}

Um ajuste similar deve ser feito na TabContent:

@Composable
fun TabContent(tabItem: String, onBackPressed: () -> Unit) {
...
when (tabItem) {
...
TabItem.ProfileInfo.route -> {
TabWrapper(tab2NavState) { navController ->
TabProfile(navController, onBackPressed)
}
}
}
}

E finalmente, ao chamar o TabContent, o callback que seleciona a primeira aba é passado como parâmetro.

Scaffold(...) {
TabContent(currentTab) { currentTab = TabItem.ListInfo.route }
}

Agora a aplicação deve estar funcionando como esperado.

Obviamente existe uma forma de não passar esse callbacks como parâmetro. Mas deixei desta forma para deixar o exemplo mais simples. Se quiser saber mais como evitar essa abordagem, dê uma olhada sobre Ambient na documentação do Compose.

Conclusão

A biblioteca de navegação está só no começo, mas já possui funcionalidades bem interessantes. Espero que mais recursos sejam adicionados para suportar mais casos de uso essenciais na implementação de uma boa navegação de uma aplicação Android.

Espero que tenham gostado e qualquer dúvida, deixem seus comentários.

4br4ç05
nglauber

Referências

--

--

Nelson Glauber

Android Developer. Google Developer Expert for Android/Kotlin. Author of “Dominando o Android”.