Ingeniero sénior de Relaciones con Desarrolladores
El ecosistema Android es enorme, y los usuarios cambian sin problemas entre teléfonos, tablets, dispositivos plegables y entornos de escritorio. Ahora que Compose es la opción principal, en esta sesión se explora cómo diseñar la arquitectura de tu app para ventanas de tamaño variable y diversas relaciones de aspecto, en las que las suposiciones de diseño tradicionales se desglosan, lo que genera IU estiradas y pérdida de estado.
Explora cómo las funciones más recientes de Jetpack Compose, Navigation 3 y WindowManager pueden ayudarte a crear diseños dinámicos que se ajusten de forma natural de pantallas compactas a pantallas de doble panel. Descubre estrategias para acelerar tu flujo de trabajo de desarrollo con Gemini y nuestras habilidades, como la habilidad adaptable, para refactorizar automáticamente las bases de código heredadas para pantallas más grandes.
MediaQuery hace un seguimiento declarativo de las capacidades activas de la IU, como el tamaño de la ventana y las posturas de plegado. Reemplaza la unión de varios administradores de plataformas heredadas en el estado de Compose.Grid y FlexBox proporcionan componentes básicos de diseño adaptable. Grid habilita disposiciones multidimensionales parametrizadas dentro de un solo elemento componible, mientras que FlexBox proporciona distribución y ajuste de espacio unidimensionales.Transcripción
Francesco Romano
Hola a todos.
Hoy, analizaremos en profundidad los aspectos técnicos para adaptar tu app a todas las pantallas con Jetpack Compose.
¿Por qué escalar a todas las pantallas?
Porque el ecosistema Android se expandió a muchos factores de forma diferentes.
Tu APK se puede ejecutar en teléfonos, dispositivos plegables, tablets, Chromebooks y Googlebooks, TVs, sistemas de infoentretenimiento para automóviles, dispositivos de RE, etcétera.
En primer lugar, debes adaptarte a las necesidades de los usuarios.
En segundo lugar, el cambio de la plataforma de Android hacia las apps adaptables no es transitorio.
No es que vayamos a volver a un mundo solo de teléfonos, llegó para quedarse.
Aquí tengo una breve cronología.
En 2022, comenzamos a sentar las bases.
Tuvimos Android 12L, la primera versión de Jetpack WindowManager, te proporcionamos APIs como las clases de tamaño de ventana y publicamos la primera versión de los lineamientos de calidad de las apps adaptables.
Luego, también actualizamos Google Play para que las calificaciones se dividan por tipo de dispositivo y comenzamos a incluir los lineamientos de calidad de apps adaptables en las decisiones de clasificación.
En Android 15, también se incluyó el modo de ventanas de escritorio en AOSP y Compose Material 3 Adaptive, que alcanzó la versión estable.
Luego, a partir de Android 16, SDK 36, el sistema simplemente ignora los bloqueos de orientación, los bloqueos de relación de aspecto y los indicadores de cambio de tamaño en pantallas más grandes.
Junto con el lanzamiento de las pantallas conectadas, ahora tu APK se puede ejecutar en prácticamente cualquier tamaño de ventana.
Y el SDK 36 te dio un indicador de manifiesto temporal para inhabilitar la anulación de la orientación y la relación de aspecto.
En Android 17, esa opción de inhabilitación ya no está disponible.
Por lo tanto, crear apps adaptables es un requisito básico de la plataforma.
Durante la última década, trabajamos en estrecha colaboración con equipos de todo el ecosistema y aprendimos de primera mano por qué puede ser difícil crear apps adaptables.
Se pierde parte del estado, las APIs de ventanas están fragmentadas, no hay señales claras sobre cuándo cambiar el diseño y hay algunas suposiciones antiguas arraigadas en la base de código.
Por lo tanto, tomamos todo lo que aprendimos de esta década de desafíos para desarrolladores y lo incorporamos directamente en dos lugares: primero, en el kit de herramientas de la IU.
Hoy exploraremos algunas funciones nuevas importantes de Jetpack Compose: consulta de medios, cuadrícula, FlexBox y Navigation 3.
También en herramientas de agentes que escriben el código contigo a través de las habilidades oficiales de Android para Gemini en Android Studio y la CLI de Android.
Por ejemplo, veremos las habilidades adaptables de Compose que se lanzaron en Google I/O a principios de este año.
Ya era capaz de migrar diseños de teléfonos a tamaños de ventana más grandes.
Ahora, también puede corregir la pérdida de estado, implementar experiencias exclusivas para dispositivos plegables, como el modo de mesa, y mejorar la compatibilidad con entradas descubriendo e implementando combinaciones de teclas, cursores personalizados, información sobre herramientas, etcétera.
Comencemos con Jetpack Compose, que es la base de todo esto.
Jetpack Compose se volvió estable en 2021, por lo que este año cumple cinco años.
Durante esos cinco años, vimos en miles de apps de producción que es mucho más fácil y confiable compilar apps adaptables con Compose.
El desarrollo de Android ahora prioriza oficialmente Compose.
Esto significa que todos los nuevos diseños adaptables, estilos y componentes de navegación modernos se compilan para Compose.
Y, desde la perspectiva de la arquitectura del sistema, la mayor ventaja de Compose para las apps adaptables sobre el sistema de vistas heredado es cómo interactúan el estado y la IU.
Para explicar esto, quiero hacer una comparación rápida con el sistema de vistas.
Piensa en cómo funcionaba el sistema de vistas.
La IU era un árbol de objetos mutables que se aumentaban una vez a partir de archivos en formato XML estáticos.
Tenías diferentes calificadores de recursos, y comencemos con el problema principal aquí: la duplicación en diferentes calificadores de recursos.
Cuando compilas una IU adaptable en XML, no tienes ninguna lógica condicional dentro del archivo de diseño.
En cambio, solo duplicas toda la jerarquía en las carpetas de recursos, como layout, layout smallest window 600 dp, layout land, etcétera.
Bien, observa el ejemplo de EditText de la derecha.
<LinearLayout android:orientation="vertical">
<EditText
android:id="@+id/text_msg"
android:inputType="textCapCharacters"
android:maxLength="12" />
</LinearLayout><ConstraintLayout>
<TextInputEditText
android:id="@+id/text_input"
android:inputType="text" />
</ConstraintLayout>Ese es exactamente el mismo componente lógico que controla la entrada de texto y está disperso en diferentes archivos XML.
Si actualizas un atributo como inputType o maxLength en el diseño del teléfono, nada en el compilador te obliga a actualizarlo también en el diseño de la tablet.
Y debido a que los componentes están desacoplados en diferentes archivos, la desviación estructural está casi garantizada con el tiempo.
Esa duplicación también está vinculada a otro problema, que es la pérdida de estado durante los cambios de configuración.
Recuerda que, en el sistema de View, los componentes de la IU tienen estado.
Un EditText posee su búfer Editable interno, la posición personalizada del cursor, el estado de enfoque y algún estado.
Por lo tanto, cuando un usuario, por ejemplo, despliega el dispositivo o cambia el tamaño de la ventana más allá de un cierto umbral, Android destruye toda la jerarquía de vistas y aumenta los nuevos archivos en formato XML desde cero.
Para guardar la entrada del usuario, el framework se basa en onSaveInstanceState, que empaquetará el estado vinculado al ID de esa vista.
Pero eso crea tres posibles modos de falla.
Primero, si alguien nombró accidentalmente el ID text_input en lugar de text_msg en el otro XML, el restablecimiento del estado descarta de forma silenciosa el texto de entrada del usuario sin advertencias.
En segundo lugar, onRestoreInstanceState se ejecuta después de onViewCreated, lo que puede generar conflictos con frecuencia con los observadores de ViewModel y provocar bucles de devolución de llamada.
En tercer lugar, si intercambias el tipo de widget entre calificadores, por ejemplo, en una pantalla compacta, tal vez quieras un ícono giratorio, y en una pantalla más ancha, un RadioGroup, si comparten el mismo ID, se produce un error en el tiempo de ejecución con una ClassCastException.
En cambio, en Jetpack Compose, la IU no es un objeto que construyes una vez.
Tu IU es una función pura del estado.
Y eso cambia por completo lo que es un cambio de configuración.
¿Qué es un cambio de configuración en Compose?
Tengo una pista.
Francesco Romano
Sí.
En Compose, un cambio de configuración es solo otra recomposición, y las recomposiciones ocurren todo el tiempo.
Cuando la ventana se ensancha, tal vez porque el usuario desplegó el dispositivo, tus elementos componibles leen el nuevo tamaño, y los que dependen de él se vuelven a ejecutar.
Ese es todo el evento.
Pero ahora, veamos qué significa escribir apps adaptables modernas con las herramientas y los componentes más recientes de Compose.
Por lo tanto, el mundo de XML te hacía preguntas como "¿Estoy ejecutando la app en un teléfono, una tablet o una computadora de escritorio?" y, luego, ramificaba la IU según la categoría del dispositivo.
Sin embargo, hay una razón fundamental para evitarlo.
Incluso cuando se ejecuta en el mismo dispositivo físico, el contexto de la aplicación y las capacidades de la IU pueden cambiar de forma dinámica durante el tiempo de ejecución.
Por lo tanto, el mensaje que quiero dejar es que las decisiones de la IU deben estar impulsadas por las capacidades activas actuales: el tamaño de la ventana actual, la postura y algunas características del hardware.
Veamos un ejemplo clásico, que es el modo de ventanas de escritorio en un monitor externo.
Si tomas tu teléfono Pixel y lo conectas a un monitor externo en tu escritorio, el mismo proceso de la app que se estaba ejecutando en una pantalla vertical de 6 pulgadas ahora se ejecuta en un monitor de 32 pulgadas con un teclado físico y un mouse conectados.
Por lo tanto, verificar el tipo de dispositivo en el que se ejecuta la app no garantiza nada sobre la pantalla en la que se muestra ni las modalidades de entrada.
Además, Android admite Multiventana, por lo que se puede cambiar el tamaño de las ventanas.
Incluso si la pantalla de una Googlebook es más grande que la del teléfono, es posible que tu app se ejecute en una ventana de 400 dp de ancho, junto con otras apps.
Por lo tanto, consultar las métricas estáticas de la pantalla física también puede ser irrelevante.
Por lo tanto, para diseñar apps para Compose primero, debemos comprender cómo consultar el entorno.
Y quiero empezar por intentar formalizar el concepto de capacidades de la IU.
Las capacidades de la IU incluyen no solo el ancho y la altura de la ventana o la postura plegable, sino también las modalidades de entrada y salida que están activas actualmente.
En el entorno de escritorio, observamos que los usuarios no siempre usan la pantalla táctil.
Pueden usar el teclado y el mouse.
En el sistema automotriz, algunos vehículos tienen una perilla giratoria o un tipo de entrada diferente, una forma diferente de ingresar señales.
Además, más allá del tamaño de la pantalla y la entrada, la distancia a la que se encuentra el usuario de la pantalla, como la distancia de visualización, o si hay una cámara o un micrófono de hardware conectados en ese momento.
Esas son todas las funciones de la IU.
Francesco Romano
Ahora, si quisieras recuperar todos los indicadores, tendrías que unir 5 o 6 APIs diferentes de distintas fuentes.
Necesitabas el objeto de configuración, WindowMetricsCalculator, WindowInfoTracker, InputManager, PackageManager y, luego, convertir manualmente cada uno de ellos en un estado, un estado de Compose.
¿Te gustó hacerlo?
No.
Para resolver este problema, presentamos la API de MediaQuery en androidx.compose.ui.
mediaQuery { ... }
derivedMediaQuery { ... }Se inspira en las consultas de medios de CSS.
Y la palabra contenido multimedia no significa reproducción de audio o video.
Se refiere al medio, el entorno en el que se renderiza la IU.
Y MediaQuery te brinda una única forma declarativa nativa de Compose para consultar las capacidades de la IU.
Veamos las propiedades expuestas en UiMediaScope.
sealed interface UiMediaScope {
@get:FrequentlyChangingValue val windowWidth: Dp
@get:FrequentlyChangingValue val windowHeight: Dp
val windowPosture: Posture // Flat, Tabletop, Book
val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
val keyboardKind: KeyboardKind // Physical, Virtual, None
val hasCamera: Boolean
val hasMicrophone: Boolean
val viewingDistance: ViewingDistance // Near, Medium, Far
}windowWidth y windowHeight en dp.
windowPosture, que, por el momento, está pensado para dispositivos plegables y puede ser Flat, Tabletop o Book.
pointerPrecision te indica la precisión del dispositivo apuntador principal: Fine para mouse, pluma stylus o panel táctil; Coarse para pantalla táctil; Blunt para joystick, controles o None.
keyboardKind puede ser físico, virtual o ninguno.
hasCamera y hasMicrophone hacen un seguimiento dinámico de la disponibilidad de este hardware.
Piensa en una cámara externa que se pueda conectar.
Y viewingDistance informa Cerca, Medio o Lejos.
Pero creo que es más fácil si vemos un ejemplo real, que es detectar si el dispositivo está en modo mesa.
El modo de mesa es una postura que permite a los usuarios plegar el dispositivo por la mitad y, por lo general, colocarlo sobre una superficie.
Y es ideal para la reproducción de videos prolongada, para una videollamada o para la fotografía de time lapse.
También es muy útil para, por ejemplo, seguir una receta o mirar una presentación de diapositivas.
Ahora, si querías detectar la postura de mesa antes de MediaQuery, tenías que escribir todo este código.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
val context = LocalContext.current
val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
.collectAsStateWithLifecycle(null)
val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
displayFeature is FoldingFeature &&
displayFeature.state == FoldingFeature.State.HALF_OPENED &&
displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
return if (isTabletopPosture) {
VideoPlayerLayout.Tabletop
} else {
VideoPlayerLayout.Default
}
}Tuviste que obtener el contexto, obtener una instancia de WindowInfoTracker, recopilar el windowLayoutInfo, que es un flujo, un estado de Compose que prioriza el ciclo de vida, y, luego, iterar a través de displayFeatures para verificar si hay uno que sea un FoldingFeature, que esté medio abierto y que sea horizontal.
Y eso es solo para detectar la postura.
Eso es mucho código estándar.
Con una nueva API de MediaQuery, todo eso se reduce a solo estas pocas líneas de código.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}La función mediaQuery toma un solo parámetro lambda y, dentro del alcance del receptor de esa lambda, expone el conjunto de propiedades que acabamos de ver.
En este ejemplo, con solo verificar windowPosture en una expresión when, podemos devolver de inmediato el elemento componible correcto.
Aquí hay otros dos ejemplos de cómo usar la API.
@Composable
fun AdaptiveChatControls(
onStartVideoCall: () -> Unit,
onSendMessage: () -> Unit,
) {
// 1. Return a Dp value directly based on pointer precision
val chipPadding = mediaQuery {
if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
}
// 2. Return a Boolean combining hardware capabilities
val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
val hasPhysicalKeyboard = mediaQuery {
keyboardKind == KeyboardKind.Physical
}
Row(modifier = Modifier.padding(chipPadding)) {
if (canVideoCall) {
IconButton(onClick = onStartVideoCall) {
Icon(Icons.Default.Videocam, contentDescription = "Video call")
}
}
Button(onClick = onSendMessage) {
Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
}
}
}Primero, está la densidad de la información.
Por lo tanto, cuando un usuario está en una laptop o un monitor de escritorio con un mouse, y la precisión del puntero es fina, podemos reducir el padding de nuestros componentes de la IU de, en este ejemplo, 8 dp a 2 dp para mostrar más contenido de forma cómoda, ya que los usuarios no usan los dedos, los objetivos táctiles pueden ser más pequeños.
También, las acciones basadas en capacidades.
Supongamos que tenemos una barra de entrada de chat. Podemos verificar hasCamera y hasMicrophone para mostrar u ocultar de forma dinámica el botón de acción de videollamada, o podemos combinar el tipo de teclado físico para mostrar, o no, algunas sugerencias de combinaciones de teclas, como presionar Ctrl + Intro para enviar.
Todos esos datos se recuperan en el tiempo de ejecución.
MediaQuery también es excelente para realizar pruebas y generar vistas previas.
@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
val simulatedMediaScope = object : UiMediaScope {
override val windowWidth = 840.dp
override val windowHeight = 700.dp
override val windowPosture = UiMediaScope.Posture.Tabletop
override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
override val keyboardKind = UiMediaScope.KeyboardKind.Physical
override val hasCamera = true
override val hasMicrophone = true
override val viewingDistance = UiMediaScope.ViewingDistance.Medium
}
CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
VideoPlayerScreen()
}
}Básicamente, MediaQuery resuelve su receptor a través de un elemento CompositionLocal llamado LocalUiMediaScope.
Por lo tanto, puedes proporcionar un UiMediaScope falso a través de CompositionLocalProvider en tu vista previa, y no necesitas activar dispositivos plegables y colocarlos en 90° ni conectar un mouse solo para ejecutar una prueba de IU de Compose.
Francesco Romano
Ahora sabemos cómo consultar las capacidades de la IU.
Ahora, veamos cómo crear el diseño real.
Para eso, quiero presentar Grid, que es una nueva primitiva de diseño en androidx.compose.foundation.layout, que ahora es estable.
Grid(
modifier = Modifier.fillMaxSize().padding(16.dp),
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val columnCount = if (maxWidthDp < 600.dp) 2 else 4
repeat(columnCount) {
column(1.fr)
}
row(GridTrackSize.Auto)
row(1.fr)
gap(row = 16.dp, column = 8.dp)
flow = GridFlow.Row
}
) {
// GridScope children...
}La cuadrícula te permite implementar un diseño bidimensional mostrando los elementos en columnas y filas que se adaptan al tamaño del contenedor disponible.
Veamos algunas de las cosas que puedes hacer en el bloque de configuración.
Puedes agregar elementos o pistas nuevos llamando a la columna o fila, y puedes usar los bucles estándar de Kotlin, como repeat.
Puedes establecer medianiles entre las pistas.
Puedes controlar la dirección de la ubicación automática a través de flow: GridFlow.Row o GridFlow.Column.
Este es un detalle clave de la arquitectura.
El bloque de configuración se ejecuta durante el pase de medición, y el alcance de la configuración de la cuadrícula implementa la densidad y expone las restricciones.
Esto significa que puedes inspeccionar constraints.maxWidth.toDp() directamente dentro de la configuración para calcular las pistas sin pases de recomposición adicionales.
En Grid, cuando agregas un elemento secundario sin modificador, este se ubicará automáticamente en la siguiente celda.
Grid(
config = {
repeat(3) { column(1.fr) }
repeat(3) { row(100.dp) }
gap(8.dp)
}
) {
// Spans 2x2 area (rows 1..2, columns 1..2)
HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
// Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
LiveBadge(
modifier = Modifier.gridItem(
row = 1,
column = 1,
alignment = Alignment.TopStart
)
)
// Negative index: placed in last row (-1), spanning all 3 columns
FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}Cuando quieras un control explícito, puedes usar Modifier.gridItem y tendrás coordenadas basadas en uno.
Por lo tanto, la fila 1, columna 1 es la celda superior izquierda.
Admite una indexación negativa, por lo que la fila -1 es la última, y así sucesivamente.
Puedes usar rowSpan para abarcar un elemento en un área 2D, y también hay superposición.
Por lo tanto, si se colocan dos elementos en celdas superpuestas, Grid los apila uno encima del otro dentro de esos límites, y el orden Z se determina por el orden de composición.
Por lo tanto, los elementos posteriores se dibujarán encima de los demás, como Box.
Ahora, ya conocemos los conceptos básicos de Grid.
Veamos el ejemplo del reproductor de video.
Según si el dispositivo está plano o semiabierto, debemos cambiar entre el diseño predeterminado o el diseño de mesa.
Antes de Grid, teníamos que crear dos elementos componibles diferentes, DefaultLayout y TabletopLayout, y, luego, ramificar el código en una expresión when.
@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
when(currentVideoPlayerLayout()) {
VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
}
}Y tal vez pienses: "¿Qué tiene de malo?".
Si observas esos diseños, verás que su contenido es idéntico.
Ambas muestran exactamente el mismo reproductor y el mismo controlador.
La única diferencia entre ellas es cómo se organizan estos dos elementos en la pantalla.
Hasta ahora, como no teníamos una forma de pasar la disposición del diseño como parámetro, nos veíamos en la obligación de definir dos funciones de componibilidad independientes.
Ahora, podemos parametrizar la configuración del diseño y controlar todo dentro de una sola función de componibilidad.
Y veremos cómo.
Por lo tanto, con la API de Grid, podemos definir áreas con nombre como enumeraciones.
enum class VideoPlayerArea { Player, Controller }
Grid(
config = { ... },
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}Luego, podemos especificar elementos componibles secundarios, player y controller, para colocarlos dentro de la cuadrícula, y podemos asociar cada elemento secundario con un área con nombre llamando a Modifier.gridItem y pasando el areaId.
Aquí, le indicamos a Grid que coloque el reproductor en el área llamada VideoPlayerArea.Player y el controlador en VideoPlayerArea.Controller.
Y aquí definimos las dos lambdas de configuración en un VideoPlayerGridConfiguration.
object VideoPlayerGridConfiguration {
val Default: GridConfigurationScope.() -> Unit = {
row(1f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
}
val Tabletop: GridConfigurationScope.() -> Unit = {
row(0.5f)
row(0.5f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
}
}Para el diseño predeterminado del reproductor de video, configuramos una cuadrícula de una fila y una columna, pero observa que, incluso si solo hay una celda, podemos asignar dos nombres de área a la misma celda.
En este caso, como vimos antes, el controlador se superpone sobre el reproductor.
Para el modo de mesa, solo definimos dos filas y una columna, y colocamos el reproductor en la mitad superior y el controlador en la mitad inferior, básicamente, de esta manera.
Una vez que hayamos definido esas lambdas de configuración, podremos usar MediaQuery directamente en la configuración de la cuadrícula.
@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Grid(
config = { mediaQuery {
if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
else { VideoPlayerGridConfiguration.Default }
}
}
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}
}Por lo tanto, ahora podemos tener un solo elemento componible de diseño de reproductor de video con la IU que cambia de forma dinámica entre dos configuraciones.
La siguiente primitiva de la que quiero hablar es FlexBox.
@Composable
fun AdaptiveActionHeader(tags: List<String>) {
FlexBox(
config = {
direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
wrap(FlexWrap.Wrap)
alignItems(FlexAlignItems.Center)
gap(row = 8.dp, column = 12.dp)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Home",
modifier = Modifier.flex {
basis(FlexBasis.Auto)
grow(1f)
shrink(1f)
}
)
tags.forEach { tag ->
FilterChipItem(
label = tag,
modifier = Modifier.flex { shrink(0f) }
)
}
}
}Por lo tanto, mientras que Grid controla los diseños de filas y columnas en 2D, FlexBox, que ahora también es estable, controla los diseños unidimensionales que aún necesitan una distribución inteligente del espacio, reducción y ajuste de varias líneas.
Además, es un diseño responsivo y conveniente que puedes usar en lugar de dos elementos componibles separados, como FlowRow y FlowColumn, porque es un solo contenedor cuya dirección puede cambiar de forma dinámica dentro de su bloque de configuración sin volver a crear elementos componibles secundarios.
Además, implementa el algoritmo completo de CSS FlexBox, por lo que tienes wrap, justify-content, align-items y gap.
Y en cada elemento secundario, tienes Modifier.flex que te proporciona la base, el crecimiento relativo, los factores de reducción, alignSelf, etcétera.
Francesco Romano
Bien, ahora alejémonos de la definición a nivel de la pantalla para ver los diseños a nivel de la app, cómo estructuramos nuestra navegación de nivel superior y las disposiciones de pantalla de varios paneles en toda la aplicación, no solo en una pantalla.
Si bien MediaQuery te brinda el ancho y la altura exactos de la ventana en dp, para la arquitectura a nivel de la app, necesitas puntos de interrupción de viewport estandarizados y definidos, que son las clases de tamaño de ventana.
Básicamente, quieres que cada pantalla sea responsiva dentro de cada uno de estos buckets, mientras que puedes tener más cambios estructurales, actualizaciones y diferencias entre los distintos buckets.
Por ejemplo, mostrando solo una lista en una pantalla compacta o la lista y los detalles en una ventana expandida.
Puedes compilar clases de tamaño de ventana directamente sobre UiMediaScope de una manera muy limpia, que consiste en definir una propiedad de extensión en UiMediaScope.
val UiMediaScope.windowSizeClass: WindowSizeClass
@Composable get() = derivedMediaQuery {
WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
widthDp = windowWidth.value,
heightDp = windowHeight.value
)
}
@Composable
fun AppRoot() {
val sizeClass = mediaQuery { windowSizeClass }
val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
)
// Drive app-level navigation and scene strategies...
}Ten en cuenta que se usa derivedMediaQuery, no la MediaQuery estándar.
¿Por qué?
Porque el ancho y el alto de la ventana son valores que cambian con frecuencia.
Cuando los usuarios cambian el tamaño de tu app, como en una computadora de escritorio, estos valores pueden cambiar en cada fotograma.
Y es probable que no quieras recomponer el scaffold a nivel de la app en cada fotograma, sino solo cuando la ventana cruce un punto de interrupción.
Y, una vez que tenemos las clases de tamaño de ventana, ¿cómo hacemos la transición de nuestra app de un flujo de un solo panel a diseños de varios paneles cuando tenemos más espacio?
Aquí es donde se destaca Jetpack Navigation 3, ya que se diseñó desde cero en torno al estado y las escenas de Compose.
En primer lugar, eres el propietario de la pila de actividades, que es simplemente una lista de NavKeys, por lo que es muy flexible y fácil de guardar como estado.
Cada clave de la pila se resuelve en una NavEntry que contiene su elemento componible y sus metadatos.
Además, NavDisplay no codifica de forma rígida cómo se dibujan las entradas, sino que evalúa una lista de estrategias de escena.
Dentro de la estrategia, puedes inspeccionar las capacidades de la IU, por ejemplo, las clases de tamaño de ventana.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}Puedes inspeccionar la pila de actividades y, luego, decidir si renderizar un NavEntry en un solo panel, varios elementos de forma simultánea o uno encima del otro.
Es muy flexible.
Además de la estrategia de varios paneles, las clases de tamaño de ventana también determinan qué componentes de navegación quieres usar para maximizar la ergonomía y el uso del espacio de la pantalla.
Por ejemplo, en los anchos compactos, querrás una barra de navegación inferior; en los medianos, un NavRail, y en los expandidos, un panel lateral de navegación completo.
Cuando combinas Nav3 para tomar decisiones de diseño a nivel de la app y componentes responsivos para la definición a nivel de la pantalla, obtienes apps que se ven así y se destacan en prácticamente todos los tamaños de ventana y entornos.
En el caso específico de Nav 3, si quieres obtener más información sobre las actualizaciones más recientes, incluida la compatibilidad con vínculos directos, consulta la charla Crea vínculos directos con seguridad de tipos con Jetpack Navigation 3.
Francesco Romano
Hoy vimos muchas APIs nuevas: MediaQuery, Grid, FlexBox y Nav3.
Y eso abarca cómo Jetpack Compose te ayuda a compilar apps adaptables.
Sin embargo, en la era del desarrollo de agentes, hay otra ventaja extremadamente importante de usar Jetpack Compose: es mucho más eficaz.
Probablemente lo escuchaste durante la presentación: vimos que las apps usan más de un 30% menos de tokens después de migrar a Compose, y estamos trabajando para optimizar aún más tu flujo de trabajo de agentes para las apps de Compose.
Hoy, quiero destacar las actualizaciones importantes de la habilidad de Compose adaptable.
android skills list
android skills add adaptiveComo dije, además de migrar los diseños a Grid, Flexbox y MediaQuery, ahora controla de forma explícita la administración de estados de extremo a extremo en toda la app, y probamos la habilidad con errores de pérdida de estado del mundo real: preservación de la navegación, la posición de desplazamiento, la reproducción de contenido multimedia activo, las entradas de texto escritas a medias, etcétera.
La habilidad ahora también incluye la optimización de la postura de los dispositivos plegables, como la refactorización automática de las pantallas de contenido multimedia y de la cámara en el modo mesa, pero también cuando tu app se ejecuta en una Googlebook o en un monitor externo con un mouse y un teclado, el diseño es solo la mitad de la historia.
La habilidad adaptable ahora puede auditar tu IU para encontrar e implementar combinaciones de teclas, cursores de puntero personalizados, estados de desplazamiento, información sobre herramientas y mucho más.
Veamos cómo funciona.
Grabé una demostración de esto.
Ya tenemos nuestra app.
Podemos mirar a nuestras mascotas.
Podemos intercambiar mensajes con ellos.
Podemos ver un video de ellos.
Sí, podemos ver las mascotas principales.
Pero ya ves que no es muy bueno.
Ahora, veamos. Solo quiero ver una imagen con algo de drama y gatos.
Bien, quiero verlos más grandes.
Ah, eso es para volver a la página principal.
De acuerdo, déjame contarle a mi amigo sobre esto.
Sí, "Buddy", hm.
La app no es muy buena.
También es demasiado amplia.
Verás que es demasiado ancho.
Intentemos cambiarle el tamaño.
¿Qué?
Eh, sí, de acuerdo.
Y se pierde el texto que escribí.
Bien, es hora de pedirle a Gemini que corrija esto.
Gracias a la habilidad de adaptación, la instrucción puede ser tan simple como "haz que la app sea adaptable".
Como puedes ver, estoy usando Gemini 3.0 Flash, que, junto con la habilidad, puede hacerlo.
Detectó la habilidad y ahora está comenzando a ejecutar los cambios.
Hará algunas preguntas, como "Oh, MediaQuery es experimental.
¿Realmente quieres usarlo?".
Y sigue adelante.
Tuve que acelerar el video para recortar algunas partes, pero si vienes al área de demostración, puedes probar las habilidades por tu cuenta.
Y esta es solo una de las habilidades que lanzamos.
Por ejemplo, también tenemos una nueva habilidad que te ayudará a crear experiencias increíbles con la pluma stylus.
Por lo tanto, acércate al área para probarlo.
Así que ya está hecho.
Se modificaron 11 archivos.
Veamos cómo funciona.
Bien.
La cuadrícula ya está haciendo su trabajo.
La navegación se movió al costado.
Probemos los filtros.
Creo que ahora funcionarán.
Sí, lo hacen.
Bien, enviémosle un mensaje a mi amigo para decirle que la app ahora funciona muy bien.
Veamos cómo se ve con más espacio.
Sí, se migró al diseño de lista-detalles.
Esta ejecución tardó unos 20 minutos y consumió alrededor de 200,000 tokens.
Bien.
Francesco Romano
Por lo general, comienzo una charla con métricas empresariales y casos de éxito, pero para ADS, quería ir directo al código.
Sin embargo, quiero dedicar unos minutos a compartir el retorno de la inversión en ingeniería.
Hoy en día, tu app para Android puede ejecutarse en más de 580 millones de dispositivos activos con pantalla grande. Además, sabemos que los usuarios de dispositivos plegables y multidispositivo se encuentran entre los segmentos con mayor participación en Android.
Veamos dos casos de éxito concretos que demuestran lo que sucede cuando envías una compilación adaptable.
La primera es Gratitude, una app popular de cuidado personal y diario personal.
Con la adopción de diseños de pantalla adaptables, Gratitude estableció las tablets y los dispositivos plegables como un canal de adquisición de alto crecimiento.
Primero, duplicaron la velocidad de adquisición en pantallas grandes.
Las instalaciones diarias que se originaron específicamente en tablets y dispositivos plegables aumentaron un 100%.
Expandieron el porcentaje de pantallas grandes de sus bases de usuarios generales del 7% al 10% del total de instalaciones.
La segunda es BoxBoxClub, una app complementaria de Fórmula 1 y automovilismo. Después de actualizar su compilación para dispositivos plegables y pantallas más grandes, su telemetría mostró ganancias masivas: un aumento del 7.6% en los usuarios activos generales y un aumento del 54% en el tiempo de participación.
Bien.
Llegamos al final de la charla y quiero darte tres conclusiones importantes.
Crear apps adaptables permite que tu app funcione en más dispositivos y para más usuarios, a la vez que la prepara para el futuro en este ecosistema en expansión.
El desarrollo de Android prioriza Compose.
Todas las herramientas, bibliotecas y habilidades nuevas se crean para Compose.
Compose es ideal para diseños adaptables y para el desarrollo agéntico.
Las bases de código son más pequeñas y fáciles de entender, y los agentes usan menos tokens cuando trabajan con código de Compose.
Así que adelante, prueba todas las herramientas, habilidades y APIs nuevas, y diviértete.
Gracias.
<LinearLayout android:orientation="vertical">
<EditText
android:id="@+id/text_msg"
android:inputType="textCapCharacters"
android:maxLength="12" />
</LinearLayout><ConstraintLayout>
<TextInputEditText
android:id="@+id/text_input"
android:inputType="text" />
</ConstraintLayout>mediaQuery { ... }
derivedMediaQuery { ... }sealed interface UiMediaScope {
@get:FrequentlyChangingValue val windowWidth: Dp
@get:FrequentlyChangingValue val windowHeight: Dp
val windowPosture: Posture // Flat, Tabletop, Book
val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
val keyboardKind: KeyboardKind // Physical, Virtual, None
val hasCamera: Boolean
val hasMicrophone: Boolean
val viewingDistance: ViewingDistance // Near, Medium, Far
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
val context = LocalContext.current
val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
.collectAsStateWithLifecycle(null)
val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
displayFeature is FoldingFeature &&
displayFeature.state == FoldingFeature.State.HALF_OPENED &&
displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
return if (isTabletopPosture) {
VideoPlayerLayout.Tabletop
} else {
VideoPlayerLayout.Default
}
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}@Composable
fun AdaptiveChatControls(
onStartVideoCall: () -> Unit,
onSendMessage: () -> Unit,
) {
// 1. Return a Dp value directly based on pointer precision
val chipPadding = mediaQuery {
if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
}
// 2. Return a Boolean combining hardware capabilities
val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
val hasPhysicalKeyboard = mediaQuery {
keyboardKind == KeyboardKind.Physical
}
Row(modifier = Modifier.padding(chipPadding)) {
if (canVideoCall) {
IconButton(onClick = onStartVideoCall) {
Icon(Icons.Default.Videocam, contentDescription = "Video call")
}
}
Button(onClick = onSendMessage) {
Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
}
}
}@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
val simulatedMediaScope = object : UiMediaScope {
override val windowWidth = 840.dp
override val windowHeight = 700.dp
override val windowPosture = UiMediaScope.Posture.Tabletop
override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
override val keyboardKind = UiMediaScope.KeyboardKind.Physical
override val hasCamera = true
override val hasMicrophone = true
override val viewingDistance = UiMediaScope.ViewingDistance.Medium
}
CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
VideoPlayerScreen()
}
}Grid(
modifier = Modifier.fillMaxSize().padding(16.dp),
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val columnCount = if (maxWidthDp < 600.dp) 2 else 4
repeat(columnCount) {
column(1.fr)
}
row(GridTrackSize.Auto)
row(1.fr)
gap(row = 16.dp, column = 8.dp)
flow = GridFlow.Row
}
) {
// GridScope children...
}Grid(
config = {
repeat(3) { column(1.fr) }
repeat(3) { row(100.dp) }
gap(8.dp)
}
) {
// Spans 2x2 area (rows 1..2, columns 1..2)
HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
// Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
LiveBadge(
modifier = Modifier.gridItem(
row = 1,
column = 1,
alignment = Alignment.TopStart
)
)
// Negative index: placed in last row (-1), spanning all 3 columns
FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
when(currentVideoPlayerLayout()) {
VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
}
}enum class VideoPlayerArea { Player, Controller }
Grid(
config = { ... },
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}object VideoPlayerGridConfiguration {
val Default: GridConfigurationScope.() -> Unit = {
row(1f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
}
val Tabletop: GridConfigurationScope.() -> Unit = {
row(0.5f)
row(0.5f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
}
}@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Grid(
config = { mediaQuery {
if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
else { VideoPlayerGridConfiguration.Default }
}
}
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}
}@Composable
fun AdaptiveActionHeader(tags: List<String>) {
FlexBox(
config = {
direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
wrap(FlexWrap.Wrap)
alignItems(FlexAlignItems.Center)
gap(row = 8.dp, column = 12.dp)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Home",
modifier = Modifier.flex {
basis(FlexBasis.Auto)
grow(1f)
shrink(1f)
}
)
tags.forEach { tag ->
FilterChipItem(
label = tag,
modifier = Modifier.flex { shrink(0f) }
)
}
}
}val UiMediaScope.windowSizeClass: WindowSizeClass
@Composable get() = derivedMediaQuery {
WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
widthDp = windowWidth.value,
heightDp = windowHeight.value
)
}
@Composable
fun AppRoot() {
val sizeClass = mediaQuery { windowSizeClass }
val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
)
// Drive app-level navigation and scene strategies...
}if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}android skills list
android skills add adaptive