Apps adaptáveis

1. Antes de começar

Pré-requisitos

  • Experiência na criação de apps Android.
  • Experiência com o Jetpack Compose.

O que você precisa

O que você aprenderá

  • Noções básicas de layout adaptável e Navigation 3
  • Implementar o recurso de arrastar e soltar
  • Suporte a atalhos de teclado
  • Ativar menus de contexto

2. Começar a configuração

Para começar, siga estas etapas:

  1. Inicie o Android Studio.
  2. Clique em Arquivo > Novo > Project from Version control.
  3. Cole o URL:
https://github.com/android/socialite.git
  1. Clique em Clone.

Aguarde o projeto carregar por completo.

  1. Abra o Terminal e execute:
$ git checkout codelab-adaptive-apps-start
  1. Executar uma sincronização do Gradle

No Android Studio, selecione File > Sync Project with Gradle Files.

  1. (opcional) Baixar o emulador de computador grande

No Android Studio, selecione Tools > Device Manager > + > Create Virtual Device > New hardware profile

Selecione o tipo de dispositivo: Computador

Tamanho da tela: 14 polegadas

Resolução: 1920 x 1080 px

Clique em Finish

  1. Executar o app em um emulador de tablet ou computador

3. Entender o app de exemplo

Neste tutorial, você vai trabalhar com um app de chat de exemplo chamado Socialite, criado com o Jetpack Compose. e9e4541f0f76d669.png

Nesse app, você pode conversar com diferentes animais, que respondem às suas mensagens cada um à sua maneira.

No momento, ele é um app focado em dispositivos móveis que não é otimizado para dispositivos grandes, como tablets ou computadores.

Vamos adaptar o app para telas grandes e adicionar alguns recursos para melhorar a experiência em todos os formatos.

Vamos começar!

4. Noções básicas de layouts adaptáveis e Navigation 3

$ git checkout codelab-adaptive-apps-step-1

No momento, o app sempre mostra apenas um painel por vez, não importa quanto espaço na tela esteja disponível.

Vamos corrigir isso usando adaptive layouts, que mostram um ou vários painéis, dependendo do tamanho da janela atual. Neste codelab, vamos usar layouts adaptáveis para mostrar automaticamente as telas chat list e chat detail lado a lado, quando houver espaço suficiente na janela.

c549fd9fa64589e9.gif

Os layouts adaptativos foram criados para integração total em qualquer app.

Neste tutorial, vamos nos concentrar em como usá-los com a biblioteca Navigation 3, que é a base do app Socialite.

Para entender a Navigation 3, vamos começar com algumas terminologias:

  • NavEntry: algum conteúdo mostrado em um app para que o usuário pode navegar. Ele é identificado de forma exclusiva por uma chave. Uma NavEntry não precisa preencher toda a janela disponível para o app. Mais de uma NavEntry pode ser mostrada ao mesmo tempo (mais detalhes abaixo).
  • Chave: um identificador exclusivo de uma NavEntry. As chaves são armazenadas na backstack.
  • Backstack: uma pilha de chaves que representa elementos NavEntry que foram mostrados anteriormente ou estão sendo exibidos no momento. Para navegar, envie as chaves para dentro ou para fora da pilha.

No Socialite, a primeira tela que queremos mostrar quando o usuário inicia o app é a lista de chats. Para isso, criamos a backstack e a inicializamos com a chave que representa essa tela.

Main.kt

// Create a new back stack
val backStack = rememberNavBackStack(ChatsList)

...

// Navigate to a particular chat
backStack.add(ChatThread(chatId = chatId))

...

// Navigate back
backStack.removeLastOrNull()

Vamos implementar a Navigation 3 diretamente no elemento combinável do ponto de entrada Main.

Remova a marca de comentário da chamada de função MainNavigation para conectar a lógica de navegação.

Agora vamos começar a criar a infraestrutura de navegação.

Primeiro, crie a backstack. Ela é a base da Navigation 3.

Até agora, abordamos vários conceitos da Navigation 3. Mas como a biblioteca determina qual objeto representa a backstack e como transformar os elementos em uma UI real?

Com o NavDisplay. Ele é o componente que reúne tudo e renderiza a backstack. Ele usa alguns parâmetros importantes. Vamos conferir cada um deles.

Parâmetro 1: backstack

O NavDisplay precisa ter acesso à backstack para renderizar o conteúdo. Vamos transmitir esse parâmetro.

Parâmetro 2: EntryProvider

O EntryProvider é uma lambda que transforma as chaves da backstack em conteúdo de UI combinável. Ele recebe uma chave e retorna uma NavEntry, que contém o conteúdo a ser mostrado e metadados sobre como exibi-lo (mais detalhes abaixo).

O NavDisplay chama essa lambda sempre que precisa acessar o conteúdo de uma determinada chave, por exemplo, quando uma nova chave é adicionada à backstack.

No momento, se clicarmos no ícone Timeline no Socialite, vamos ver "Unknown back stack key: Timeline".

532134900a30c9c.gif

Isso acontece porque, embora a chave Timeline seja adicionada à backstack, o EntryProvider não sabe como renderizá-la. Por isso, ele volta à implementação padrão. A mesma coisa acontece quando clicamos no ícone Settings. Vamos corrigir isso, garantindo que EntryProvider processe as chaves da backstack de Timeline e Settings corretamente.

Parâmetro 3: SceneStrategy

O próximo parâmetro importante do NavDisplay é a SceneStrategy. Ela é usada quando queremos mostrar vários elementos NavEntry ao mesmo tempo. Cada estratégia define como vários elementos NavEntry são mostrados lado a lado ou em camadas.

Por exemplo, se usarmos DialogSceneStrategy e marcarmos alguns NavEntry com metadados especiais, ele vai aparecer como uma caixa de diálogo acima do conteúdo atual em vez de ocupar a tela inteira.

No nosso caso, vamos usar uma SceneStrategy diferente: ListDetailSceneStrategy. Ela foi projetada para o layout canônico de detalhes e listas.

Primeiro, vamos adicioná-la ao construtor NavDisplay.

sceneStrategy = rememberListDetailSceneStrategy(),

Agora precisamos marcar a ChatList da NavEntry como um painel de lista e a ChatThread NavEntry como um painel de detalhes. Assim, a estratégia poderá determinar quando os dois elementos NavEntry estiverem na backstack e precisarem aparecer lado a lado.

Na próxima etapa, marque a ChatsList da NavEntry como um painel de lista.

entryProvider = { backStackKey ->
   when (backStackKey) {
      is ChatsList -> NavEntry(
         key = backStackKey,
         metadata = ListDetailSceneStrategy.listPane(),
      ) {
         ...
      }
      ...
   }
}

Da mesma forma, marque a ChatThread da NavEntry como um painel de detalhes.

entryProvider = { backStackKey ->
   when (backStackKey) {
      is ChatThread -> NavEntry(
         key = backStackKey,
         metadata = ListDetailSceneStrategy.detailPane(),
      ) {
         ...
      }
      ...
   }
}

Com isso, integramos os layouts adaptáveis ao nosso app.

5. Arrastar e soltar

$ git checkout codelab-adaptive-apps-step-2

Nesta etapa, vamos adicionar a opção de arrastar e soltar, para que os usuários possam arrastar imagens do app Files para o Socialite.

78fe1bb6689c9b93.gif

Nosso objetivo é ativar o recurso de arrastar e soltar na área message list, que é definida pelo elemento combinável MessageList, localizado no arquivo ChatScreen.kt.

No Jetpack Compose, o recurso de arrastar e soltar é implementado pelo modificador dragAndDropTarget. Aplicamos isso a elementos combináveis que precisam aceitar itens soltos.

Modifier.dragAndDropTarget(
   shouldStartDragAndDrop = { event ->
       // condition to accept dragged item
   },
   target = // DragAndDropTarget
)

O modificador tem dois parâmetros.

  • O primeiro, shouldStartDragAndDrop, permite que o elemento combinável filtre eventos de arrastar e soltar. No nosso caso, queremos aceitar apenas imagens e ignorar todos os outros tipos de dados.
  • O segundo, target, é um callback que define a lógica para processar eventos de arrastar e soltar aceitos.

Primeiro, vamos adicionar o dragAndDropTarget ao elemento combinável MessageList.

.dragAndDropTarget(
   shouldStartDragAndDrop = { event ->
       event.mimeTypes().any { it.startsWith("image/") }
   },
   target = remember {
       object : DragAndDropTarget {
           override fun onDrop(event: DragAndDropEvent): Boolean {
               TODO("Not yet implemented")
           }
       }
   }
),

O objeto de callback target precisa implementar o método onDrop(), que recebe um DragAndDropEvent como argumento.

Esse método é invocado quando o usuário solta um item no elemento combinável. Ele retorna true se o item foi processado e false se ele foi rejeitado.

Cada DragAndDropEvent contém um objeto ClipData, que encapsula os dados que estão sendo arrastados.

Os dados em ClipData são uma matriz de objetos Item. Como vários itens podem ser arrastados de uma só vez, cada Item representa um deles.

target = remember {
   object : DragAndDropTarget {
       override fun onDrop(event: DragAndDropEvent): Boolean {
           val clipData = event.toAndroidDragEvent().clipData
           if (clipData != null && clipData.itemCount > 0) {
               repeat(clipData.itemCount) { i ->
                   val item = clipData.getItemAt(i)
                   // TODO: Implement Item handling
               }
               return true
           }
           return false
       }
   }
}

Um Item pode conter dados no formato de URI, texto ou Intent.

No nosso caso, como só aceitamos imagens, estamos procurando especificamente um URI.

Se um Item tiver um, precisamos:

  1. Solicitar permissão de arrastar e soltar para acessar o URI
  2. Processar o URI (no nosso caso, chamando a função onMediaItemAttached() já implementada)
  3. Liberar a permissão
override fun onDrop(event: DragAndDropEvent): Boolean {
   val clipData = event.toAndroidDragEvent().clipData
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
       && clipData != null && clipData.itemCount > 0) {
       repeat(clipData.itemCount) { i ->
           val item = clipData.getItemAt(i)
           val passedUri = item.uri?.toString()
           if (!passedUri.isNullOrEmpty()) {
               val dropPermission = activity
                   .requestDragAndDropPermissions(
                       event.toAndroidDragEvent()
                   )
               try {
                   val mimeType = context.contentResolver
                       .getType(passedUri.toUri()) ?: ""
                   onMediaItemAttached(MediaItem(passedUri, mimeType))
               } finally {
                   dropPermission.release()
               }
           }
       }
       return true
   }
   return false
}

Nesse ponto, o recurso de arrastar e soltar está totalmente implementado, e você pode arrastar fotos do app Files para o Socialite.

Vamos melhorar ainda mais a aparência adicionando uma borda visual para destacar que a área pode receber itens soltos.

Para fazer isso, podemos usar outros hooks que correspondem a diferentes estágios da sessão de arrastar e soltar:

  1. onStarted(): é chamado quando uma sessão de arrastar e soltar começa e o DragAndDropTarget está qualificado para receber itens. Esse é um bom lugar para preparar o estado da interface para a sessão.
  2. onEntered(): é acionado quando um item arrastado entra nos limites desse DragAndDropTarget.
  3. onMoved(): é chamado quando o item arrastado se move dentro dos limites desse DragAndDropTarget.
  4. onExited(): é chamado quando o item arrastado se move para fora dos limites do DragAndDropTarget.
  5. onChanged(): é invocado quando algo muda na sessão de arrastar e soltar dentro dos limites do destino, por exemplo, se uma tecla modificadora for pressionada ou liberada.
  6. onEnded(): é chamado quando a sessão de arrastar e soltar termina. Todos os DragAndDropTarget que receberam um evento onStarted antes vão receber esse também. Útil para redefinir o estado da UI.

Para adicionar a borda visual, precisamos fazer o seguinte:

  1. Criar uma variável booleana lembrada que é definida como true quando uma ação de arrastar e soltar começa e é redefinida para false quando ela termina.
  2. Aplicar um modificador ao elemento combinável MessageList que renderiza uma borda quando essa variável é true.
override fun onEntered(event: DragAndDropEvent) {
   super.onEntered(event)
   isDraggedOver = true
}

override fun onEnded(event: DragAndDropEvent) {
   super.onExited(event)
   isDraggedOver = false
}

6. Atalhos do teclado

$ git checkout codelab-adaptive-apps-step-3

Ao usar um app de chat no computador, os usuários esperam atalhos de teclado conhecidos, como enviar uma mensagem com a tecla Enter.

Nesta etapa, vamos adicionar esse comportamento ao app.

Os eventos de teclado no Compose são processados com modificadores.

Há dois principais:

  • onPreviewKeyEvent: intercepta o evento do teclado antes de ele ser processado pelo elemento em foco. Como parte da implementação, decidimos se o evento vai ser propagado ou consumido.
  • onKeyEvent: intercepta o evento do teclado depois de ele ser processado pelo elemento em foco. Ele só é acionado se os outros gerenciadores não consumirem o evento.

No nosso caso, o uso de onKeyEvent em um TextField não funciona, porque o gerenciador padrão consome o evento de tecla Enter e move o cursor para a nova linha.

.onPreviewKeyEvent { keyEvent ->
   //TODO: implement key event handling
},

A lambda dentro do modificador será chamada duas vezes para cada tecla pressionada: quando o usuário pressiona a tecla e quando ele a libera.

Podemos determinar esses eventos verificando a propriedade type do objeto KeyEvent. O objeto de evento também expõe flags de modificador, incluindo:

  • isAltPressed
  • isCtrlPressed
  • isMetaPressed
  • isShiftPressed

O retorno de true da lambda notifica o Compose que nosso código processou o evento de tecla e impede o comportamento padrão, como a inserção de uma nova linha.

Agora, implemente o modificador onPreviewKeyEvent. Confira se o evento corresponde ao pressionamento da tecla Enter e se nenhum dos modificadores shift, alt, ctrl ou meta foi aplicado. Em seguida, chame a função onSendClick().

.onPreviewKeyEvent { keyEvent ->
   if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown
       && keyEvent.isShiftPressed == false
       && keyEvent.isAltPressed == false
       && keyEvent.isCtrlPressed == false
       && keyEvent.isMetaPressed == false) {
       onSendClick()
       true
   } else {
       false
   }
},

7. Menus de contexto

$ git checkout codelab-adaptive-apps-step-4

Os menus de contexto são uma parte importante de uma UI adaptativa.

Nesta etapa, vamos adicionar um menu pop-up Reply que aparece quando o usuário clica com o botão direito do mouse em uma mensagem.

d9d30ae7e0230422.gif

Há muitos gestos diferentes compatíveis e já prontos para uso. Por exemplo, o modificador clickable permite a detecção fácil de um clique.

Para gestos personalizados, como cliques com o botão direito do mouse, podemos usar o modificador pointerInput, que dá acesso a eventos brutos do ponteiro e controle total sobre a detecção de gestos.

Primeiro, vamos adicionar a UI que vai responder a um clique com o botão direito do mouse. No nosso caso, queremos mostrar DropdownMenu com um único item: um botão Reply. Vamos precisar de duas variáveis remember:

  • rightClickOffset armazena a posição do clique para que seja possível mover o botão Reply para perto do cursor
  • isMenuVisible para controlar se o botão Reply vai aparecer ou ser ocultado

Os valores serão atualizados como parte do processamento de gestos de clique com o botão direito do mouse.

Também precisamos unir o elemento combinável de mensagem a uma Box para que o DropdownMenu possa aparecer em camadas acima dele.

@Composable
internal fun MessageBubble(
   ...
) {
   var rightClickOffset by remember { mutableStateOf<DpOffset>(DpOffset.Zero) }
   var isMenuVisible by remember { mutableStateOf(false) }
   val density = LocalDensity.current

   Box(
       modifier = Modifier
           .pointerInput(Unit) {
               // TODO: Implement right click handling
           }
           .then(modifier),
   ) {
       AnimatedVisibility(isMenuVisible) {
           DropdownMenu(
               expanded = true,
               onDismissRequest = { isMenuVisible = false },
               offset = rightClickOffset,
           ) {
               DropdownMenuItem(
                   text = { Text("Reply") },
                   onClick = {
                       // Custom Reply functionality
                   },
               )
           }
       }
       MessageBubbleSurface(
           ...
       ) {
           ...
       }
   }
}

Agora vamos implementar o modificador pointerInput. Primeiro, adicionamos um awaitEachGesture, que inicia um novo escopo sempre que o usuário faz um novo gesto. Nesse escopo, precisamos:

  1. Receber o próximo evento do ponteiro: o awaitPointerEvent() fornece um objeto que representa o evento do ponteiro.
  2. Filtrar para um clique puro com o botão direito do mouse: verificamos se apenas o botão secundário está pressionado.
  3. Capturar a posição do clique: use a posição em pixels e converta-a em um DpOffset para que a posição do menu seja independente do DPI.
  4. Mostrar o menu: defina isMenuVisible = true e armazene o offset para que o DropdownMenu apareça exatamente onde o ponteiro estava.
  5. Consumir o evento: chame consume() no evento de pressionamento e na liberação correspondente, impedindo que outros manipuladores reajam.
.pointerInput(Unit) {
   awaitEachGesture { // Start listening for pointer gestures
       val event = awaitPointerEvent()

       if (
           event.type == PointerEventType.Press
           && !event.buttons.isPrimaryPressed
           && event.buttons.isSecondaryPressed
           && !event.buttons.isTertiaryPressed
           // all pointer inputs just went down
           && event.changes.fastAll { it.changedToDown() }
       ) {
           // Get the pressed pointer info
           val press = event.changes.find { it.pressed }
           if (press != null) {
               // Convert raw press coordinates (px) to dp for positioning the menu
               rightClickOffset = with(density) {
                   isMenuVisible = true // Show the context menu
                   DpOffset(
                       press.position.x.toDp(),
                       press.position.y.toDp()
                   )
               }
           }
           // Consume the press event so it doesn't propagate further
           event.changes.forEach {
               it.consume()
           }
           // Wait for the release and consume it as well
           waitForUpOrCancellation()?.consume()
       }
   }
}

8. Parabéns

Parabéns! Você migrou o app para a Navigation 3 e adicionou:

  • Layouts adaptáveis
  • Arrastar e soltar
  • Atalhos do teclado
  • Menu de contexto

Essa é uma base sólida para criar um app totalmente adaptável.

Saiba mais