Múltiplas pilhas traseiras

Tempo de leitura: 8 minutes

Se uma “pilha de retorno” é um conjunto de telas que você pode navegar de volta por meio do botão Voltar do sistema, “várias pilhas de retorno” são apenas um monte delas, certo? Bem, isso é exatamente o que fizemos com o suporte múltiplo back stack adicionado no Navigation 2.4.0-alpha01 e Fragment 1.4.0-alpha01!

 

As alegrias do botão de voltar do sistema

Esteja você usando o novo sistema de navegação por gestos do Android ou a barra de navegação tradicional, a capacidade dos usuários de ‘voltar’ é uma parte fundamental para a experiência do usuário no Android e fazer isso da maneira certa é uma parte importante para fazer seu aplicativo parecer um parte natural do ecossistema.

Nos casos mais simples, o botão Voltar do sistema apenas finaliza sua atividade. Embora no passado você pudesse se sentir tentado a substituir o método onBackPressed() de sua atividade para personalizar esse comportamento, é 2021 e isso é totalmente desnecessário. Em vez disso, existem APIs para navegação personalizada de volta no OnBackPressedDispatcher. Esta é na verdade a mesma API que o FragmentManager e o NavController já se conectam.

Isso significa que quando você usa Fragments ou Navigation, eles usam o OnBackPressedDispatcher para garantir que, se você estiver usando as APIs de back stack, o botão Voltar do sistema funcionará para reverter cada uma das telas que você empurrou para a back stack.

Várias pilhas anteriores não mudam esses fundamentos. O botão de voltar do sistema ainda é um comando direcional – “voltar”. Isso tem um efeito profundo em como as várias APIs de back stack funcionam.

 

Múltiplas pilhas traseiras em fragmentos

No nível da superfície, o suporte para várias pilhas traseiras é enganosamente simples, mas requer um pouco de uma explicação do que realmente é a “pilha traseira de fragmentos”. A pilha de retorno do FragmentManager não é composta de fragmentos, mas sim de transações de fragmentos. Especificamente, aqueles que usaram a API addToBackStack(String name).

Isso significa que quando você commit() uma transação de fragmento com addToBackStack(), o FragmentManager vai executar a transação passando por e executando cada uma das operações (a substituição, etc.) que você especificou na transação, movendo cada fragmento até o estado esperado. FragmentManager então mantém essa transação como parte de sua pilha de retorno.

Quando você chama popBackStack() (diretamente ou por meio da integração do FragmentManager com o botão Voltar do sistema), a transação superior na pilha de retorno do fragmento é revertida – um fragmento adicionado é removido, um fragmento oculto é mostrado, etc. Isso coloca o FragmentManager de volta no mesmo estado em que estava antes da transação do fragmento ser inicialmente confirmada.

Nota: Eu não posso enfatizar isso o suficiente, mas você absolutamente nunca deve intercalar transações com addToBackStack() e transações sem no mesmo FragmentManager: transações em sua pilha traseira felizmente não estão cientes de transações de fragmento de alteração de pilha não reversa – trocando coisas de baixo transações faz essa reversão quando você apresenta uma proposta muito mais arriscada.

Isso significa que popBackStack() é uma operação destrutiva: qualquer fragmento adicionado terá seu estado destruído quando a transação for interrompida. Isso significa que você perde seu estado de visualização, qualquer estado de instância salvo e todas as instâncias de ViewModel anexadas a esse fragmento são apagadas. Esta é a principal diferença entre essa API e o novo saveBackStack(). saveBackStack() faz a mesma reversão que popping a transação faz, mas garante que o estado de exibição, estado de instância salva e instâncias de ViewModel sejam salvos da destruição. É assim que a API restoreBackStack() pode posteriormente recriar essas transações e seus fragmentos do estado salvo e efetivamente ‘refazer’ tudo o que foi salvo. Magia!

No entanto, isso não aconteceu sem pagar muitas dívidas técnicas.

 

Pagando nossas dívidas técnicas em Fragments

Embora os fragmentos sempre salvaram o estado de exibição do Fragment, a única vez que o onSaveInstanceState() de um fragmento seria chamado seria quando o onSaveInstanceState() da Activity fosse chamado. Para garantir que o estado da instância salvo seja salvo ao chamar saveBackStack(), precisamos também injetar uma chamada para onSaveInstanceState() no ponto certo nas transições do ciclo de vida do fragmento. Não podemos chamá-lo muito cedo (seu fragmento nunca deve ter seu estado salvo enquanto ainda estiver STARTED), mas não tarde demais (você deseja salvar o estado antes que o fragmento seja destruído).

Este requisito deu início a um processo para corrigir como FragmentManager muda para estado para garantir que haja um local que gerencie a movimentação de um fragmento para seu estado esperado e lida com o comportamento de reentrada e todas as transições de estado que vão para os fragmentos.

Após 35 mudanças e 6 meses nessa reestruturação de fragmentos, descobriu-se que os fragmentos adiados foram seriamente quebrados, levando a um mundo onde as transações postergadas foram deixadas flutuando no limbo – não realmente confirmadas e não realmente não confirmadas. Mais de 65 mudanças e mais 5 meses depois, e reescrevemos completamente a maioria dos detalhes internos de como o FragmentManager gerencia o estado, as transições adiadas e as animações.

 

O que esperar em fragmentos

Com a dívida técnica quitada (e um FragmentManager muito mais confiável e compreensível), a ponta do iceberg APIs de saveBackStack() e restoreBackStack() foram adicionados.

Se você não usar essas novas APIs, nada muda: a única pilha de retorno do FragmentManager funciona como antes. A API addToBackStack() existente permanece inalterada – você pode usar um nome nulo ou qualquer nome que desejar. No entanto, esse nome assume uma nova importância quando você começa a olhar para várias pilhas anteriores: é esse nome que é a chave única para aquela transação de fragmento que você usaria com saveBackStack() e posteriormente com restoreBackStack().

Isso pode ser mais fácil de ver em um exemplo. Digamos que você tenha adicionado um fragmento inicial à sua atividade e, em seguida, feito duas transações, cada uma com uma única operação de replace:

// Este é o fragmento inicial que o usuário vê
fragmentManager.commit {
  setReorderingAllowed(true)
  replace<HomeFragment>(R.id.fragment_container)
}

// Posteriormente, em resposta às ações do usuário, adicionamos mais dois
// transações para a pilha posterior
fragmentManager.commit {
  setReorderingAllowed(true)
  replace<ProfileFragment>(R.id.fragment_container)
  addToBackStack(“profile”)
}

fragmentManager.commit {
  setReorderingAllowed(true)
  replace<EditProfileFragment>(R.id.fragment_container)
  addToBackStack(“edit_profile”)
}

Isso significa que nosso FragmentManager se parece com:

Digamos que queremos trocar nossa pilha de retorno de perfil e trocar para o fragmento de notificações. Chamaríamos saveBackStack() seguido por uma nova transação:

fragmentManager.saveBackStack("profile")

fragmentManager.commit {
  setReorderingAllowed(true)
  replace<NotificationsFragment>(R.id.fragment_container)
  addToBackStack("notifications")
}

Agora nossa transação que adicionou o ProfileFragment e a transação que adicionou o EditProfileFragment foi salva no “profile” chave. Esses fragmentos tiveram seu estado salvo completamente e FragmentManager está mantendo seu estado junto com o estado da transação. Importante: essas instâncias de fragmento não existem mais na memória ou no FragmentManager – é apenas o estado (e qualquer estado não configurado na forma de instâncias de ViewModel):

A troca de volta é bastante simples: podemos fazer a mesma operação saveBackStack() em nossa transação de “notifications” e, em seguida, restoreBackStack():

fragmentManager.saveBackStack(“notifications”)

fragmentManager.restoreBackStack(“profile”)

As duas pilhas trocaram de posição efetivamente:

Este estilo de manter uma única pilha de retorno ativa e trocar transações nela garante que o FragmentManager e o resto do sistema sempre tenham uma visão consistente do que realmente deve acontecer quando o botão de retorno do sistema é pressionado. Na verdade, essa lógica permaneceu inteiramente inalterada: ela ainda apenas retira a última transação da pilha de volta do fragmento como antes.

Essas APIs são propositalmente mínimas, apesar de seus efeitos subjacentes. Isso torna possível construir sua própria estrutura em cima desses blocos de construção, evitando quaisquer hacks para salvar o estado de exibição Fragment, estado de instância salva e estado de não configuração.

Claro, se você não quiser construir sua própria estrutura em cima dessas APIs, você também pode usar a que fornecemos.

 

Trazendo várias pilhas de volta para qualquer tipo de tela com Navegação

O Navigation Component foi construído desde o início como um runtime genérico que não sabe nada sobre Views, Fragments, Composables, ou qualquer outro tipo de tela ou “destino” que você possa implementar em sua atividade. Em vez disso, é responsabilidade de uma implementação da interface NavHost adicionar uma ou mais instâncias do Navigator que saibam como interagir com um determinado tipo de destino.

Isso significa que a lógica para interagir com os fragmentos foi totalmente encapsulada no artefato navigation-fragment e seus FragmentNavigator e DialogFragmentNavigator. Da mesma forma, a lógica para interagir com Composables está no artefato navigation-compose completamente independente e seu ComposeNavigator. Essa abstração significa que se você deseja construir seu aplicativo exclusivamente com Composables, você não é forçado a extrair qualquer dependência de fragmentos ao usar o Navigation Compose.

Este nível de separação significa que existem realmente duas camadas para várias pilhas posteriores na navegação:

  • Salvar o estado das instâncias individuais de NavBackStackEntry que compõem a pilha de retorno do NavController. Isso é responsabilidade do NavController.
  • Salvar qualquer estado específico do Navigator associado a cada NavBackStackEntry (por exemplo, o fragmento associado a um destino FragmentNavigator). Isso é responsabilidade do Navigator.

Atenção especial foi dada aos casos em que o Navigator não foi atualizado para suportar o salvamento de seu estado. Enquanto a API Navigator subjacente foi totalmente reescrita para suportar o estado de salvamento (com novas sobrecargas de suas APIs navigate() e popBackStack() que você deve substituir em vez das versões anteriores), NavController salvará o estado NavBackStackEntry mesmo se o Navigator não tiver sido atualizado (compatibilidade com versões anteriores é um grande negócio no mundo Jetpack!).

PS: esta nova API do Navigator também torna mais fácil testar seu próprio Navigator de forma isolada, anexando um TestNavigatorState que atua como um mini-NavController.

Se você estiver usando apenas o Navigation em seu aplicativo, o nível do Navigator é mais um detalhe de implementação do que algo com o qual você precisará interagir diretamente. Basta dizer que já fizemos o trabalho necessário para obter o FragmentNavigator e o ComposeNavigator para as novas APIs do Navigator para que eles salvem e restaurem corretamente seu estado; não há trabalho que você precise fazer nesse nível.

 

Habilitando várias pilhas anteriores na navegação

Se você estiver usando NavigationUI, nosso conjunto de auxiliares opinativos para conectar seu NavController aos componentes de visualização de Material, você descobrirá que várias pilhas traseiras estão ativadas por padrão para itens de menu, BottomNavigationView (e agora NavigationRailView!) E NavigationView. Isso significa que a combinação comum de usar o fragmento de navegação e interface de usuário de navegação simplesmente funcionará.

As APIs NavigationUI são propositadamente construídas em cima das outras APIs públicas disponíveis no Navigation, garantindo que você possa construir suas próprias versões para precisamente o seu conjunto de componentes personalizados que você deseja. As APIs para permitir salvar e restaurar uma pilha de retorno não são exceção a isso, com novas APIs em NavOptions, navOptions Kotlin DSL, no XML de navegação e em uma sobrecarga para popBackStack() que permite especificar que deseja uma operação pop para salvar o estado ou você deseja uma operação de navegação para restaurar algum estado salvo anteriormente.

Por exemplo, no Compose, qualquer padrão de navegação global (seja uma barra de navegação inferior, barra de navegação, gaveta ou qualquer coisa que você possa imaginar) pode usar a mesma técnica que mostramos para integração com BottomNavigation e chamar navegate() com os atributos saveState e restoreState:

onClick = {
  navController.navigate(screen.route) {
    // Abra o destino inicial do gráfico para
    // evite acumular uma grande pilha de destinos
    // na pilha posterior conforme os usuários selecionam os itens    
    popUpTo(navController.graph.findStartDestination().id) {
      saveState = true
    }

    // Evite várias cópias do mesmo destino quando
    // selecionando novamente o mesmo item
    launchSingleTop = true
    // Restaura o estado ao selecionar novamente um item previamente selecionado
    restoreState = true
  }
}

 

Salve seu estado, salve seus usuários

Uma das coisas mais frustrantes para um usuário é perder seu estado. Essa é uma das razões pelas quais os fragmentos têm uma página inteira sobre o estado de salvamento e uma das muitas razões pelas quais estou tão feliz em ter cada camada atualizada para suportar várias pilhas antigas:

  • Fragmentos (ou seja, sem usar o componente de navegação): esta é uma alteração opcional usando as novas APIs FragmentManager de saveBackStack e restoreBackStack.
  • O Navigation Runtime principal: adiciona novos métodos NavOptions opt-in para restoreState e saveState e uma nova sobrecarga de popBackStack() que também aceita um booleano saveState (o padrão é false).
  • Navegação com fragmentos: o FragmentNavigator agora utiliza as novas APIs do Navigator para traduzir adequadamente as APIs do Navigation Runtime nas APIs do Fragment usando as APIs do Navigation Runtime.
  • NavigationUI: O onNavDestinationSelected(), NavigationBarView.setupWithNavController() e NavigationView.setupWithNavController() agora usam as novas NavOptions restoreState e saveState por padrão sempre que iriam abrir a pilha de retorno. Isso significa que todos os aplicativos que usam essas APIs NavigationUI obterão várias pilhas de retorno sem nenhuma alteração de código de sua parte após a atualização do Navigation 2.4.0-alpha01 ou superior.

Se você quiser ver mais alguns exemplos que usam essa API, dê uma olhada em NavigationAdvancedSample (recentemente atualizado sem nenhum código de NavigationExtensions que costumava exigir para suportar várias pilhas antigas):

E para Navigation Compose, considere olhar para Tivi:

Se você encontrar algum problema, certifique-se de usar o rastreador de problemas oficial para registrar bugs em Fragments ou Navigation e nós iremos dar uma olhada neles!