Navegação para IUs responsivas

A navegação é o processo de interagir com a IU de um aplicativo para acessar os destinos do conteúdo do app. Os princípios de navegação do Android oferecem diretrizes que ajudam a criar uma navegação consistente e intuitiva para o app.

As interfaces responsivas fornecem destinos de conteúdo responsivo e geralmente incluem diferentes tipos de elementos de navegação em resposta a mudanças no tamanho da tela. Por exemplo, uma barra de navegação na parte de baixo de telas pequenas, uma coluna de navegação em telas de tamanho médio ou uma gaveta de navegação (links em inglês) persistente em telas grandes. No entanto, as interfaces responsivas ainda precisam estar em conformidade com os princípios de navegação.

O componente de navegação do Jetpack implementa os princípios de navegação e pode ser usado para facilitar o desenvolvimento de apps com IUs responsivas.

Figura 1. Telas expandidas, médias e compactas com gaveta, coluna e barra inferior de navegação.

Navegação de IU responsiva

O tamanho da janela de exibição ocupada por um app afeta a ergonomia e a usabilidade. As classes de tamanho de janela permitem determinar elementos de navegação adequados (como barras, colunas ou gavetas de navegação) e os colocar onde ficam mais acessíveis ao usuário. Nas diretrizes de layout (link em inglês) do Material Design, os elementos de navegação ocupam um espaço persistente na borda de cima e podem se mover para a borda de baixo quando a largura do app é compacta. A escolha dos elementos de navegação depende muito do tamanho da janela do app e do número de itens que o elemento precisa conter.

Classe de tamanho da janela Poucos itens Muitos itens
largura compacta barra de navegação de baixo gaveta de navegação (borda de cima ou de baixo)
largura média coluna de navegação gaveta de navegação (borda superior)
largura expandida coluna de navegação gaveta de navegação persistente (borda de cima)

Em layouts baseados em visualização, os arquivos de recursos de layout podem ser qualificados por pontos de interrupção de classes de tamanho da janela para usar elementos de navegação diferentes em diferentes dimensões de exibição. O Jetpack Compose pode usar pontos de interrupção fornecidos pela API de classe de tamanho da janela para determinar programaticamente o elemento de navegação mais adequado para a janela do app.

Visualizações

<!-- res/layout/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w600dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigationrail.NavigationRailView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w1240dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigation.NavigationView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

Compose

// This method should be run inside a Composable function.
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
// You can get the height of the current window by invoking heightSizeClass instead.

@Composable
fun MyApp(widthSizeClass: WindowWidthSizeClass) {
    // Select a navigation element based on window size.
    when (widthSizeClass) {
        WindowWidthSizeClass.Compact -> { CompactScreen() }
        WindowWidthSizeClass.Medium -> { MediumScreen() }
        WindowWidthSizeClass.Expanded -> { ExpandedScreen() }
    }
}

@Composable
fun CompactScreen() {
    Scaffold(bottomBar = {
                NavigationBar {
                    icons.forEach { item ->
                        NavigationBarItem(
                            selected = isSelected,
                            onClick = { ... },
                            icon = { ... })
                    }
                }
            }
        ) {
        // Other content
    }
}

@Composable
fun MediumScreen() {
    Row(modifier = Modifier.fillMaxSize()) {
        NavigationRail {
            icons.forEach { item ->
                NavigationRailItem(
                    selected = isSelected,
                    onClick = { ... },
                    icon = { ... })
            }
        }
        // Other content
    }
}

@Composable
fun ExpandedScreen() {
    PermanentNavigationDrawer(
        drawerContent = {
            icons.forEach { item ->
                NavigationDrawerItem(
                    icon = { ... },
                    label = { ... },
                    selected = isSelected,
                    onClick = { ... }
                )
            }
        },
        content = {
            // Other content
        }
    )
}

Destinos de conteúdo responsivo

Em uma IU responsiva, o layout de cada destino de conteúdo precisa se adaptar às mudanças no tamanho da janela. O app pode ajustar o espaçamento do layout, reposicionar elementos, adicionar ou remover conteúdo ou mudar elementos da IU, incluindo elementos de navegação. Consulte Migrar a IU para layouts responsivos e Criar layouts adaptáveis.

Quando cada destino individual gerencia os eventos de redimensionamento, as mudanças são isoladas na IU. O restante do estado do app, incluindo a navegação, não é afetado.

A navegação não pode ocorrer como um efeito colateral das mudanças de tamanho da janela. Não crie destinos de conteúdo apenas para acomodar tamanhos de janela diferentes. Por exemplo, não crie destinos de conteúdo diferentes para as telas de um dispositivo dobrável.

A navegação como efeito colateral das mudanças de tamanho da janela apresenta os problemas abaixo:

  • O destino antigo (para o tamanho anterior da janela) pode ficar temporariamente visível antes de navegar ao novo destino.
  • Para manter a reversibilidade, por exemplo, quando um dispositivo está dobrado e desdobrado, a navegação é necessária para cada tamanho de janela.
  • A manutenção do estado do aplicativo entre destinos pode ser difícil, já que a navegação pode destruir o estado ao destacar a backstack.

Além disso, talvez o app não esteja em primeiro plano enquanto as mudanças no tamanho da janela estiverem acontecendo. O layout do app pode exigir mais espaço do que o app em primeiro plano. Quando o usuário volta para o app, a orientação e o tamanho da janela podem ter mudado.

Caso o app exija destinos de conteúdo exclusivos com base no tamanho da janela, considere combinar os destinos relevantes em um único destino que inclua layouts alternativos.

Destinos de conteúdo com layouts alternativos

Como parte de um design responsivo, um único destino de navegação pode ter layouts alternativos, dependendo do tamanho da janela do app. Cada layout ocupa a janela inteira, mas layouts diferentes são apresentados para diferentes tamanhos de janela.

Um exemplo canônico é a visualização de detalhes de lista (link em inglês). Em janelas pequenas, o app mostra um layout de conteúdo para a lista e outro para os detalhes. Navegar até o destino da visualização de detalhes e listas mostra inicialmente apenas o layout. Quando um item da lista é selecionado, o app mostra o layout detalhado, substituindo a lista. Quando o controle "Voltar" é selecionado, o layout da lista aparece, substituindo o detalhe. No entanto, para tamanhos de janela expandidos, os layouts de lista e detalhes são exibidos lado a lado.

Visualizações

A classe SlidingPaneLayout permite criar um único destino de navegação com dois painéis de conteúdo lado a lado em telas grandes, mas apenas um painel por vez em dispositivos de tela pequena, como smartphones.

<!-- Single destination for list and detail. -->

<navigation ...>

    <!-- Fragment that implements SlidingPaneLayout. -->
    <fragment
        android:id="@+id/article_two_pane"
        android:name="com.example.app.ListDetailTwoPaneFragment" />

    <!-- Other destinations... -->
</navigation>

Consulte Criar um layout de dois painéis para detalhes sobre a implementação de um layout de detalhes e listas usando SlidingPaneLayout.

Compose

No Compose, é possível implementar uma visualização de detalhes e listas unindo elementos combináveis alternativos em uma única rota que usa classes de tamanho de janela para emitir o elemento correto para cada classe de tamanho.

Uma rota é o caminho de navegação para um destino de conteúdo, que normalmente é um único elemento combinável, mas que também pode ser alternativo. A lógica de negócios determina quais elementos alternativos que podem ser compostos vão aparecer. A função de composição preenche a janela do app, independente de qual elemento alternativo for mostrado.

A visualização em detalhes e listas consiste em três elementos, por exemplo:

/* Displays a list of items. */
@Composable
fun ListOfItems(
    onItemSelected: (String) -> Unit,
) { /*...*/ }

/* Displays the detail for an item. */
@Composable
fun ItemDetail(
    selectedItemId: String? = null,
) { /*...*/ }

/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(
    selectedItemId: String? = null,
    onItemSelected: (String) -> Unit,
) {
  Row {
    ListOfItems(onItemSelected = onItemSelected)
    ItemDetail(selectedItemId = selectedItemId)
  }
}

Uma única rota de navegação que fornece acesso à visualização de detalhes e listas:

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      /*...*/
    )
  } else {
    // If the display size cannot accommodate both the list and the item detail,
    // show one of them based on the user's focus.
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(/*...*/)
    }
  }
}

O ListDetailRoute (destino de navegação) determina quais dos três elementos combináveis são emitidos: ListAndDetail para o tamanho da janela expandida. ListOfItems ou ItemDetail para janelas compactas, dependendo se um item da lista foi selecionado ou não.

A rota é incluída em um NavHost, por exemplo:

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  /*...*/
}

Você pode fornecer o argumento isExpandedWindowSize examinando a classe WindowMetrics do app.

O argumento selectedItemId pode ser fornecido por um ViewModel que mantém o estado em todos os tamanhos de janela. Quando o usuário seleciona um item da lista, a variável de estado selectedItemId é atualizada:

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

A rota também inclui um BackHandler personalizado quando o detalhe do item que pode ser composto ocupa toda a janela do app:

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }

  fun onItemBackPress() {
    viewModelState.update {
      it.copy(selectedItemId = null)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
    onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
      BackHandler {
        onItemBackPress()
      }
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

A combinação do estado do app de um ViewModel com informações da classe de tamanho da janela torna a escolha do elemento adequado uma questão de lógica simples. Ao manter um fluxo de dados unidirecional, o app consegue usar todo o espaço de exibição disponível, preservando o estado do aplicativo.

Para conferir uma implementação completa da visualização de detalhes e listas no Compose, consulte o exemplo JetNews (link em inglês) no GitHub.

Um gráfico de navegação

Para fornecer uma experiência do usuário consistente em qualquer tamanho de dispositivo ou janela, use um único gráfico de navegação em que o layout de cada destino de conteúdo seja responsivo.

Se você usar um gráfico de navegação diferente para cada classe de tamanho de janela, sempre que o app fizer a transição de uma classe de tamanho para outra, vai ser necessário determinar o destino atual do usuário nos outros gráficos, construir uma backstack e reconciliar as informações de estado diferentes entre os gráficos.

Host de navegação aninhado

O app pode incluir um destino de conteúdo que tenha destinos próprios. Por exemplo, em uma visualização de detalhes e listas, o painel de detalhes do item pode incluir elementos da interface que vão para o conteúdo que substitui o detalhe do item.

Para implementar esse tipo de subnavegação, o painel de detalhes pode ser um host de navegação aninhado com um gráfico de navegação próprio que especifica os destinos acessados no painel de detalhes:

Visualizações

<!-- layout/two_pane_fragment.xml -->

<androidx.slidingpanelayout.widget.SlidingPaneLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/sliding_pane_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_pane"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_gravity="start"/>

    <!-- Detail pane is a nested navigation host. Its graph is not connected
         to the main graph that contains the two_pane_fragment destination. -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/detail_pane"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/detail_pane_nav_graph" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

Compose

@Composable
fun ItemDetail(selectedItemId: String? = null) {
    val navController = rememberNavController()
    NavHost(navController, "itemSubdetail1") {
        composable("itemSubdetail1") { ItemSubdetail1(...) }
        composable("itemSubdetail2") { ItemSubdetail2(...) }
        composable("itemSubdetail3") { ItemSubdetail3(...) }
    }
}

Isso é diferente de um gráfico de navegação aninhado, porque o gráfico de navegação do NavHost aninhado não está conectado ao principal, ou seja, não é possível navegar diretamente dos destinos em um gráfico para destinos no outro.

Para ver mais informações, consulte Gráficos de navegação aninhados e Como navegar com o Compose.

Estado preservado

Para fornecer destinos de conteúdo responsivo, o app precisa preservar o estado quando o dispositivo é dobrado, girado ou quando a janela do app é redimensionada. Por padrão, mudanças de configuração como essas recriam as atividades, os fragmentos, a hierarquia de visualização e os elementos combináveis do app. A forma recomendada de salvar o estado da IU é com um ViewModel ou rememberSaveable, que sobrevivem às mudanças na configuração. Consulte Salvar estados da IU e Estado e Jetpack Compose.

As mudanças de tamanho precisam ser reversíveis, por exemplo, quando o usuário gira o dispositivo e depois volta à posição original.

Os layouts responsivos podem mostrar diferentes conteúdos em diferentes tamanhos de janela. Assim, os layouts responsivos geralmente precisam salvar mais estados relacionados ao conteúdo, mesmo que ele não se aplique ao tamanho atual da janela. Por exemplo, suponha que um layout possa ter espaço para mostrar um widget de rolagem extra apenas em janela de larguras maiores. Se um redimensionamento deixar a largura da janela muito pequena, o widget vai ser ocultado. Quando o app for redimensionado para as dimensões anteriores, o widget de rolagem vai ficar visível novamente e a posição de rolagem original vai precisar ser restaurada.

Escopos do ViewModel

O guia para desenvolvedores sobre como Migrar para o componente de navegação recomenda uma arquitetura de atividade única em que os destinos são implementados como fragmentos e os modelos de dados deles são implementados usando ViewModel.

Um ViewModel tem o escopo definido para um ciclo de vida e, quando esse ciclo termina permanentemente, o ViewModel é liberado e pode ser descartado. O ciclo de vida que tem o escopo ViewModel e, portanto, a amplitude de compartilhamento do ViewModel, depende da delegação de propriedade que é usada para acessar o ViewModel.

No caso mais simples, cada destino de navegação é um único fragmento com um estado de IU completamente isolado. Assim, cada fragmento pode usar o delegado de propriedade viewModels() para ter um ViewModel com escopo para o fragmento.

Para compartilhar o estado da IU entre fragmentos, defina o escopo do ViewModel para a atividade chamando activityViewModels() nos fragmentos. O equivalente à atividade é apenas viewModels(). Isso permite que a atividade e os fragmentos anexados a ela compartilhem a instância ViewModel. No entanto, em uma arquitetura de atividade única, esse escopo ViewModel tem a duração do app, de modo que o ViewModel permaneça na memória mesmo que não seja usado por nenhum fragmento.

Suponha que o gráfico de navegação tenha uma sequência de destinos de fragmento que representa um fluxo de finalização da compra e que o estado atual de toda a experiência de finalização de compra esteja em um ViewModel compartilhado entre os fragmentos. O escopo do ViewModel para a atividade é amplo demais e também expõe outro problema: se o usuário passa pelo fluxo de finalização de compra de um pedido e faz isso novamente para um segundo pedido, ambos os pedidos usam a mesma instância do ViewModel da finalização da compra. Antes da segunda finalização de pedido, é necessário limpar os dados do primeiro pedido manualmente, e os erros podem custar caro para o usuário.

Em vez disso, defina o escopo do ViewModel para um gráfico de navegação no NavController atual. Crie um gráfico de navegação aninhado para encapsular os destinos que fazem parte do fluxo de finalização de compra. Em seguida, em cada um desses destinos do fragmento, use o delegado de propriedade navGraphViewModels() e transmita o ID do gráfico de navegação para extrair o ViewModel compartilhado. Isso garante que, quando o usuário sair do fluxo de finalização da compra e o gráfico de navegação aninhado estiver fora do escopo, a instância correspondente do ViewModel seja descartada e não seja usada na próxima finalização.

Escopo Delegado de propriedade Pode compartilhar o ViewModel com
Fragmento Fragment.viewModels() Apenas o fragmento atual
Atividade Activity.viewModels()

Fragment.activityViewModels()

A atividade e todos os fragmentos anexados a ela
Gráfico de navegação Fragment.navGraphViewModels() Todos os fragmentos no mesmo gráfico de navegação

Se você estiver usando um host de navegação aninhada (como mostrado acima), os destinos nesse host não podem compartilhar ViewModels com destinos fora do host ao usar navGraphViewModels(), porque os gráficos não estão conectados. Nesse caso, é possível usar o escopo da atividade.

Estado elevado

No Compose, você pode preservar o estado durante mudanças de tamanho da janela com a elevação de estado. Ao elevar o estado dos elementos combináveis até uma posição mais alta na árvore combináveis, ele pode ser preservado mesmo que os elementos não estejam mais visíveis.

Na seção Compose de Destinos de conteúdo com layouts alternativos acima, elevamos o estado dos elementos combináveis da visualização em detalhes e listas para a ListDetailRoute. Assim, podemos manter o estado, independente de qual elemento for mostrado:

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) { /*...*/ }

Outros recursos