1. Antes de comenzar
Requisitos previos
- Experiencia en el desarrollo de apps para Android
- Experiencia con Jetpack Compose
Requisitos
- La versión estable más reciente de Android Studio
Qué aprenderás
- Conceptos básicos de los diseños adaptativos y Navigation 3
- Cómo implementar la acción de arrastrar y soltar
- Compatibilidad con combinaciones de teclas
- Cómo habilitar los menús contextuales
2. Prepárate
Para comenzar, sigue estos pasos:
- Inicia Android Studio.
- Haz clic en File > New >
Project from Version control
. - Pega la URL:
https://github.com/android/socialite.git
- Haz clic en
Clone
.
Espera a que se cargue completamente el proyecto.
- Abre la terminal y ejecuta lo siguiente:
$ git checkout codelab-adaptive-apps-start
- Ejecuta una sincronización de Gradle.
En Android Studio, selecciona File > Sync Project with Gradle Files.
- Descarga el emulador de computadora de escritorio grande (opcional).
En Android Studio, selecciona Tools > Device Manager > + > Create Virtual Device > New hardware profile.
Selecciona el tipo de dispositivo: Computadora de escritorio.
Tamaño de la pantalla: 14 pulgadas
Resolución: 1,920 × 1,080 px
Haz clic en Finish.
- Ejecuta la app en un emulador de tablet o computadora.
3. Comprende la app de ejemplo
En este instructivo, trabajarás con una aplicación de chat de ejemplo llamada Socialite, compilada con Jetpack Compose.
En esta app, puedes chatear con diferentes animales, y ellos responden tus mensajes, cada uno a su manera.
En este momento, es una aplicación para dispositivos móviles que no está optimizada para dispositivos grandes, como tablets o computadoras.
Adaptaremos la app para pantallas grandes y agregaremos algunas funciones para mejorar la experiencia en todos los factores de forma.
Comencemos.
4. Diseños adaptativos y conceptos básicos de Navigation 3
$ git checkout codelab-adaptive-apps-step-1
Actualmente, la app muestra un solo panel a la vez en todo momento, sin importar la cantidad de espacio de pantalla disponible.
Para solucionarlo, usaremos adaptive layouts
, que muestra uno o varios paneles según el tamaño de la ventana actual. En este codelab, usaremos diseños adaptativos para mostrar automáticamente las pantallas chat list
y chat detail
una al lado de la otra, cuando haya suficiente espacio en la ventana.
Los diseños adaptativos están diseñados para integrarse sin problemas en cualquier aplicación.
En este instructivo, nos enfocaremos en cómo usarlos con la biblioteca de Navigation 3, en la que se compiló la app de Socialite.
Aspectos básicos de Navegation 3
Para comprender Navigation 3, veamos algunos términos, como los siguientes:
- NavEntry: se trata del contenido que se muestra dentro de una app al que un usuario puede navegar. Se identifica de forma única con una clave. Un NavEntry no tiene que ocupar toda la ventana disponible para la app. Se puede mostrar más de un NavEntry al mismo tiempo (más información sobre esto más adelante).
- Clave: se trata de un identificador único para un NavEntry. Las claves se almacenan en la pila de actividades.
- Pila de actividades: se trata de una pila de claves que representan elementos NavEntry que se mostraron antes o que se están mostrando actualmente. Para navegar, presiona teclas en la pila o quítalas.
En Socialite, la primera pantalla que queremos mostrar cuando el usuario inicia la app es la lista de chat. Por lo tanto, creamos la pila de actividades y la inicializamos con la clave que representa esa pantalla.
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()
Implementación de Navigation 3
Implementaremos Navigation 3 directamente en el elemento componible del punto de entrada Main
.
Quita los comentarios de la llamada a la función MainNavigation
para conectar la lógica de navegación.
Ahora comencemos a compilar la infraestructura de navegación.
Primero, crea la pila de actividades. Es la piedra angular de Navigation 3.
NavDisplay
Hasta este punto, hemos cubierto varios conceptos de Navigation 3. Pero ¿cómo determina la biblioteca qué objeto representa la pila de actividades y cómo convertir sus elementos en una IU real?
Te presentamos a NavDisplay
, el componente que une todo y renderiza la pila de actividades. Toma algunos parámetros importantes. Analicemos cada uno de ellos.
Parámetro 1: pila de actividades
NavDisplay
necesita acceso a la pila de actividades para renderizar su contenido. Déjemoslo acceder.
Parámetro 2: EntryProvider
EntryProvider
es una lambda que transforma las claves de pila de actividades en contenido de IU componible. Toma una clave y muestra un NavEntry
, que contiene el contenido que se mostrará y los metadatos sobre cómo mostrarlo (hablaremos de esto más adelante).
NavDisplay
llama a esta lambda cada vez que necesita obtener contenido para una clave determinada, por ejemplo, cuando se agrega una clave nueva a la pila de actividades.
Actualmente, si hacemos clic en el ícono de Línea de tiempo en Socialite, veremos un mensaje que dice "Unknown back stack key: Timeline" (Clave de pila de actividades desconocida: Timeline).
Esto se debe a que, aunque la clave Timeline se agrega a la pila de actividades, EntryProvider
no sabe cómo renderizarla, por lo que recurre a la implementación predeterminada. Lo mismo sucede cuando hacemos clic en el ícono de Configuración. Para solucionarlo, asegúrate de que EntryProvider
controle correctamente las claves de pila de actividades de Rutas y Configuración.
Parámetro 3: SceneStrategy
El siguiente parámetro importante de NavDisplay
es SceneStrategy
. Se usa cuando queremos mostrar varios elementos NavEntry
al mismo tiempo. Cada estrategia define cómo se muestran varios elementos NavEntry
uno al lado del otro o superpuestos.
Por ejemplo, si usamos DialogSceneStrategy
y marcamos algunos NavEntry
con metadatos especiales, aparecerá como un diálogo sobre el contenido actual en lugar de ocupar toda la pantalla.
En nuestro caso, usaremos una SceneStrategy diferente: ListDetailSceneStrategy
. Está diseñado para el diseño canónico de lista-detalles.
Primero, agrégalo al constructor NavDisplay
.
sceneStrategy = rememberListDetailSceneStrategy(),
Ahora debemos marcar el NavEntry
ChatList
como un panel de lista y el NavEntry ChatThread
como un panel de detalles, de modo que la estrategia pueda determinar cuándo ambos elementos NavEntry están en la pila de actividades y deben mostrarse juntos, uno al lado del otro.
Como siguiente paso, marca ChatsList
NavEntry
como un panel de lista.
entryProvider = { backStackKey ->
when (backStackKey) {
is ChatsList -> NavEntry(
key = backStackKey,
metadata = ListDetailSceneStrategy.listPane(),
) {
...
}
...
}
}
De manera similar, marca ChatThread
NavEntry
como panel de detalles.
entryProvider = { backStackKey ->
when (backStackKey) {
is ChatThread -> NavEntry(
key = backStackKey,
metadata = ListDetailSceneStrategy.detailPane(),
) {
...
}
...
}
}
Con eso, integramos correctamente los diseños adaptativos en nuestra app.
5. Arrastrar y soltar
$ git checkout codelab-adaptive-apps-step-2
En este paso, agregaremos compatibilidad con la acción de arrastrar y soltar, lo que permitirá a los usuarios arrastrar imágenes de la app de Files a Socialite.
Nuestro objetivo es habilitar esta acción en el área message list
, que define el elemento MessageList
componible, ubicado en el archivo ChatScreen.kt
.
En Jetpack Compose, el modificador dragAndDropTarget
implementa la compatibilidad con la función de arrastrar y soltar. Lo aplicamos a los elementos componibles que deben aceptar elementos soltados.
Modifier.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
// condition to accept dragged item
},
target = // DragAndDropTarget
)
El modificador tiene dos parámetros.
- El primero,
shouldStartDragAndDrop
, permite que el elemento componible filtre eventos de arrastrar y soltar. En nuestro caso, solo queremos aceptar imágenes y, además, ignorar todos los demás tipos de datos. - El segundo,
target
, es una devolución de llamada que define la lógica para controlar los eventos de arrastrar y soltar aceptados.
Primero, comencemos por agregar dragAndDropTarget
al elemento MessageList
componible.
.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
event.mimeTypes().any { it.startsWith("image/") }
},
target = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
TODO("Not yet implemented")
}
}
}
),
El objeto de devolución de llamada target
debe implementar el método onDrop()
, que toma un DragAndDropEvent
como argumento.
Este método se invoca cuando el usuario coloca un elemento en el elemento componible. Muestra true
si se controló el elemento y false
si se rechazó.
Cada DragAndDropEvent
contiene un objeto ClipData
, que encapsula los datos que se arrastran.
Los datos dentro de ClipData
son un array de objetos Item
. Dado que se pueden arrastrar varios elementos a la vez, cada Item
representa uno de ellos.
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
}
}
}
Un Item
puede contener datos en forma de URI, texto o Intent
.
En nuestro caso, como solo aceptamos imágenes, buscamos específicamente un URI.
Si un Item
contiene uno, debemos hacer lo siguiente:
- Solicitar permiso de arrastrar y soltar para acceder al URI
- Controlar el URI (en nuestro caso, llama a la función
onMediaItemAttached()
ya implementada) - Liberar el permiso
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
}
En este punto, la acción de arrastrar y soltar está completamente implementada, y puedes arrastrar fotos de la app de Files a Socialite sin problemas.
Para que se vea aún mejor, agreguemos un borde visual que destaque que el área puede aceptar elementos que se suelten.
Para ello, podemos usar hooks adicionales que corresponden a diferentes etapas de la sesión de arrastrar y soltar:
onStarted()
: se llama cuando comienza una sesión de arrastrar y soltar, y esteDragAndDropTarget
es apto para recibir elementos. Este es un buen lugar para preparar el estado de la IU para la sesión entrante.onEntered()
: se activa cuando un elemento arrastrado ingresa a los límites de esteDragAndDropTarget
.onMoved()
: se llama cuando el elemento arrastrado se mueve dentro de los límites de esteDragAndDropTarget
.onExited()
: se llama cuando el elemento arrastrado se mueve fuera de los límites de esteDragAndDropTarget
.onChanged()
: se invoca cuando algo cambia en la sesión de arrastrar y soltar dentro de los límites de este objetivo; por ejemplo, si se presiona o suelta una tecla modificadora.onEnded()
: se lo llama cuando finaliza la sesión de arrastrar y soltar. CualquierDragAndDropTarget
que haya recibido un eventoonStarted
lo recibirá. Es útil para restablecer el estado de la IU.
Para agregar el borde visual, debemos hacer lo siguiente:
- Crear una variable booleana recordada que se establezca en
true
cuando comience una acción de arrastrar y soltar, y se restablezca enfalse
cuando finalice - Aplicar un modificador al elemento
MessageList
componible que renderice un borde cuando esta variable seatrue
override fun onEntered(event: DragAndDropEvent) {
super.onEntered(event)
isDraggedOver = true
}
override fun onEnded(event: DragAndDropEvent) {
super.onExited(event)
isDraggedOver = false
}
6. Combinaciones de teclas
$ git checkout codelab-adaptive-apps-step-3
Cuando usan una app de chat en computadoras, los usuarios esperan combinaciones de teclas conocidas, como enviar un mensaje con la tecla Intro.
En este paso, agregaremos ese comportamiento a nuestra app.
Los eventos del teclado en Compose se controlan con modificadores.
Hay dos principales:
onPreviewKeyEvent
: intercepta el evento del teclado antes de que lo controle el elemento enfocado. Como parte de la implementación, decidimos si propagar el evento más adelante o consumirlo.onKeyEvent
: intercepta el evento del teclado después de que el elemento enfocado lo controla. Solo se activa si los otros controladores no consumen el evento.
En nuestro caso, usar onKeyEvent
en un TextField
no funcionaría, ya que el controlador predeterminado consume el evento de tecla Intro y mueve el cursor a la línea nueva.
.onPreviewKeyEvent { keyEvent ->
//TODO: implement key event handling
},
Se llamará a la expresión lambda dentro del modificador dos veces por cada vez que se presione una tecla: una vez cuando el usuario presione la tecla y una vez cuando la suelte.
Para determinar cuál es, podemos verificar la propiedad type
del objeto KeyEvent
. El objeto del evento también expone marcas de modificadores, incluidas las siguientes:
isAltPressed
isCtrlPressed
isMetaPressed
isShiftPressed
La devolución de true
desde la expresión lambda notifica a Compose que nuestro código controló el evento de tecla y evita el comportamiento predeterminado, como insertar una línea nueva.
Ahora, implementa el modificador onPreviewKeyEvent
. Verifica si el evento corresponde a la tecla Intro presionada y si no se aplica ninguno de los modificadores Mayúsculas, Alt, Ctrl o Meta. Luego, llama a la función 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. Menús contextuales
$ git checkout codelab-adaptive-apps-step-4
Los menús contextuales son una parte importante de una IU adaptativa.
En este paso, agregaremos un menú emergente Responder que aparecerá cuando el usuario haga clic con el botón derecho en un mensaje.
Existen muchos gestos diferentes que se admiten de forma predeterminada, por ejemplo, el modificador clickable
permite detectar fácilmente un clic.
Para los gestos personalizados, como los clics con el botón derecho, podemos usar el modificador pointerInput
, que nos brinda acceso a los eventos de puntero sin procesar y control total sobre la detección de gestos.
Primero, agreguemos la IU que responderá a un clic con el botón derecho. En nuestro caso, queremos mostrar DropdownMenu
con un solo elemento: un botón Responder. Necesitaremos 2 variables con remember
:
rightClickOffset
almacena la posición del clic para que podamos mover el botón Responder cerca del cursor.isMenuVisible
controla si se muestra o se oculta el botón Responder.
Sus valores se actualizarán como parte del manejo del gesto de clic derecho.
También debemos unir el elemento componible de mensaje en un Box
, de modo que el DropdownMenu
pueda aparecer en capas sobre él.
@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(
...
) {
...
}
}
}
Ahora, implementemos el modificador pointerInput
. Primero, agregamos awaitEachGesture
, que inicia un nuevo alcance cada vez que el usuario comienza un gesto nuevo. Dentro de ese alcance, debemos hacer lo siguiente:
- Obtener el siguiente evento del puntero:
awaitPointerEvent()
proporciona un objeto que representa el evento del puntero. - Filtrar un clic con el botón derecho puro: verificamos que solo se presione el botón secundario.
- Capturar la posición del clic: toma la posición en píxeles y conviértela a
DpOffset
para que la posición del menú no dependa del DPI. - Mostrar el menú: establece
isMenuVisible
=true
y almacena el desplazamiento para queDropdownMenu
aparezca exactamente donde estaba el puntero. - Procesar el evento: llama a
consume()
en la presión y en su liberación coincidente para evitar que otros controladores reaccionen.
.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. Felicitaciones
¡Felicitaciones! Migraste correctamente la app a Navigation 3 y agregaste lo siguiente:
- Diseños adaptativos
- La acción de arrastrar y soltar
- Combinaciones de teclas
- Menú contextual
Esa es una base sólida para crear una app completamente adaptativo.
Más información