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:
- Inicie o Android Studio.
- Clique em Arquivo > Novo >
Project from Version control
. - Cole o URL:
https://github.com/android/socialite.git
- Clique em
Clone
.
Aguarde o projeto carregar por completo.
- Abra o Terminal e execute:
$ git checkout codelab-adaptive-apps-start
- Executar uma sincronização do Gradle
No Android Studio, selecione File > Sync Project with Gradle Files.
- (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
- 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.
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.
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.
Noções básicas da Navigation 3
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()
Implementação da Navigation 3
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.
NavDisplay
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".
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.
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:
- Solicitar permissão de arrastar e soltar para acessar o URI
- Processar o URI (no nosso caso, chamando a função
onMediaItemAttached()
já implementada) - 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:
onStarted()
: é chamado quando uma sessão de arrastar e soltar começa e oDragAndDropTarget
está qualificado para receber itens. Esse é um bom lugar para preparar o estado da interface para a sessão.onEntered()
: é acionado quando um item arrastado entra nos limites desseDragAndDropTarget
.onMoved()
: é chamado quando o item arrastado se move dentro dos limites desseDragAndDropTarget
.onExited()
: é chamado quando o item arrastado se move para fora dos limites doDragAndDropTarget
.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.onEnded()
: é chamado quando a sessão de arrastar e soltar termina. Todos osDragAndDropTarget
que receberam um eventoonStarted
antes vão receber esse também. Útil para redefinir o estado da UI.
Para adicionar a borda visual, precisamos fazer o seguinte:
- Criar uma variável booleana lembrada que é definida como
true
quando uma ação de arrastar e soltar começa e é redefinida parafalse
quando ela termina. - 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.
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 cursorisMenuVisible
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:
- Receber o próximo evento do ponteiro: o
awaitPointerEvent()
fornece um objeto que representa o evento do ponteiro. - Filtrar para um clique puro com o botão direito do mouse: verificamos se apenas o botão secundário está pressionado.
- 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. - Mostrar o menu: defina
isMenuVisible
=true
e armazene o offset para que oDropdownMenu
apareça exatamente onde o ponteiro estava. - 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