Implementar a navegação para IUs adaptáveis

A navegação se refere às interações que permitem aos usuários navegar, entrar e sair de diferentes partes do conteúdo no app. IUs adaptáveis no Compose não mudam os fundamentos da navegação, ainda é necessário aderir a todos os princípios de navegação. O componente de navegação facilita a aplicação dos padrões recomendados e você pode continuar a usá-lo em apps com layouts altamente adaptáveis.

Além dos princípios acima, há algumas outras considerações para melhorar a experiência do usuário em apps com layouts adaptáveis. Conforme abordado no guia sobre como criar layouts adaptáveis, a estrutura da IU pode depender do espaço disponível para o app. Todos esses princípios de navegação extra consideram o que acontece quando o espaço da tela disponível ao app muda.

IU de navegação responsiva

Para fornecer a melhor experiência de navegação aos usuários, você precisa oferecer uma IU de navegação personalizada para o espaço disponível ao app. Você pode usar uma barra de apps inferior, uma gaveta de navegação permanente ou que pode ser recolhida, uma coluna ou talvez algo completamente novo com base no espaço de tela disponível e no estilo exclusivo do app (links em inglês).

Como esses componentes ocupam toda a largura ou altura da tela, a lógica para decidir qual deles usar é uma decisão de acordo com o layout da tela. Por esse motivo, recomendamos o uso de classes de tamanho da janela para determinar o tipo de IU de navegação a ser exibido. As classes de tamanho da janela são pontos de interrupção projetados para equilibrar simplicidade e a flexibilidade de otimizar o app para a maioria dos casos exclusivos.

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the nav rail
    val showNavRail = windowSizeClass != WindowSizeClass.Compact
    MyScreen(
        showNavRail = showNavRail,
        /* ... */
    )
}

Destinos totalmente responsivos

O modo de várias janelas, dobráveis e janelas de formato livre no Chrome OS pode fazer com que o espaço disponível para o app mude mais do que nunca.

Para oferecer uma experiência perfeita ao usuário, dentro do host de navegação, use um único gráfico de navegação em que cada destino é responsivo. Essa abordagem reforça os principais princípios da IU responsiva: flexibilidade e continuidade. Se cada destino individual processar corretamente eventos de redimensionamento, as mudanças serão isoladas apenas na IU, e o restante do estado do app (incluindo a navegação) será preservado, o que ajudará na continuidade.

Com gráficos de navegação paralelos, sempre que o app fizer a transição para outra classe de tamanho, você precisará determinar o destino atual do usuário no outro gráfico, reconstruir uma backstack e reconciliar outras informações de estado que diferem entre os gráficos. Essa abordagem é complicada e propensa a erros.

Em um destino específico, há muitas opções para tornar um layout responsivo. Você pode ajustar o espaçamento, usar layouts alternativos, adicionar outras colunas de informações para usar mais espaço ou mostrar detalhes extras que não caberiam em menos espaço. Saiba mais sobre as ferramentas disponíveis para implementar essas mudanças em Criar layouts adaptáveis.

Para uma experiência ainda melhor do usuário, é possível adicionar mais conteúdo a um destino específico usando um layout canônico de tela grande (link em inglês), como uma visualização em lista/detalhes. As considerações de navegação para esse design são exploradas abaixo.

Diferenciar entre rotas e telas

O componente de navegação permite definir rotas, cada uma delas correspondente a algum destino. Navegar pelos resultados muda o destino a ser mostrado, e também é necessário monitorar uma backstack, que é a lista de destinos em que o usuário estava anteriormente.

Você pode exibir qualquer conteúdo que quiser em um destino específico. Para um NavHost que processa a navegação principal do app, geralmente há telas diferentes em cada destino, que ocupam todo o espaço disponível ao app.

Geralmente, cada destino é responsável por mostrar uma única tela e cada tela é mostrada em apenas um destino. No entanto, esse não é um requisito obrigatório. Na verdade, pode ser extremamente útil ter um destino que permite escolher entre várias telas para exibição, dependendo do tamanho disponível ao app.

Veja o JetNews, um dos exemplos oficiais do Compose. A principal função do app é mostrar artigos que o usuário pode selecionar em uma lista. Quando o app tiver espaço suficiente, ele poderá exibir a lista e um artigo ao mesmo tempo. Essa interface é um layout de lista/detalhes, que é um dos layouts canônicos do Material Design (link em inglês).

Telas de "lista", "detalhes" e "lista + detalhes" no JetNews

Mesmo que sejam três telas visualmente distintas, o app exibirá todas as três abaixo da mesma rota "home".

No código, o destino chama a HomeRoute:

@Composable
fun JetnewsNavGraph(
    navController: NavHostController,
    isExpandedScreen: Boolean,
    // ...
) {
    // ...
    NavHost(
        navController = navController,
        startDestination = JetnewsDestinations.HomeRoute
    ) {
        composable(JetnewsDestinations.HomeRoute) {
            // ...
            HomeRoute(
                isExpandedScreen = isExpandedScreen,
                // ...
            )
        }
        // ...
    }
}

Em seguida, o código da HomeRoute decide qual das três telas será mostrada, e cada função que pode ser composta recebe o sufixo Screen. O app toma essa decisão com base em uma combinação do estado armazenado no HomeViewModel, além da classe de tamanho da janela que descreve o espaço atual disponível.

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(/* ... */)
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

Nessa abordagem, o app separa claramente as operações de navegação que substituem toda a HomeRoute por outro destino ao chamar o método navigate() no NavController) das operações de navegação que afetam apenas o conteúdo desse destino, como ao selecionar um artigo da lista. Recomendamos processar esses eventos atualizando um estado compartilhado que se aplica a todos os tamanhos de janela, mesmo que uma transição entre a tela de lista e a do artigo pareça uma operação de navegação do ponto de vista do usuário se o app estiver mostrando apenas um painel.

Portanto, quando tocamos em um artigo na lista, atualizamos uma sinalização booleana isArticleOpen:

class HomeViewModel(/* ... */) {
    fun selectArticle(articleId: String) {
        viewModelState.update {
            it.copy(
                isArticleOpen = true,
                selectedArticleId = articleId
            )
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            selectedArticleId = selectedArticleId,
            onSelectArticle = onSelectArticle,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                // ...
            )
        } else {
            HomeListScreen(
                onSelectArticle = onSelectArticle,
                // ...
            )
        }
    }
}

Da mesma forma, instalaremos um BackHandler personalizado quando apenas a tela do artigo estiver em exibição, o que define a sinalização isArticleOpen como falsa novamente.

class HomeViewModel(/* ... */) {
    fun onArticleBackPress() {
        viewModelState.update {
            it.copy(isArticleOpen = false)
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    onArticleBackPress: () -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                onUpPressed = onArticleBackPress,
                // ...
            )
            BackHandler {
                onArticleBackPress()
            }
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

Essa criação de camadas reúne vários conceitos importantes ao criar um app do Compose. Ao tornar as telas reutilizáveis e permitir que o estado importante delas seja elevado, é possível trocar telas inteiras facilmente. Ao combinar o estado do app de um ViewModel com as informações de tamanho disponíveis, uma lógica simples é usada para decidir qual tela mostrar. Por fim, com a preservação de um fluxo de dados unidirecional, a IU adaptável sempre usará o espaço disponível e preservará o estado do usuário.

Para ver a implementação completa, confira o exemplo do JetNews no GitHub.

Preservar o estado do usuário

O mais importante para IUs adaptáveis é preservar o estado do usuário quando o dispositivo for girado ou dobrado ou quando a janela do app for redimensionada. Especificamente, todos esses redimensionamentos precisam ser reversíveis.

Por exemplo, vamos supor que o usuário esteja visualizando alguma tela no app e gire o dispositivo. Se a rotação for desfeita, ou seja, o dispositivo for girado para a posição inicial, o app precisa retornar à mesma tela em que estava antes de ser girado e todo o estado deve ser preservado. Caso o usuário tenha navegado por parte do conteúdo antes de girar o dispositivo, ele retornará à mesma posição de rolagem após girar o dispositivo para a posição inicial.

Salvar a posição de rolagem da lista após a rotação

Mudanças de orientação e redimensionamento de janelas causam mudanças de configuração, que, por padrão, recriam a Activity e os elementos que podem ser compostos. O estado pode ser salvo durante as mudanças de configuração usando rememberSaveable ou ViewModels. Consulte o guia Estado e Jetpack Compose para saber mais. Se você não usar ferramentas como essas, o estado do usuário será perdido.

Os layouts adaptáveis costumam ter um outro estado, já que podem exibir conteúdos distintos em diferentes tamanhos de tela. Portanto, também é importante salvar o estado do usuário nessas outras partes, mesmo para componentes que não são mais visíveis.

Suponha que parte do conteúdo de rolagem só fique visível em larguras maiores. Se uma rotação fizer com que a largura se torne muito pequena para exibir o conteúdo de rolagem, o conteúdo será oculto. Quando o usuário girar o dispositivo para a posição inicial, o conteúdo de rolagem ficará visível novamente, e a posição de rolagem original será restaurada.

Salvar a posição de rolagem dos detalhes ao girar

No Compose, é possível fazer isso usando a elevação de estado. Ao elevar o estado dos elementos que podem ser compostos mais acima na árvore de composição, o estado deles pode ser preservado, mesmo que não esteja mais visível.

No JetNews, o estado é elevado para a HomeRoute de modo que ele seja preservado e reutilizado enquanto a tela visível muda:

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    selectedArticleId: String,
    // ...
) {
    val homeListState = rememberHomeListState()
    val articleState = rememberSaveable(
        selectedArticleId,
        saver = ArticleState.Saver
    ) {
        ArticleState()
    }

    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            homeListState = homeListState,
            articleState = articleState,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                articleState = articleState,
                // ...
            )
        } else {
            HomeListScreen(
                homeListState = homeListState,
                // ...
            )
        }
    }
}

Evitar a navegação como efeito colateral de uma mudança de tamanho

Se você adicionar uma tela que usa o espaço extra que as telas maiores podem fornecer, pode ser tentador adicionar um novo destino ao app para o layout recém-projetado.

No entanto, vamos supor que o usuário esteja visualizando esse novo layout na tela interna de um dispositivo dobrável. Se o usuário dobrar o dispositivo, pode não haver espaço suficiente para mostrar o novo layout na tela externa. Isso apresenta o requisito de navegar para outro lugar se o novo tamanho da tela for muito pequeno, o que causa alguns problemas:

  • Navegar como um efeito colateral da composição pode fazer com que o destino antigo fique visível por um momento, já que ele precisará ser exibido antes da navegação.
  • Para que a mudança possa ser reversível, também precisamos poder navegar de volta ao destino original desdobrado.
  • Será extremamente difícil manter o estado do usuário entre essas mudanças, já que a navegação pode limpar o estado antigo ao acessar a backstack.

Outro ponto a se considerar é que o app pode não estar em primeiro plano enquanto essas mudanças estiverem ocorrendo. O app pode estar mostrando um layout que exige mais espaço e, em seguida, o usuário coloca o app em segundo plano. Se eles voltarem para o app, a orientação, o tamanho e a tela física poderão ter mudado desde que ele foi retomado pela última vez.

Se você quiser mostrar apenas alguns destinos para determinados tamanhos de tela, combine os destinos relevantes em uma única rota e mostre telas diferentes nela, conforme explicado acima.