Navegação para IUs responsivas

Mantenha tudo organizado com as coleções Salve e categorize o conteúdo com base nas suas preferências.

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 IUs 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 em telas pequenas, uma coluna de navegação em telas de tamanho médio ou uma gaveta de navegação persistente em telas grandes (links em inglês). Porém, as IUs 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.

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 gaveta de navegação persistente (borda de cima) 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-w840dp/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 = {
                BottomNavigation {
                    icons.forEach { item ->
                        BottomNavigationItem(
                            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 da 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 da lista exibe inicialmente apenas o layout da lista. 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 ver detalhes sobre a implementação de um layout de detalhes da lista usando SlidingPaneLayout.

Compose

No Compose, é possível implementar uma visualização de detalhes de lista combinando elementos alternativos que podem ser compostos 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 que pode ser composto, 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 que pode ser composta preenche a janela do app, independente de qual elemento alternativo for mostrado.

A visualização em detalhes de lista 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 da lista:

@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 que podem ser compostos é emitido: 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 ver uma implementação completa da visualização em lista do 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 lista, o painel de detalhes do item pode incluir elementos de IU 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

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  navigation(startDestination = "itemSubdetail1", route = "itemSubDetail") {
    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 que podem ser compostos 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 (veja 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 que podem ser compostos até uma posição mais alta na árvore de composição, 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 que podem ser compostos da visualização em detalhes da lista para a ListDetailRoute a fim de manter o estado, independente de qual elemento for exibido:

@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?,
) { /*...*/ }