L'ecosistema Android è vasto e gli utenti passano senza problemi da smartphone, tablet, pieghevoli e ambienti desktop. Ora che abbiamo adottato l'approccio Compose First, questa sessione esplora come progettare l'architettura della tua app per finestre ridimensionabili e diversi formati, dove le ipotesi di layout tradizionali non funzionano, portando a UI allungate e perdita di stato.
Scopri come le funzionalità più recenti di Jetpack Compose, Navigation 3 e WindowManager possono aiutarti a creare layout dinamici che si adattano in modo naturale da schermi compatti a display a doppio riquadro. Scopri le strategie per accelerare il tuo workflow di sviluppo utilizzando Gemini e le nostre skill, come la skill adattiva, per rifattorizzare automaticamente i codebase legacy per schermi più grandi.
MediaQuery monitora in modo dichiarativo le funzionalità attive della UI, come le dimensioni della finestra e le posizioni di piegatura. Sostituisce l'unione di più gestori di piattaforme legacy nello stato di Compose.Grid e FlexBox stabili forniscono componenti di base per il layout adattivo. Grid consente disposizioni multidimensionali parametrizzate all'interno di un singolo composable, mentre FlexBox fornisce la distribuzione e l'allineamento dello spazio unidimensionale.Trascrizione
Francesco Romano
Ciao,
Oggi approfondiremo gli aspetti tecnici su come scalare la tua app su ogni schermo con Jetpack Compose.
Perché adattare i contenuti a ogni schermo?
Perché l'ecosistema Android si è espanso in molti fattori di forma diversi.
L'APK può essere eseguito su smartphone, pieghevoli, tablet, Chromebook e Googlebook, TV, sistemi di infotainment per auto, dispositivi XR e così via.
Innanzitutto, raggiungi i tuoi utenti ovunque si trovino.
In secondo luogo, il passaggio della piattaforma Android alle app adattive non è transitorio.
Non è che torneremo a un mondo di soli telefoni, l'IA è qui per restare.
Ecco una breve cronologia.
Nel 2022 abbiamo iniziato a gettare le basi.
Abbiamo avuto Android 12L, la prima release di Jetpack WindowManager, abbiamo fornito API come le classi di dimensioni della finestra e abbiamo pubblicato la prima versione delle norme sulla qualità delle app adattive.
Inoltre, abbiamo aggiornato Google Play in modo che le valutazioni siano suddivise per tipo di dispositivo e abbiamo iniziato a includere le norme sulla qualità delle app adattive nelle decisioni relative al ranking.
In Android 15, abbiamo anche introdotto le finestre delle app in AOSP e Compose Material 3 Adaptive, che ha raggiunto la versione stabile.
A partire da Android 16, SDK 36, il sistema ignora semplicemente i blocchi dell'orientamento, i blocchi delle proporzioni e i flag di ridimensionamento sugli schermi più grandi.
Insieme al rilascio dei display connessi, ora il tuo APK può essere eseguito praticamente in qualsiasi dimensione della finestra.
L'SDK 36 ha fornito un flag manifest temporaneo per disattivare l'override dell'orientamento e delle proporzioni.
In Android 17, questa disattivazione non è più disponibile.
Pertanto, la creazione di un'app adattiva è un requisito di base della piattaforma.
Negli ultimi dieci anni, abbiamo lavorato a stretto contatto con i team di tutto l'ecosistema e abbiamo imparato in prima persona perché la creazione di app adattive può sembrare difficile.
Si verificano alcune perdite di stato, API di finestre frammentate, indicatori poco chiari su quando cambiare layout e alcune vecchie ipotesi radicate nella codebase.
Abbiamo quindi preso tutto ciò che abbiamo imparato da questo decennio di sfide per gli sviluppatori e lo abbiamo integrato direttamente in due punti: innanzitutto, nel toolkit dell'interfaccia utente stesso.
Oggi esploreremo alcune nuove funzionalità principali di Jetpack Compose: query supporti, griglia, FlexBox e Navigation 3.
Ma anche in strumenti agentici che scrivono il codice insieme a te tramite le skill Android ufficiali per Gemini in Android Studio e la CLI Android.
Ad esempio, vedremo le competenze adattive di Compose rilasciate durante la conferenza Google I/O all'inizio di quest'anno.
Era già in grado di eseguire la migrazione dei layout degli smartphone a finestre di dimensioni maggiori.
Ora può anche correggere la perdita di stato, implementare esperienze uniche per i pieghevoli, come la modalità da tavolo, e migliorare il supporto degli input scoprendo e implementando scorciatoie da tastiera, cursori personalizzati, descrizioni comando e così via.
Bene, iniziamo con Jetpack Compose, che è la base di tutto questo.
Jetpack Compose è diventato stabile nel 2021, quindi quest'anno compie cinque anni.
In questi cinque anni, abbiamo visto in migliaia di app di produzione quanto sia più facile e affidabile creare app adattive con Compose.
Lo sviluppo per Android è ora ufficialmente incentrato su Compose.
Ciò significa che tutti i nuovi layout adattivi, gli stili e i componenti di navigazione moderni sono creati per Compose.
Dal punto di vista dell'architettura di sistema, il vantaggio principale di Compose per le app adattive rispetto al sistema di visualizzazione precedente è il modo in cui lo stato e la UI interagiscono.
Per spiegarlo, voglio fare un rapido confronto con il sistema di visualizzazione.
Pensa a come funzionava il sistema di visualizzazione.
La UI era un albero di oggetti modificabili che venivano espansi una volta da file XML statici.
Avevi diversi qualificatori delle risorse e iniziamo con il problema principale: la duplicazione tra diversi qualificatori delle risorse.
Quando crei una UI adattiva in XML, non hai alcuna logica condizionale all'interno del file di layout.
Invece, devi solo duplicare l'intera gerarchia nelle cartelle delle risorse, come layout, layout smallest window 600 dp, layout land e così via.
Ok, guarda l'esempio EditText a destra.
<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>Si tratta dello stesso componente logico che gestisce l'input di testo ed è sparso in diversi file XML.
Se aggiorni un attributo come inputType o maxLength nel layout per smartphone, il compilatore non ti obbliga ad aggiornarlo anche nel layout per tablet.
Inoltre, poiché i componenti sono disaccoppiati in file diversi, la deviazione strutturale è quasi garantita nel tempo.
Inoltre, questa duplicazione è collegata a un altro problema, ovvero la perdita di stato durante le modifiche alla configurazione.
Ricorda che nel sistema View i componenti dell'interfaccia utente sono stateful.
Un EditText possiede il proprio buffer Editable interno, la posizione del cursore personalizzata, lo stato attivo e un altro stato.
Quindi, quando un utente, ad esempio, apre il dispositivo o ridimensiona la finestra oltre una determinata soglia, Android distrugge l'intera gerarchia di oggetti View e gonfia i nuovi file XML da zero.
Per salvare l'input dell'utente, il framework si basa su onSaveInstanceState, che raggruppa lo stato collegato all'ID della visualizzazione.
Ma questo crea tre possibili modalità di errore.
Innanzitutto, se qualcuno ha accidentalmente denominato l'ID text_input anziché text_msg nell'altro XML, il ripristino dello stato elimina silenziosamente il testo di input dell'utente senza alcun avviso.
In secondo luogo, onRestoreInstanceState viene eseguito dopo onViewCreated, il che può spesso entrare in conflitto con gli osservatori di ViewModel e causare loop di callback.
Terzo, se scambi il tipo di widget tra i qualificatori, ad esempio su uno schermo compatto vuoi uno spinner e su uno schermo più ampio vuoi un RadioGroup, se condividono lo stesso ID, si verifica un arresto anomalo in fase di runtime con una ClassCastException.
In Jetpack Compose, invece, la UI non è un oggetto che crei una volta.
La tua UI è una funzione pura dello stato.
E questo cambia completamente il concetto stesso di modifica della configurazione.
Che cos'è una modifica della configurazione in Compose?
Ho un suggerimento.
Francesco Romano
Sì.
In Compose, una modifica della configurazione è solo un'altra ricomposizione e le ricomposizioni avvengono continuamente.
Quando la finestra si allarga, ad esempio perché l'utente ha aperto il dispositivo, i composabili leggono le nuove dimensioni e quelli il cui output dipende da queste vengono semplicemente eseguiti di nuovo.
Questo è l'intero evento.
Ma ora vediamo cosa significa scrivere app adattive moderne utilizzando gli strumenti e i componenti di Compose più recenti.
Quindi, il mondo XML ti faceva porre domande come "Sto eseguendo l'app su uno smartphone, un tablet o un computer?" e poi ramificava la UI in base alla categoria del dispositivo.
Ma c'è un motivo fondamentale per evitare di farlo.
Anche se l'app viene eseguita esattamente sullo stesso dispositivo fisico, il contesto dell'applicazione e le funzionalità della UI possono cambiare dinamicamente in fase di runtime.
Quindi, il messaggio che voglio lasciare è che le decisioni relative alla UI dovrebbero essere guidate dalle funzionalità attive correnti: dimensioni della finestra corrente, postura, alcune caratteristiche hardware.
Vediamo un esempio classico: le finestre delle app su un monitor esterno.
Se colleghi lo smartphone Pixel a un monitor esterno sulla scrivania, lo stesso processo dell'app che era in esecuzione su uno schermo verticale da 6 pollici ora è in esecuzione su un monitor da 32 pollici con tastiera fisica e mouse collegati.
Pertanto, il controllo del tipo di dispositivo su cui è in esecuzione l'app non garantisce nulla sullo schermo su cui viene effettivamente visualizzata o sulle modalità di input.
Inoltre, Android supporta la modalità Multi-finestra, quindi le finestre possono essere ridimensionate.
Anche se lo schermo di un Googlebook è più grande di quello dello smartphone, l'app potrebbe essere eseguita in una finestra larga 400 dp, insieme ad altre app.
Pertanto, anche l'esecuzione di query sulle metriche statiche del display fisico può essere priva di significato.
Quindi, per progettare app per Compose, dobbiamo prima capire come eseguire query sull'ambiente.
Vorrei iniziare cercando di formalizzare il concetto di funzionalità della UI.
Le funzionalità della UI includono non solo la larghezza e l'altezza della finestra o la postura del pieghevole, ma anche le modalità di input e output attualmente attive.
Abbiamo notato che nell'ambiente desktop gli utenti non utilizzano sempre il touch screen.
Possono usare la tastiera e il mouse.
Nel sistema dell'auto, alcune vetture hanno una manopola o un input diverso, un modo diverso per inserire i segnali.
Inoltre, oltre alle dimensioni dello schermo e all'input, la distanza dell'utente dal display, ad esempio la distanza di visualizzazione, o se una videocamera o un microfono hardware sono attualmente collegati.
Queste sono tutte le funzionalità della UI.
Francesco Romano
Ora, se volevi recuperare tutti gli indicatori, dovevi unire 5 o 6 API diverse provenienti da fonti diverse.
Avevi bisogno dell'oggetto di configurazione, di WindowMetricsCalculator, di WindowInfoTracker, di InputManager, di PackageManager e poi, manualmente, di convertire ciascuno di questi in uno stato, uno stato di Compose.
Ti è piaciuto?
No.
Per risolvere questo problema, abbiamo introdotto l'API MediaQuery in androidx.compose.ui.
mediaQuery { ... }
derivedMediaQuery { ... }Si ispira alle query supporti CSS.
Inoltre, il termine "media" non si riferisce alla riproduzione di audio o video.
Si riferisce al mezzo, ovvero l'ambiente in cui viene eseguito il rendering della UI.
MediaQuery ti offre un unico modo dichiarativo nativo di Compose per eseguire query sulle funzionalità della UI.
Diamo un'occhiata alle proprietà esposte in 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 in dp.
windowPosture, che al momento è destinato ai pieghevoli e può essere Flat, Tabletop o Book.
pointerPrecision indica la precisione del dispositivo di puntamento principale: Fine per mouse, stilo o trackpad; Approssimativa per il tocco; Nessuna per joystick, controller o Nessuno.
keyboardKind può essere fisico, virtuale o nessuno.
hasCamera e hasMicrophone monitorano dinamicamente la disponibilità di questo hardware.
Pensa a una fotocamera esterna che può essere collegata.
viewingDistance segnala Vicino, Medio o Lontano.
Ma penso che sia più facile se guardiamo un esempio reale, ovvero il rilevamento della modalità da tavolo del dispositivo.
La modalità da tavolo è una posizione che consente agli utenti di piegare a metà il dispositivo e, di solito, di appoggiarlo su una superficie.
È ideale per la riproduzione di video prolungata, per una videochiamata e per la fotografia time-lapse.
È anche molto utile per seguire una ricetta o guardare una presentazione.
In precedenza, per rilevare la postura Tabletop prima di MediaQuery, dovevi scrivere tutto questo codice.
@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
}
}Dovevi ottenere il contesto, un'istanza di WindowInfoTracker, raccogliere windowLayoutInfo, che è un flusso, uno stato di Compose sensibile al ciclo di vita, quindi eseguire l'iterazione di displayFeatures per verificare se ce n'è uno che è un FoldingFeature, è semiaperto ed è orizzontale.
E questo solo per rilevare la postura.
Si tratta di un boilerplate di grandi dimensioni.
Con una nuova API MediaQuery, tutto questo si riduce a poche righe di codice.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}La funzione mediaQuery accetta un singolo parametro lambda e, all'interno dell'ambito del ricevitore di quel lambda, espone l'insieme di proprietà che abbiamo appena visto.
In questo esempio, controllando semplicemente windowPosture in un'espressione when, possiamo restituire immediatamente il composable corretto.
Ecco altri due esempi di utilizzo dell'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")
}
}
}La prima è la densità delle informazioni.
Quindi, quando un utente utilizza un laptop, un monitor desktop con un mouse e la precisione del puntatore è Fine, possiamo ridurre il padding sui componenti della UI da, in questo esempio, 8 Dp a 2 Dp per mostrare più contenuti in modo confortevole, poiché gli utenti non utilizzano le dita, i touch target possono essere più piccoli.
Inoltre, azioni basate sulle funzionalità.
Supponiamo di avere una barra di inserimento della chat, possiamo controllare hasCamera e hasMicrophone per mostrare o nascondere dinamicamente il pulsante di azione della videochiamata oppure possiamo combinare il tipo di tastiera fisica per mostrare o meno alcuni suggerimenti per le scorciatoie da tastiera, ad esempio premi Ctrl+Invio per inviare.
Tutti questi dati vengono recuperati in fase di esecuzione.
MediaQuery è ottimo anche per i test e la generazione di anteprime.
@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()
}
}In pratica, MediaQuery risolve il suo ricevitore tramite un elemento CompositionLocal chiamato LocalUiMediaScope.
Quindi, puoi semplicemente fornire un UiMediaScope fittizio tramite CompositionLocalProvider nell'anteprima e non è necessario avviare dispositivi pieghevoli e impostarli a 90° o collegare un mouse solo per eseguire un test della UI di Compose.
Francesco Romano
Ora sappiamo come eseguire query sulle funzionalità della UI.
Ora vediamo come creare il layout effettivo.
Per questo, voglio presentare Grid, una nuova primitiva di layout in androidx.compose.foundation.layout, che ora è stabile.
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 griglia consente di implementare un layout bidimensionale visualizzando gli elementi in colonne e righe che si adattano alle dimensioni del contenitore disponibili.
Vediamo alcune cose che puoi fare nel blocco di configurazione.
Puoi aggiungere nuovi elementi o tracce chiamando column o row e puoi utilizzare i loop Kotlin standard come repeat.
Puoi impostare i canali tra le tracce.
Puoi controllare la direzione del posizionamento automatico tramite flow-- GridFlow.Row o GridFlow.Column.
Ecco un dettaglio chiave dell'architettura.
Il blocco di configurazione viene eseguito durante la fase di misurazione e l'ambito di configurazione della griglia implementa la densità ed espone i vincoli.
Ciò significa che puoi ispezionare constraints.maxWidth.toDp() direttamente all'interno della configurazione per calcolare le tracce senza passaggi di ricomposizione aggiuntivi.
In Grid, quando aggiungi un elemento secondario senza modificatore, questo viene inserito automaticamente nella cella successiva.
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 vuoi un controllo esplicito, puoi utilizzare Modifier.gridItem e hai coordinate basate su uno.
Quindi, la riga 1, colonna 1 è la cella in alto a sinistra.
Supporta un'indicizzazione negativa, per cui la riga -1 è l'ultima riga e così via.
Puoi utilizzare rowSpan per estendere un elemento su un'area 2D, oltre alla sovrapposizione.
Pertanto, se due elementi vengono posizionati in celle sovrapposte, Grid li impila uno sopra l'altro all'interno di questi limiti e l'ordine Z è determinato dall'ordine di composizione.
Quindi, gli elementi successivi verranno disegnati sopra gli altri, proprio come Box.
Ora conosciamo le nozioni di base di Grid.
Vediamo l'esempio del video player.
A seconda che il dispositivo sia piatto o semiaperto, dobbiamo passare dal layout predefinito al layout da tavolo.
Prima di Grid, dovevamo creare due diversi componibili, DefaultLayout e TabletopLayout, e poi creare un ramo del codice su un'espressione 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)
}
}E potresti pensare: "Cosa c'è di male?"
Se guardi questi layout, i loro contenuti sono identici.
Entrambi i display mostrano esattamente lo stesso player e lo stesso controller.
L'unica differenza tra i due è il modo in cui questi due elementi sono disposti sullo schermo.
Finora, non avendo un modo per passare la disposizione del layout stessa come parametro, eravamo costretti a definire due composabili separati.
Ora possiamo parametrizzare la configurazione del layout e gestire tutto all'interno di una singola funzione componibile.
Vedremo come.
Quindi, con l'API Grid, possiamo definire le aree denominate come enum.
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()
}
}Poi, possiamo specificare i composabili secondari, player e controller, da inserire nella griglia e possiamo associare ogni elemento secondario a un'area denominata chiamando Modifier.gridItem e passando areaId.
In questo caso, indichiamo a Grid di posizionare il player nell'area denominata VideoPlayerArea.Player e il controller nell'area VideoPlayerArea.Controller.
Qui definiamo le due funzioni lambda di configurazione in 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)
}
}Per il layout predefinito del video player, configuriamo una griglia di una riga e una colonna, ma tieni presente che, anche se esiste una sola cella, possiamo comunque assegnare due nomi di area alla stessa cella.
In questo caso, come abbiamo visto in precedenza, il controller è sovrapposto al player.
Per la modalità da tavolo, definiamo semplicemente due righe e una colonna, posizionando il player nella metà superiore e il controller nella metà inferiore, in pratica, in questo modo.
Una volta definite queste lambda di configurazione, possiamo utilizzare MediaQuery direttamente nella configurazione della griglia.
@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()
}
}
}Ora possiamo avere un singolo composable di layout del video player con l'UI che passa dinamicamente da una configurazione all'altra.
La prossima primitiva di cui voglio parlare è 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) }
)
}
}
}Quindi, mentre Grid gestisce i layout di righe e colonne 2D, FlexBox, che ora è anche stabile, gestisce i layout monodimensionali che richiedono comunque una distribuzione intelligente dello spazio, la riduzione e l'allineamento su più righe.
Inoltre, è un layout comodo e adattabile che puoi utilizzare al posto di due componibili separati, come FlowRow e FlowColumn, perché è un singolo contenitore la cui direzione può cambiare dinamicamente all'interno del suo blocco di configurazione senza ricreare i componibili secondari.
Inoltre, implementa l'algoritmo CSS FlexBox completo, quindi hai wrap, justify-content, align-items, gap.
E su ogni elemento secondario, hai Modifier.flex che ti fornisce basis, fattori di crescita e riduzione relativi, alignSelf e così via.
Francesco Romano
Ok, ora passiamo dalla definizione a livello di schermo ai layout a livello di app, ovvero come strutturiamo la navigazione di primo livello e le disposizioni degli schermi con più riquadri nell'intera applicazione, non solo nella singola schermata.
Sebbene MediaQuery fornisca la larghezza e l'altezza esatte della finestra in dp, per l'architettura a livello di app, è necessario utilizzare i breakpoint della finestra di visualizzazione standardizzati e predefiniti, ovvero le classi di dimensioni della finestra.
In pratica, vuoi che ogni schermo sia reattivo all'interno di ciascuno di questi bucket, mentre puoi avere più modifiche, aggiornamenti e differenze strutturali tra i diversi bucket.
Ad esempio, mostrando solo un elenco su uno schermo compatto o sia l'elenco che i dettagli in una finestra espansa.
Puoi creare classi di dimensioni della finestra direttamente su UiMediaScope in modo molto pulito, ovvero definendo una proprietà di estensione su 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...
}Una cosa da notare è che utilizza derivedMediaQuery, non la classe MediaQuery standard.
Perché?
Perché la larghezza e l'altezza della finestra sono valori che cambiano di frequente.
Quando gli utenti ridimensionano l'app, ad esempio su un computer, questi valori possono cambiare a ogni frame.
Inoltre, probabilmente non vuoi ricomporre lo scaffold a livello di app in ogni frame, ma solo quando la finestra supera effettivamente un breakpoint.
E una volta ottenute le classi di dimensioni della finestra, come facciamo a passare da un flusso a riquadro singolo a layout a più riquadri quando abbiamo più spazio?
È qui che Jetpack Navigation 3 dà il meglio di sé, perché Nav 3 è stato progettato da zero attorno allo stato e alle scene di Compose.
Innanzitutto, il back stack è di tua proprietà ed è semplicemente un elenco di NavKey, quindi è molto flessibile ed è facile da salvare come stato.
Ogni chiave nello stack si risolve in un NavEntry contenente il relativo composable e i metadati.
Inoltre, NavDisplay non hardcoded il modo in cui vengono disegnate le voci, ma valuta un elenco di strategie di scena.
All'interno della strategia, puoi esaminare le funzionalità della UI, ad esempio le classi di dimensioni della finestra.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}Puoi ispezionare il back stack e poi decidere se eseguire il rendering di una NavEntry in un singolo riquadro, di più elementi contemporaneamente o di un elemento sopra l'altro.
È molto flessibile.
Oltre alla strategia multipannello, le classi di dimensioni della finestra determinano anche i componenti di navigazione da utilizzare per massimizzare l'ergonomia e l'utilizzo dello spazio sullo schermo.
Ad esempio, su larghezze compatte, vuoi una barra di navigazione in basso, su larghezze medie, una barra di navigazione, su larghezze espanse, un riquadro di navigazione a scomparsa completo.
Quando combini Nav3 per le decisioni di layout a livello di app e i componenti adattivi per la definizione a livello di schermo, ottieni app come questa, che si distinguono praticamente in ogni dimensione e ambiente della finestra.
Per Nav 3 in particolare, se vuoi saperne di più sugli ultimi aggiornamenti, incluso il supporto per i deep link, dai un'occhiata al talk Building Type-Safe Deep Links with Jetpack Navigation 3.
Francesco Romano
Ok, oggi abbiamo parlato di molte nuove API: MediaQuery, Grid, FlexBox, Nav3.
Questo è tutto su come Jetpack Compose ti aiuta a creare app adattive.
Tuttavia, nell'era dello sviluppo agentico, l'utilizzo di Jetpack Compose offre un altro vantaggio estremamente importante: è molto più efficace.
Probabilmente l'avrai sentito durante il keynote: abbiamo visto app che utilizzano oltre il 30% di token in meno dopo la migrazione a Compose e stiamo lavorando per ottimizzare ulteriormente l'Agentic Workflow per le app Compose.
Oggi voglio evidenziare gli aggiornamenti principali della skill Compose Adaptive.
android skills list
android skills add adaptiveCome ho detto, oltre a eseguire la migrazione dei layout a Grid, Flexbox e MediaQuery, ora gestisce esplicitamente la gestione dello stato end-to-end nell'app e testiamo la competenza rispetto a bug di perdita di stato reali: preservando la navigazione, la posizione di scorrimento, la riproduzione di contenuti multimediali attivi, gli input di testo digitati a metà e così via.
La skill ora include anche l'ottimizzazione della postura dei pieghevoli, come il refactoring automatico delle schermate dei contenuti multimediali e della fotocamera in modalità da tavolo, ma anche quando l'app viene eseguita su un Googlebook o su un monitor esterno con mouse e tastiera, il layout è solo una parte della storia.
La skill Adattabilità ora può controllare la tua UI per trovare e implementare scorciatoie da tastiera, cursori del puntatore personalizzati, stati di passaggio del mouse, tooltip e così via.
Vediamo come funziona.
Ho registrato una demo di questo.
Quindi abbiamo la nostra app.
Possiamo guardare i nostri animali domestici.
Possiamo scambiare messaggi con loro.
Possiamo guardare un video.
Sì, possiamo vedere gli animali domestici più popolari.
Ma vedi già che non è un granché.
Ora vediamo, voglio solo guardare un'immagine con un po' di drammaticità e dei gatti.
Ok, voglio vederle più grandi.
Dah, torna alla home page.
Ok, lo dico al mio amico.
Sì, quindi, "Amico", hm.
L'app non è un granché.
È anche troppo ampio.
Noti che è troppo largo.
Proviamo a ridimensionarla.
Cosa?
Eh, sì, ok.
Il testo che ho digitato è andato perso.
Ok, è il momento di chiedere a Gemini di risolvere il problema.
Quindi, grazie alla skill Adaptive, il prompt può essere semplice come "rendi l'app adattiva".
Come puoi vedere, sto utilizzando Gemini 3.0 Flash che, insieme alla skill, è in grado di farlo.
Ha acquisito la skill e ora sta iniziando a eseguire le modifiche.
Ti farà alcune domande, ad esempio: "Oh, MediaQuery è sperimentale.
Vuoi davvero usarlo?"
E continua così.
Ho dovuto accelerare per tagliare alcune parti, ma se vieni nell'area demo, puoi provare le skill in prima persona.
E questa è solo una delle skill che abbiamo rilasciato.
Ad esempio, abbiamo anche una nuova competenza che ti aiuterà a creare esperienze straordinarie con lo stilo.
Quindi, vieni nell'area per provarlo.
Quindi, è fatta.
Ha modificato 11 file.
Vediamolo all'opera.
Ok.
Grid sta già facendo il suo lavoro.
La navigazione è stata spostata sul lato.
Proviamo i filtri.
Penso che ora funzioneranno.
Sì, lo fanno.
Ok, inviamo un messaggio al mio amico per dirgli che l'app ora è fantastica.
Vediamo come appare con più spazio.
Sì, è stata eseguita la migrazione al layout elenco-dettagli.
Questa esecuzione ha richiesto circa 20 minuti e circa 200.000 token.
Ok.
Francesco Romano
Di solito inizio un discorso con metriche aziendali e casi di successo, ma per ADS volevo passare direttamente al codice.
Tuttavia, voglio dedicare qualche minuto a condividere il ritorno sull'investimento in termini di progettazione.
Oggi, la tua app per Android può potenzialmente essere eseguita su oltre 580 milioni di dispositivi con schermi di grandi dimensioni attivi e sappiamo anche che gli utenti di dispositivi pieghevoli e multi-dispositivo sono tra i segmenti più coinvolti su Android.
Diamo un'occhiata a due case study concreti che dimostrano cosa succede quando si distribuisce una build adattiva.
La prima è Gratitude, una famosa app per il benessere personale e il diario giornaliero.
Adottando design di schermi adattivi, Gratitude ha reso i tablet e i pieghevoli un canale di acquisizione in forte crescita.
Innanzitutto, hanno raddoppiato la velocità di acquisizione sugli schermi di grandi dimensioni.
Le installazioni giornaliere provenienti specificamente da tablet e pieghevoli sono aumentate del 100%.
Hanno ampliato la quota di schermi di grandi dimensioni della loro base utenti complessiva dal 7% al 10% delle installazioni totali.
La seconda è BoxBoxClub, un'app complementare per la Formula 1 e gli sport motoristici.Dopo aver aggiornato la build per i pieghevoli e gli schermi più grandi, la telemetria ha mostrato enormi guadagni: un aumento del 7, 6% degli utenti attivi complessivi e un aumento del 54% del tempo di coinvolgimento.
Ok.
Siamo alla fine del discorso e voglio darti tre concetti fondamentali.
La creazione di app adattive consente alla tua app di supportare più dispositivi e più utenti, garantendo al contempo la compatibilità futura con questo ecosistema in espansione.
Lo sviluppo Android è incentrato su Compose.
Tutti i nuovi strumenti, le librerie e le competenze sono creati per Compose.
Compose è ideale per i layout adattivi e per lo sviluppo agentico.
Le codebase sono più piccole e facili da comprendere, gli agenti utilizzano meno token quando gestiscono il codice di Compose.
Quindi, prova tutti i nuovi strumenti, le skill e le API e divertiti.
Grazie.
<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