Engenheiro sênior de relações com desenvolvedores
O ecossistema Android é vasto, com usuários que alternam facilmente entre smartphones, tablets, dobráveis e ambientes de computador. Agora que priorizamos o Compose, esta sessão mostra como arquitetar seu app para janelas redimensionáveis e diversas proporções, em que as suposições de layout tradicionais falham, levando a interfaces esticadas e perda de estado.
Saiba como os recursos mais recentes do Jetpack Compose, do Navigation 3 e do WindowManager podem ajudar você a criar layouts dinâmicos que se adaptam naturalmente de telas compactas a telas de painel duplo. Saia com estratégias para acelerar seu fluxo de trabalho de desenvolvimento usando o Gemini e nossas habilidades, como a adaptativa, para refatorar automaticamente bases de código legadas para telas maiores.
MediaQuery rastreia de forma declarativa os recursos ativos da interface, como o tamanho da janela e as posições de dobra. Ele substitui a união de vários gerenciadores de plataformas legadas no estado do Compose.Grid e FlexBox fornecem elementos básicos de layout adaptável. Grid permite arranjos multidimensionais parametrizados em um único elemento combinável, enquanto FlexBox oferece distribuição e quebra de espaço unidimensionais.Transcrição
Francesco Romano
Olá, pessoal.
Hoje, vamos nos aprofundar nos detalhes técnicos de como dimensionar seu app para todas as telas com o Jetpack Compose.
Por que dimensionar para todas as telas?
Porque o ecossistema Android se expandiu para muitos formatos diferentes.
Seu APK pode ser executado em smartphones, dobráveis, tablets, Chromebooks e Googlebooks, TVs, sistemas de infoentretenimento automotivo, dispositivos XR etc.
A primeira coisa é atender às necessidades dos usuários.
E, em segundo lugar, a mudança da plataforma Android para apps adaptáveis não é temporária.
Não é como se fôssemos voltar a um mundo só de smartphones, ele veio para ficar.
E tenho uma linha do tempo rápida aqui.
Em 2022, começamos a estabelecer as bases.
Tivemos o Android 12L, a primeira versão do Jetpack WindowManager, oferecemos APIs como as classes de tamanho de janela e publicamos a primeira versão das diretrizes de qualidade de apps adaptáveis.
Além disso, atualizamos o Google Play para que as notas sejam divididas por tipo de dispositivo e começamos a incluir as diretrizes de qualidade de apps adaptáveis nas decisões de classificação.
No Android 15, também lançamos o modo janela para computador no AOSP e o Compose Material 3 Adaptive, que chegou à versão estável.
A partir do Android 16, SDK 36, o sistema simplesmente ignora bloqueios de orientação, de proporção e flags de redimensionamento em telas maiores.
Com o lançamento das telas conectadas, agora seu APK pode ser executado em praticamente qualquer tamanho de janela.
O SDK 36 ofereceu uma flag de manifesto temporária para desativar a substituição de orientação e proporção.
No Android 17, essa desativação não existe mais.
Portanto, criar um design adaptável é um requisito básico da plataforma.
Na última década, trabalhamos em estreita colaboração com equipes de todo o ecossistema e aprendemos em primeira mão por que criar apps adaptáveis pode parecer difícil.
Você tem alguma perda de estado, APIs de janela fragmentadas, sinais pouco claros sobre quando mudar o layout e algumas suposições antigas incorporadas à base de código.
Então, pegamos tudo o que aprendemos com essa década de desafios para desenvolvedores e criamos dois lugares: primeiro, o próprio kit de ferramentas de IU.
Hoje, vamos conhecer alguns novos recursos importantes do Jetpack Compose: consulta de mídia, grade, FlexBox e Navigation 3.
Mas também em ferramentas de agentes que escrevem o código com você usando as habilidades oficiais do Android para o Gemini no Android Studio e na CLI do Android.
Por exemplo, vamos conferir as habilidades adaptativas do Compose que foram lançadas no Google I/O no início deste ano.
Ele já era capaz de migrar layouts de smartphones para tamanhos de janela maiores.
Agora, ele também pode corrigir a perda de estado, implementar experiências exclusivas para dobráveis, como o modo de mesa, e melhorar o suporte de entrada ao descobrir e implementar atalhos de teclado, cursores personalizados, dicas de ferramentas etc.
Vamos começar com o Jetpack Compose, que é a base de tudo isso.
O Jetpack Compose se tornou estável em 2021, então este é o quinto aniversário dele.
E, ao longo desses cinco anos, vimos em milhares de apps de produção como é mais fácil e confiável criar apps adaptáveis com o Compose.
O desenvolvimento para Android agora tem o Compose como prioridade.
Isso significa que todos os novos layouts adaptativos modernos, estilos e componentes de navegação são criados para o Compose.
E, do ponto de vista da arquitetura do sistema, a maior vantagem do Compose para apps adaptáveis em relação ao sistema de visualização legado é como o estado e a interface interagem.
Para explicar isso, vamos fazer uma comparação rápida com o sistema de visualização.
Pense em como o sistema de visualização funcionava.
Sua IU era uma árvore de objetos mutáveis que você inflava uma vez de arquivos XML estáticos.
Você tinha diferentes qualificadores de recurso, e vamos começar com o principal problema aqui: a duplicação em diferentes qualificadores de recurso.
Ao criar uma interface adaptável em XML, você não tem nenhuma lógica condicional dentro do arquivo de layout.
Em vez disso, basta duplicar toda a hierarquia nas pastas de recursos, como layout, layout smallest window 600 dp, layout land e assim por diante.
Veja o exemplo de EditText à direita.
<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>Esse é o mesmo componente lógico que processa a entrada de texto e está espalhado por diferentes arquivos XML.
Se você atualizar um atributo como inputType ou maxLength no layout do smartphone, nada no compilador vai forçar você a atualizá-lo também no layout do tablet.
E como os componentes são dissociados em arquivos diferentes, o desvio estrutural é quase garantido ao longo do tempo.
Essa duplicação também está vinculada a outro problema, que é a perda de estado durante mudanças de configuração.
No sistema de visualização, os componentes da interface têm estado.
Um EditText tem o próprio buffer editável interno, posição personalizada do cursor, estado de foco e algum estado.
Então, quando um usuário, por exemplo, abre o dispositivo ou redimensiona a janela além de um determinado limite, o Android destrói toda a hierarquia de visualização e infla os novos arquivos XML do zero.
Para salvar a entrada do usuário, o framework usa onSaveInstanceState, que vai empacotar o estado vinculado ao ID dessa visualização.
Mas isso cria três possíveis modos de falha.
Primeiro, se alguém acidentalmente nomeou o ID como text_input em vez de text_msg no outro XML, a restauração de estado descarta silenciosamente o texto de entrada do usuário sem nenhum aviso.
Em segundo lugar, onRestoreInstanceState é executado após onViewCreated, o que pode entrar em conflito com os observadores do ViewModel e causar loops de callback.
E terceiro, se você trocar o tipo de widget entre qualificadores, digamos que em uma tela compacta você queira um controle giratório e em uma tela mais ampla você queira um RadioGroup, se eles compartilharem o mesmo ID, isso vai causar uma falha no tempo de execução com uma ClassCastException.
No Jetpack Compose, a interface não é um objeto que você cria uma vez.
A interface é uma função pura do estado.
E isso muda completamente o que é uma mudança de configuração.
O que é uma mudança de configuração no Compose?
Tenho uma dica.
Francesco Romano
Sim.
No Compose, uma mudança de configuração é apenas mais uma recomposição, e as recomposições acontecem o tempo todo.
Quando a janela fica mais larga, talvez porque o usuário abriu o dispositivo, os elementos de composição leem o novo tamanho, e aqueles que dependem dele são executados novamente.
Esse é o evento inteiro.
Mas agora, vamos ver o que significa escrever apps adaptáveis modernos usando as ferramentas e os componentes mais recentes do Compose.
O mundo do XML fazia você se perguntar: "Estou usando um smartphone, um tablet ou um computador?" e, em seguida, ramificar a IU com base na categoria do dispositivo.
Mas há um motivo fundamental para evitar isso.
Mesmo quando executados no mesmo dispositivo físico, o contexto do aplicativo e os recursos da interface podem mudar dinamicamente no momento da execução.
A mensagem que quero deixar é que as decisões de interface precisam ser orientadas pelos recursos ativos atuais: tamanho da janela, postura e algumas características de hardware.
Vamos ver um exemplo clássico, que é o modo janela para computador em um monitor externo.
Se você conectar seu celular Pixel a um monitor externo na sua mesa, o mesmo processo do app que estava sendo executado em uma tela de 6 polegadas no modo retrato agora será executado em um monitor de 32 polegadas com teclado físico e mouse conectados.
Portanto, verificar o tipo de dispositivo em que o app está sendo executado não garante nada sobre a tela em que ele está sendo exibido ou as modalidades de entrada.
Além disso, o Android oferece suporte a várias janelas, que podem ser redimensionadas.
Mesmo que a tela de um Googlebook seja maior que a do smartphone, seu app pode estar sendo executado em uma janela de 400 dp de largura, junto com outros apps.
Portanto, consultar as métricas estáticas de exibição física também pode não fazer sentido.
Portanto, para arquitetar apps para o Compose primeiro, precisamos entender como consultar o ambiente.
Quero começar formalizando o conceito de recursos de interface.
Os recursos de interface incluem não apenas a largura e a altura da janela ou a postura dobrável, mas também as modalidades de entrada e saída que estão ativas no momento.
No ambiente de computadores, os usuários nem sempre usam telas touch.
Eles podem usar teclado e mouse.
No sistema automotivo, alguns carros têm um botão giratório ou uma entrada diferente, uma maneira diferente de inserir sinais.
Além do tamanho da tela e da entrada, também é importante considerar a distância do usuário em relação à tela, como a distância de visualização, ou se uma câmera ou um microfone de hardware está conectado no momento.
Esses são todos os recursos da interface.
Francesco Romano
Agora, se você quisesse recuperar todos os indicadores, precisaria juntar cinco ou seis APIs diferentes de fontes distintas.
Você precisava do objeto de configuração, do WindowMetricsCalculator, do WindowInfoTracker, do InputManager, do PackageManager e, em seguida, converter manualmente cada um deles em um estado, um estado do Compose.
Você gostou de fazer isso?
Não…
Para resolver isso, introduzimos a API MediaQuery no androidx.compose.ui.
mediaQuery { ... }
derivedMediaQuery { ... }Ela é inspirada nas consultas de mídia CSS.
E a palavra "mídia" não significa reprodução de áudio ou vídeo.
Refere-se ao meio, o ambiente em que a interface está sendo renderizada.
E a MediaQuery oferece uma maneira declarativa única e nativa do Compose para consultar os recursos da interface.
Vamos analisar as propriedades expostas no 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 e windowHeight em Dp.
windowPosture, que é destinado a dispositivos dobráveis no momento e pode ser Flat, Tabletop ou Book.
pointerPrecision informa a precisão do dispositivo apontador principal: "Fine" para mouse, stylus ou trackpad; "Coarse" para toque; "Blunt" para joystick, controles ou "None".
keyboardKind pode ser físico, virtual ou nenhum.
hasCamera e hasMicrophone rastreiam dinamicamente se esse hardware está disponível.
Pense em uma câmera externa que pode ser conectada.
E viewingDistance informa se o objeto está perto, a uma distância média ou longe.
Mas acho que é mais fácil analisar um exemplo real, que é detectar se o dispositivo está no modo de mesa.
O modo de mesa é uma postura em que os usuários dobram o dispositivo ao meio e geralmente o colocam em uma superfície.
E é ótimo para reprodução de vídeo prolongada, videochamadas e fotografia com time-lapse.
Também é muito útil para, por exemplo, seguir uma receita ou assistir uma apresentação de slides.
Antes, se você quisesse detectar a postura de mesa antes do MediaQuery, precisava escrever todo esse 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
}
}Você precisou acessar o contexto, receber uma instância de WindowInfoTracker, coletar o windowLayoutInfo, que é um fluxo, um estado do Compose que reconhece o ciclo de vida, e iterar pelo displayFeatures para verificar se há um que seja um FoldingFeature, esteja meio aberto e seja horizontal.
E isso é só para detectar a postura.
É muito código boilerplate.
Com uma nova API MediaQuery, tudo isso é reduzido a apenas algumas linhas de código.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}A função mediaQuery usa um único parâmetro lambda e, dentro do escopo do receptor dessa lambda, expõe o conjunto de propriedades que acabamos de ver.
Neste exemplo, basta verificar windowPosture em uma expressão when para retornar imediatamente o elemento que pode ser composto correto.
Confira mais dois exemplos de como usar a 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")
}
}
}A primeira é a densidade de informações.
Portanto, quando um usuário está em um laptop ou monitor de computador com um mouse e a precisão do ponteiro é "Fina", podemos reduzir o padding nos componentes da interface de, neste exemplo, 8 Dp para 2 Dp para mostrar mais conteúdo de forma confortável. Como os usuários não estão usando os dedos, as áreas de toque podem ser menores.
Além disso, as ações baseadas em recursos.
Digamos que temos uma barra de entrada de chat. Podemos verificar hasCamera e hasMicrophone para mostrar ou ocultar dinamicamente o botão de ação de videochamada ou podemos combinar o tipo de teclado físico para mostrar ou não algumas dicas de atalhos de teclado, como "pressione Ctrl+Enter para enviar".
Todos eles são recuperados no momento da execução.
A MediaQuery também é ótima para testes e geração de prévias.
@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()
}
}Basicamente, a MediaQuery resolve o receptor usando um CompositionLocal chamado LocalUiMediaScope.
Assim, basta fornecer um UiMediaScope falso usando CompositionLocalProvider na visualização, sem precisar ativar dispositivos dobráveis e colocar 90° ou conectar um mouse só para executar um teste de interface do Compose.
Francesco Romano
Agora sabemos como consultar os recursos da interface.
Agora, vamos ver como criar o layout.
Para isso, quero apresentar o Grid, uma nova primitiva de layout no androidx.compose.foundation.layout, que agora é estável.
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...
}A grade permite implementar um layout bidimensional exibindo itens em colunas e linhas que se adaptam ao tamanho do contêiner disponível.
Vamos ver o que você pode fazer no bloco de configuração.
É possível adicionar ao final novos elementos ou faixas chamando column ou row, e usar as repetições padrão do Kotlin, como repeat.
É possível definir espaçamentos entre as faixas.
É possível controlar a direção do posicionamento automático usando flow: GridFlow.Row ou GridFlow.Column.
E aqui está um detalhe importante da arquitetura.
O bloco de configuração é executado durante a transmissão de medição, e o escopo de configuração da grade implementa a densidade e expõe as restrições.
Isso significa que você pode inspecionar constraints.maxWidth.toDp() diretamente na configuração para calcular as faixas sem nenhuma passagem de recomposição extra.
Na grade, quando você adiciona um filho sem modificador, ele flui automaticamente para a próxima célula.
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))
}Quando você quer controle explícito, pode usar Modifier.gridItem, e tem coordenadas baseadas em um.
Portanto, a linha 1, coluna 1 é a célula superior esquerda.
Ele é compatível com uma indexação negativa, então a linha -1 é a última, e assim por diante.
Você pode usar rowSpan para estender um item por uma área 2D, e também há sobreposição.
Portanto, se dois itens forem colocados em células sobrepostas, a grade vai empilhar um sobre o outro dentro desses limites, e a ordem Z será determinada pela ordem de composição.
Portanto, os itens posteriores serão desenhados sobre os outros, assim como Box.
Agora, conhecemos os conceitos básicos do Grid.
Vamos analisar o exemplo do player de vídeo.
Dependendo se o dispositivo está plano ou meio aberto, precisamos alternar entre o layout padrão ou o de mesa.
Antes do Grid, precisávamos criar dois elementos combináveis diferentes, DefaultLayout e TabletopLayout, e depois ramificar o código em uma expressão 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)
}
}Você pode pensar: "Qual é o problema?"
Se você observar esses layouts, o conteúdo deles é idêntico.
Ambos mostram o mesmo player e o mesmo controle.
A única diferença entre eles é como esses dois elementos são organizados na tela.
Até agora, como não tínhamos uma maneira de transmitir o arranjo do layout como um parâmetro, fomos forçados a definir dois elementos que podem ser compostos separados.
Agora, podemos parametrizar a configuração do layout e processar tudo em uma única função combinável.
Vamos ver como.
Com a API Grid, podemos definir áreas nomeadas como tipos enumerados.
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()
}
}Em seguida, podemos especificar elementos combináveis filhos, player e controller, para colocar dentro da grade, e podemos associar cada filho a uma área nomeada chamando Modifier.gridItem e transmitindo o areaId.
E aqui estamos dizendo ao Grid para colocar o player na área chamada VideoPlayerArea.Player e o controle em VideoPlayerArea.Controller.
E aqui definimos as duas lambdas de configuração em um 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 o layout padrão do player de vídeo, configuramos uma grade de uma linha e uma coluna, mas observe que, mesmo que haja apenas uma célula, ainda podemos atribuir dois nomes de área à mesma célula.
Nesse caso, como vimos antes, o controlador é sobreposto ao player.
Para o modo mesa, definimos apenas duas linhas e uma coluna, colocando o player na metade de cima e o controlador na metade de baixo, basicamente, assim.
Depois de definir essas lambdas de configuração, podemos usar o MediaQuery diretamente na configuração da grade.
@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()
}
}
}Agora, podemos ter um único elemento combinável de layout do player de vídeo com a interface que alterna dinamicamente entre duas configurações.
A próxima primitiva que quero abordar é 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) }
)
}
}
}Enquanto o Grid lida com layouts 2D de linhas e colunas, o FlexBox, que agora também é estável, lida com layouts unidimensionais que ainda precisam de distribuição inteligente de espaço, redução e quebra de linha em várias linhas.
É um layout conveniente e responsivo que pode ser usado em vez de dois elementos combináveis separados, como FlowRow e FlowColumn, porque é um único contêiner que pode mudar de direção dinamicamente dentro do bloco de configuração sem recriar os elementos combináveis filhos.
Ele implementa o algoritmo CSS FlexBox completo, então você tem wrap, justify-content, align-items, gap.
E em cada filho, você tem Modifier.flex, que fornece base, crescimento relativo, fatores de redução, alignSelf e assim por diante.
Francesco Romano
Agora, vamos ampliar a definição do nível da tela para os layouts do app, como estruturamos a navegação de nível superior e os arranjos de tela de vários painéis em todo o aplicativo, não apenas na tela única.
Embora o MediaQuery forneça a largura e a altura exatas da janela em Dp, para a arquitetura no nível do app, você precisa de pontos de interrupção de janela de visualização padronizados e opinativos, que são as classes de tamanho de janela.
Basicamente, você quer que todas as telas sejam responsivas em cada um desses buckets, mas pode ter mais mudanças estruturais, atualizações e diferenças entre buckets diferentes.
Por exemplo, mostrando apenas uma lista em uma tela compacta ou a lista e os detalhes em uma janela expandida.
É possível criar classes de tamanho de janela diretamente sobre UiMediaScope de uma maneira muito limpa, que é definir uma propriedade de extensão em 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...
}Observe que ele está usando derivedMediaQuery, não o MediaQuery padrão.
Por quê?
Porque a largura e a altura da janela são valores que mudam com frequência.
Quando os usuários estão redimensionando o app, como em um computador, esses valores podem mudar a cada frame.
E você provavelmente não quer recompor o scaffold no nível do app em cada frame, mas apenas quando a janela realmente cruzar um ponto de interrupção.
E depois de termos as classes de tamanho de janela, como fazemos a transição do app de um fluxo de painel único para layouts de vários painéis quando temos mais espaço?
É aí que o Jetpack Navigation 3 se destaca, porque ele foi projetado do zero com base no estado e nas cenas do Compose.
Primeiro, você é o proprietário da backstack, que é simplesmente uma lista de NavKeys, então é muito flexível e fácil de salvar como um estado.
Cada chave na pilha é resolvida em uma NavEntry que contém o elemento combinável e os metadados.
E o NavDisplay não codifica como as entradas são desenhadas. Em vez disso, ele avalia uma lista de estratégias de cena.
Na estratégia, é possível inspecionar os recursos da interface, por exemplo, as classes de tamanho de janela.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}É possível inspecionar a backstack e decidir se uma NavEntry será renderizada em um único painel, se vários itens serão renderizados simultaneamente ou se um será renderizado sobre o outro.
É muito flexível.
Além da estratégia de vários painéis, as classes de tamanho de janela também determinam quais componentes de navegação você quer usar para maximizar a ergonomia e o uso do espaço da tela.
Por exemplo, em larguras compactas, você quer uma barra de navegação na parte de baixo, em larguras médias, uma coluna de navegação e, em larguras expandidas, uma gaveta de navegação completa.
Ao combinar o Nav3 para decisões de layout no app e componentes responsivos para definição na tela, você cria apps como este, que se destacam em praticamente qualquer tamanho de janela e ambiente.
Para saber mais sobre as atualizações mais recentes da Nav 3, incluindo o suporte a links diretos, confira a palestra "Como criar links diretos com segurança de tipo usando o Jetpack Navigation 3".
Francesco Romano
Hoje, abordamos muitas APIs novas: MediaQuery, Grid, FlexBox e Nav3.
E isso é tudo sobre como o Jetpack Compose ajuda você a criar apps adaptáveis.
Mas na era do desenvolvimento com agentes, há outra vantagem extremamente importante em usar o Jetpack Compose: ele é muito mais eficaz.
Como você deve ter ouvido durante a palestra de abertura, os apps que migraram para o Compose usam mais de 30% menos tokens, e estamos trabalhando para otimizar ainda mais o Agentic Workflows para apps do Compose.
Hoje, quero destacar as atualizações importantes da habilidade de adaptação do Compose.
android skills list
android skills add adaptiveComo eu disse, além de migrar layouts para Grid, Flexbox e MediaQuery, ele agora lida explicitamente com o gerenciamento de estado de ponta a ponta em todo o app, e testamos a habilidade contra bugs de perda de estado do mundo real: preservação da navegação, posição de rolagem, reprodução de mídia ativa, entradas de texto digitadas pela metade e assim por diante.
A habilidade agora também inclui a otimização de postura dobrável, como a refatoração automática de telas de mídia e câmera no modo de mesa. No entanto, quando o app é executado em um Googlebook ou em um monitor externo com mouse e teclado, o layout é apenas metade da história.
A habilidade adaptativa agora pode auditar sua interface para encontrar e implementar atalhos de teclado, cursores de ponteiro personalizados, estados de passagem, dicas de ferramentas e muito mais.
Vamos ver como isso funciona.
Gravei uma demonstração disso.
Temos nosso app.
Podemos olhar para nossos animais de estimação.
Podemos enviar mensagens para eles.
Vamos assistir um vídeo sobre eles.
Sim, podemos ver os animais de estimação mais populares.
Mas você já percebeu que não é muito bom.
Agora, quero ver uma imagem com um pouco de drama e gatos.
Ok, quero ver as imagens maiores.
Dah, isso é voltar para casa.
Certo, vou contar para meu amigo.
Sim, "Buddy", hm.
O app não é muito bom.
Também é muito amplo.
Você percebe que ela é muito larga.
Vamos tentar redimensionar.
O quê?
É, sim, ok.
E o texto que digitei foi perdido.
Ok, é hora de pedir ao Gemini para corrigir isso.
Graças à habilidade adaptativa, o comando pode ser tão simples quanto "torne o app adaptável".
Como você pode ver, estou usando o Gemini 3.0 Flash que, junto com a habilidade, é capaz de fazer isso.
Ela identificou a habilidade e agora está começando a executar as mudanças.
Ele vai fazer algumas perguntas, como: "Ah, o MediaQuery é experimental.
Você quer mesmo usar?"
E continue.
Tive que acelerar para cortar algumas coisas, mas se você vier à área de demonstração, poderá testar as habilidades por conta própria.
E essa é apenas uma das habilidades que lançamos.
Por exemplo, também temos uma nova habilidade que vai ajudar você a criar experiências incríveis com a stylus.
Então, venha até a área para testar.
Então, está feito.
Ele modificou 11 arquivos.
Vamos ver como funciona.
Ok.
A grade já está fazendo o trabalho dela.
A navegação foi movida para o lado.
Vamos testar os filtros.
Acho que agora vai funcionar.
Sim, eles fazem.
Ok, vou mandar uma mensagem para meu amigo e dizer que o app está ótimo agora.
Vamos ver como fica com mais espaço.
Sim, ele migrou para o layout de detalhes e listas.
Essa execução levou cerca de 20 minutos e usou aproximadamente 200.000 tokens.
Ok.
Francesco Romano
Normalmente, começo uma palestra com métricas de negócios e casos de sucesso, mas, para o ADS, queria ir direto ao código.
No entanto, quero dedicar alguns minutos para compartilhar o retorno do investimento em engenharia.
Hoje, seu app Android pode ser executado em mais de 580 milhões de dispositivos ativos de tela grande. Além disso, sabemos que os usuários de dispositivos dobráveis e multidispositivo estão entre os segmentos mais engajados no Android.
Vamos analisar dois estudos de caso concretos que mostram o que acontece quando você envia um build adaptável.
O primeiro é o Gratitude, um app de autocuidado e diário pessoal muito usado.
Ao adotar designs de tela adaptáveis, o Gratitude estabeleceu os tablets e dobráveis como um canal de aquisição de alto crescimento.
Primeiro, eles dobraram a velocidade de aquisição em telas grandes.
As instalações diárias em tablets e dobráveis cresceram 100%.
A participação de telas grandes na base geral de usuários aumentou de 7% para 10% do total de instalações.
O segundo é o BoxBoxClub, um app complementar de Fórmula 1 e automobilismo.Depois de atualizar o build para dispositivos dobráveis e telas maiores, a telemetria mostrou ganhos enormes: um aumento de 7, 6% no total de usuários ativos e de 54% no tempo de engajamento.
Ok.
Estamos no final da palestra, e quero deixar três conclusões importantes.
A criação de apps adaptáveis permite que eles atendam a mais dispositivos e usuários, além de garantir que estejam preparados para o futuro nesse ecossistema em expansão.
O desenvolvimento para Android prioriza o Compose.
Todas as novas ferramentas, bibliotecas e habilidades são criadas para o Compose.
O Compose é ótimo para layouts adaptáveis e para desenvolvimento agêntico.
As bases de código são menores e mais fáceis de entender, e os agentes usam menos tokens ao lidar com o código do Compose.
Então, teste todas as novas ferramentas, habilidades e APIs e divirta-se.
Nossa equipe agradece.
<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