Ingénieur senior des relations avec les développeurs
L'écosystème Android est vaste, et les utilisateurs passent facilement d'un téléphone à une tablette, un appareil pliable ou un ordinateur. Maintenant que nous sommes passés à Compose First, cette session explore comment concevoir l'architecture de votre application pour des fenêtres redimensionnables et divers formats, où les hypothèses de mise en page traditionnelles ne s'appliquent plus, ce qui entraîne des UI étirées et une perte d'état.
Découvrez comment les dernières fonctionnalités de Jetpack Compose, Navigation 3 et WindowManager peuvent vous aider à créer des mises en page dynamiques qui s'adaptent naturellement des écrans compacts aux écrans à double volet. Vous repartirez avec des stratégies pour accélérer votre workflow de développement à l'aide de Gemini et de nos compétences, comme la compétence adaptative, afin de refactoriser automatiquement les anciennes codebases pour les écrans plus grands.
MediaQuery suit de manière déclarative les fonctionnalités d'interface utilisateur actives, telles que la taille de la fenêtre et les postures de pliage. Il remplace l'assemblage de plusieurs gestionnaires de plates-formes d'ancienne génération dans l'état Compose.Grid et FlexBox fournissent des composants de mise en page adaptative. Grid permet des dispositions multidimensionnelles paramétrées dans un seul composable, tandis que FlexBox fournit une distribution et un habillage de l'espace unidimensionnels.Transcription
Francesco Romano
Bonjour,
Aujourd'hui, nous allons nous pencher sur l'aspect technique de l'adaptation de votre application à tous les écrans avec Jetpack Compose.
Pourquoi s'adapter à tous les écrans ?
En effet, l'écosystème Android s'est étendu à de nombreux facteurs de forme différents.
Votre APK peut s'exécuter sur les téléphones, les pliables, les tablettes, les Chromebooks et les Googlebooks, les téléviseurs, les systèmes d'infodivertissement automobile, les appareils XR, etc.
Tout d'abord, fournissez les solutions là où l'utilisateur se trouve.
Deuxièmement, la transition de la plate-forme Android vers les applications adaptatives n'est pas temporaire.
Nous ne reviendrons pas à un monde où seuls les téléphones existent. Les montres connectées sont là pour rester.
Voici une chronologie rapide.
En 2022, nous avons commencé à poser les bases.
Nous avons lancé Android 12L, la première version de Jetpack WindowManager, nous vous avons fourni des API telles que les classes de taille de fenêtre, et nous avons publié la première version des consignes relatives à la qualité des applications adaptatives.
Nous avons également mis à jour Google Play pour que les notes soient réparties par type d'appareil, et nous avons commencé à inclure les consignes relatives à la qualité des applications adaptatives dans les décisions de classement.
Dans Android 15, nous avons également intégré le fenêtrage du bureau dans AOSP et Compose Material 3 Adaptive est désormais stable.
À partir d'Android 16 (SDK 36), le système ignorera simplement les verrouillages d'orientation, les verrouillages de format et les indicateurs de redimensionnement sur les écrans plus grands.
Avec le lancement des écrans connectés, votre APK peut désormais s'exécuter dans pratiquement n'importe quelle taille de fenêtre.
Le SDK 36 vous a fourni un indicateur de fichier manifeste temporaire pour désactiver le forçage de l'orientation et du format.
Dans Android 17, cette option de désactivation n'est plus disponible.
La conception adaptative est donc une exigence de base de la plate-forme.
Au cours des dix dernières années, nous avons travaillé en étroite collaboration avec des équipes de l'écosystème et nous avons appris de première main pourquoi il peut être difficile de créer des applications adaptatives.
Vous avez une perte d'état, des API de fenêtre fragmentées, des signaux peu clairs sur le moment où changer de mise en page et d'anciennes hypothèses ancrées au plus profond de la codebase.
Nous avons donc pris tout ce que nous avons appris de cette décennie de défis pour les développeurs et nous l'avons intégré directement à deux endroits : d'abord, dans le kit d'outils d'interface utilisateur lui-même.
Aujourd'hui, nous allons explorer de nouvelles fonctionnalités majeures de Jetpack Compose : les requêtes média, les grilles, FlexBox et Navigation 3.
Mais aussi dans des outils agentiques qui écrivent le code avec vous grâce aux compétences Android officielles pour Gemini dans Android Studio et la CLI Android.
Par exemple, nous verrons les compétences adaptatives de Compose, qui ont été lancées lors de la conférence Google I/O plus tôt cette année.
Il était déjà capable de migrer les mises en page de téléphone vers des tailles de fenêtre plus grandes.
Désormais, il peut également corriger la perte d'état, implémenter des expériences uniques pour les pliables, comme le mode Écran à plat, et améliorer la compatibilité des entrées en découvrant et en implémentant des raccourcis clavier, des curseurs personnalisés, des info-bulles, etc.
Commençons par Jetpack Compose, qui est la base de tout cela.
Jetpack Compose est devenu stable en 2021, ce qui marque son cinquième anniversaire.
Au cours de ces cinq années, nous avons constaté, sur des milliers d'applications de production, qu'il est beaucoup plus facile et fiable de créer des applications adaptatives avec Compose.
Le développement Android est désormais officiellement Compose-first.
Cela signifie que toutes les nouvelles mises en page adaptatives, tous les styles et tous les composants de navigation modernes sont conçus pour Compose.
Du point de vue de l'architecture système, le plus grand avantage de Compose pour les applications adaptatives par rapport à l'ancien système de vues est la façon dont l'état et l'UI interagissent.
Pour l'expliquer, je souhaite faire une comparaison rapide avec le système de vues.
Rappelez-vous comment fonctionnait le système de vues.
Votre UI était une arborescence d'objets modifiables que vous avez gonflée une fois à partir de fichiers XML statiques.
Vous aviez différents qualificateurs de ressources. Commençons par le problème principal : la duplication entre différents qualificateurs de ressources.
Lorsque vous créez une UI adaptative en XML, vous n'avez pas de logique conditionnelle dans le fichier de mise en page.
À la place, vous dupliquez l'intégralité de la hiérarchie dans les dossiers de ressources, tels que layout, layout-sw600dp, layout-land, etc.
Examinons l'exemple EditText à droite.
<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>Il s'agit exactement du même composant logique qui gère la saisie de texte, et il est réparti dans différents fichiers XML.
Si vous mettez à jour un attribut tel que inputType ou maxLength dans la mise en page pour téléphone, rien dans le compilateur ne vous oblige à le mettre également à jour dans la mise en page pour tablette.
De plus, comme les composants sont dissociés dans différents fichiers, une dérive structurelle est presque garantie au fil du temps.
Cette duplication est également liée à un autre problème : la perte d'état lors des modifications de configuration.
N'oubliez pas que dans le système de vues, les composants d'interface utilisateur sont avec état.
Un EditText possède son propre tampon Editable interne, une position de curseur personnalisée, un état de sélection et d'autres états.
Ainsi, lorsqu'un utilisateur déplie l'appareil ou redimensionne la fenêtre au-delà d'un certain seuil, Android détruit l'intégralité de la hiérarchie des vues et gonfle les nouveaux fichiers XML à partir de zéro.
Pour enregistrer l'entrée utilisateur, le framework s'appuie sur onSaveInstanceState, qui empaquette l'état associé à l'ID de cette vue.
Mais cela crée trois modes de défaillance possibles.
Tout d'abord, si quelqu'un a accidentellement nommé l'ID text_input au lieu de text_msg dans l'autre fichier XML, la restauration de l'état supprime silencieusement le texte saisi par l'utilisateur sans aucun avertissement.
Deuxièmement, onRestoreInstanceState s'exécute après onViewCreated, ce qui peut souvent entrer en conflit avec les observateurs ViewModel et provoquer des boucles de rappel.
Troisièmement, si vous changez de type de widget entre les qualificatifs (par exemple, si vous voulez un spinner sur un écran compact et un RadioGroup sur un écran plus large), s'ils partagent le même ID, l'application plante au moment de l'exécution avec une ClassCastException.
Dans Jetpack Compose, en revanche, votre UI n'est pas un objet que vous créez une seule fois.
Votre UI est une fonction pure de l'état.
Cela change complètement la nature même d'un changement de configuration.
Qu'est-ce qu'un changement de configuration dans Compose ?
J'ai un indice.
Francesco Romano
Oui.
Dans Compose, un changement de configuration n'est qu'une recomposition parmi d'autres, et les recompositions se produisent en permanence.
Lorsque la fenêtre s'élargit, par exemple parce que l'utilisateur a déplié l'appareil, vos composables lisent la nouvelle taille, et ceux dont la sortie dépend de cette taille sont réexécutés.
C'est tout pour cet événement.
Voyons maintenant ce que signifie écrire des applications adaptatives modernes à l'aide des derniers outils et composants Compose.
Dans le monde du XML, vous deviez vous poser des questions telles que "Est-ce que je m'exécute sur un téléphone, une tablette ou un ordinateur ?", puis créer des branches d'UI en fonction de la catégorie d'appareil.
Mais il existe une raison fondamentale de ne pas le faire.
Même lorsqu'elle s'exécute sur le même appareil physique, le contexte de l'application et les fonctionnalités de l'UI peuvent changer de manière dynamique au moment de l'exécution.
Le message que je souhaite faire passer est que les décisions concernant l'UI doivent être guidées par les capacités actives actuelles : taille de la fenêtre, posture, certaines caractéristiques matérielles, etc.
Prenons un exemple classique : le fenêtrage du bureau sur un écran externe.
Si vous branchez votre téléphone Pixel sur un écran externe sur votre bureau, le même processus d'application qui s'exécutait sur un écran de 6 pouces en mode Portrait s'exécute désormais sur un écran de 32 pouces avec un clavier physique et une souris.
Par conséquent, vérifier le type d'appareil sur lequel l'application s'exécute ne garantit rien sur l'écran sur lequel elle est réellement affichée ni sur les modalités de saisie.
De plus, Android est compatible avec le mode multifenêtre, ce qui permet de redimensionner les fenêtres.
Même si l'écran d'un Googlebook est plus grand que celui d'un téléphone, votre application peut s'exécuter dans une fenêtre de 400 dp de large, aux côtés d'autres applications.
Par conséquent, interroger les métriques d'affichage physique statique peut également être inutile.
Pour concevoir des applications Compose-first, nous devons donc comprendre comment interroger l'environnement.
Je souhaite commencer par essayer de formaliser le concept de fonctionnalités d'UI.
Les fonctionnalités de l'UI incluent non seulement la largeur et la hauteur de la fenêtre ou la position de l'appareil pliable, mais aussi les modalités d'entrée et de sortie actuellement actives.
Nous avons constaté que les utilisateurs n'utilisent pas toujours l'écran tactile dans l'environnement de bureau.
Il peut utiliser un clavier et une souris.
Dans le système automobile, certaines voitures sont équipées d'un bouton rotatif ou d'une autre méthode de saisie, d'une autre façon de saisir des signaux.
En plus de la taille de l'écran et de la saisie, vous devez tenir compte de la distance entre l'utilisateur et l'écran (distance de visionnage) ou de la connexion d'une caméra ou d'un micro.
Voilà pour les fonctionnalités de l'interface utilisateur.
Francesco Romano
Pour récupérer tous les signaux, vous deviez assembler cinq ou six API différentes provenant de sources différentes.
Vous aviez besoin de l'objet de configuration, de WindowMetricsCalculator, de WindowInfoTracker, d'InputManager et de PackageManager, puis de convertir manuellement chacun d'eux en un état Compose.
Avez-vous aimé faire cela ?
Non.
Pour résoudre ce problème, nous avons introduit l'API MediaQuery dans androidx.compose.ui.
mediaQuery { ... }
derivedMediaQuery { ... }Il s'inspire des requêtes média CSS.
Et le mot "média" ne signifie pas la lecture audio ou vidéo.
Il fait référence au support, à l'environnement dans lequel votre UI est affichée.
MediaQuery vous offre un moyen déclaratif unique et natif de Compose pour interroger les fonctionnalités de l'interface utilisateur.
Examinons les propriétés exposées dans 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 et windowHeight en dp.
windowPosture, qui est actuellement destiné aux pliables et peut être "Flat" (plat), "Tabletop" (écran à plat) ou "Book" (livre).
pointerPrecision vous indique la précision du dispositif de pointage principal : "Fine" pour une souris, un stylet ou un pavé tactile ; "Coarse" pour un écran tactile ; "Blunt" pour un joystick ou une manette ; ou "None".
keyboardKind peut être physique, virtuel ou aucun.
hasCamera et hasMicrophone suivent de manière dynamique la disponibilité de ce matériel.
Pensez à une caméra externe qui peut être branchée.
Et viewingDistance indique "Proche", "Moyen" ou "Loin".
Mais je pense qu'il est plus facile de prendre un exemple concret, comme la détection du mode sur table.
Le mode sur table est une posture qui permet aux utilisateurs de plier l'appareil à moitié et de le poser sur une surface.
Il est idéal pour regarder des vidéos pendant longtemps, passer un appel vidéo ou prendre des photos en accéléré.
C'est aussi très utile pour suivre une recette ou regarder un diaporama.
Auparavant, si vous vouliez détecter la position "écran à plat" avant MediaQuery, vous deviez écrire tout ce code.
@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
}
}Vous deviez obtenir le contexte, obtenir une instance de WindowInfoTracker, collecter windowLayoutInfo, qui est un flux, un état Compose tenant compte du cycle de vie, puis itérer sur displayFeatures pour vérifier s'il en existe un qui est un FoldingFeature, qui est à moitié ouvert et qui est horizontal.
Et ce n'est que pour détecter la posture.
Cela représente beaucoup de code récurrent.
Avec la nouvelle API MediaQuery, tout cela se réduit à quelques lignes de code.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}La fonction mediaQuery utilise un seul paramètre lambda et, dans le champ d'application du récepteur de ce lambda, elle expose l'ensemble des propriétés que nous venons de voir.
Dans cet exemple, en vérifiant simplement windowPosture dans une expression "when", nous pouvons immédiatement renvoyer le bon composable.
Voici deux autres exemples d'utilisation de l'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")
}
}
}Le premier est la densité d'informations.
Ainsi, lorsqu'un utilisateur se sert d'un ordinateur portable ou d'un écran d'ordinateur de bureau avec une souris, et que la précision du pointeur est "Fine", nous pouvons réduire la marge intérieure de nos composants d'interface utilisateur de 8 dp à 2 dp (dans cet exemple) pour afficher plus de contenu confortablement. Comme les utilisateurs n'utilisent pas leurs doigts, les cibles tactiles peuvent être plus petites.
et des actions basées sur les capacités.
Imaginons que nous ayons une barre de saisie de chat. Nous pouvons vérifier hasCamera et hasMicrophone pour afficher ou masquer de manière dynamique le bouton d'action d'appel vidéo. Nous pouvons également combiner le type de clavier physique pour afficher ou non des conseils de raccourcis clavier, comme "Appuyez sur Ctrl+Entrée pour envoyer".
Elles sont toutes récupérées au moment de l'exécution.
MediaQuery est également idéal pour les tests et la génération d'aperçus.
@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()
}
}En gros, MediaQuery résout son récepteur via un composant local de composition appelé LocalUiMediaScope.
Vous pouvez donc simplement fournir un faux UiMediaScope via CompositionLocalProvider dans votre aperçu. Vous n'avez pas besoin de lancer des appareils pliables et de les placer à 90° ni de brancher une souris juste pour exécuter un test d'UI Compose.
Francesco Romano
Maintenant que nous savons comment interroger les fonctionnalités de l'UI,
Voyons à présent comment créer la mise en page.
Pour cela, je souhaite vous présenter Grid, une nouvelle primitive de mise en page dans androidx.compose.foundation.layout, qui est désormais stable.
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" vous permet d'implémenter une mise en page bidimensionnelle en affichant les éléments dans des colonnes et des lignes qui s'adaptent à la taille du conteneur disponible.
Voyons ce que vous pouvez faire dans le bloc de configuration.
Vous pouvez ajouter de nouveaux éléments ou pistes en appelant une colonne ou une ligne, et vous pouvez utiliser les boucles Kotlin standards comme "repeat".
Vous pouvez définir des gouttières entre les pistes.
Vous pouvez contrôler la direction du placement automatique via flow-- GridFlow.Row ou GridFlow.Column.
Voici un détail important de l'architecture.
Le bloc de configuration s'exécute pendant la phase de mesure, et le champ d'application de la configuration de la grille implémente la densité et expose les contraintes.
Cela signifie que vous pouvez inspecter constraints.maxWidth.toDp() directement dans la configuration pour calculer les pistes sans aucune passe de recomposition supplémentaire.
Dans Grid, lorsque vous ajoutez un enfant sans modificateur, il passe automatiquement à la cellule suivante.
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))
}Lorsque vous souhaitez un contrôle explicite, vous pouvez utiliser Modifier.gridItem, et vous disposez de coordonnées basées sur un.
Ainsi, la ligne 1, colonne 1 correspond à la cellule en haut à gauche.
Elle accepte l'indexation négative. Ainsi, la ligne -1 est la dernière ligne, et ainsi de suite.
Vous pouvez utiliser rowSpan pour étendre un élément sur une zone 2D, et il existe également un chevauchement.
Ainsi, si deux éléments sont placés dans des cellules qui se chevauchent, Grid les empile l'un sur l'autre dans ces limites, et l'ordre Z est déterminé par l'ordre de composition.
Les éléments suivants seront donc dessinés par-dessus les autres, comme pour Box.
Vous connaissez maintenant les bases de Grid.
Prenons l'exemple du lecteur vidéo.
Selon que l'appareil est à plat ou à moitié ouvert, nous devons basculer entre la mise en page par défaut ou la mise en page sur table.
Avant l'introduction de Grid, nous devions créer deux composables différents, DefaultLayout et TabletopLayout, puis créer une branche dans le code sur une expression "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)
}
}Vous vous demandez peut-être où est le problème.
Si vous regardez ces mises en page, leur contenu est identique.
Les deux affichent exactement le même lecteur et le même contrôleur.
La seule différence entre eux est la façon dont ces deux éléments sont disposés à l'écran.
Jusqu'à présent, comme nous n'avions aucun moyen de transmettre la disposition de la mise en page elle-même en tant que paramètre, nous étions obligés de définir deux composables distincts.
Nous pouvons maintenant paramétrer la configuration de la mise en page et tout gérer dans une seule fonction composable.
Nous allons voir comment.
Ainsi, avec l'API Grid, nous pouvons définir des zones nommées en tant qu'énumérations.
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()
}
}Ensuite, nous pouvons spécifier les composables enfants, le lecteur et le contrôleur, à placer dans la grille. Nous pouvons associer chaque enfant à une zone nommée en appelant Modifier.gridItem et en transmettant areaId.
Ici, nous indiquons à Grid de placer le lecteur dans la zone nommée VideoPlayerArea.Player et le contrôleur dans VideoPlayerArea.Controller.
Ici, nous définissons les deux lambdas de configuration dans 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)
}
}Pour la mise en page par défaut du lecteur vidéo, nous configurons une grille d'une ligne et d'une colonne. Notez que même s'il n'y a qu'une seule cellule, nous pouvons toujours attribuer deux noms de zone à la même cellule.
Dans ce cas, comme nous l'avons vu précédemment, le contrôleur est superposé au lecteur.
Pour l'écran à plat, nous définissons simplement deux lignes et une colonne, en plaçant le lecteur dans la moitié supérieure et le contrôleur dans la moitié inférieure, comme ceci.
Une fois ces lambdas de configuration définis, nous pouvons utiliser MediaQuery directement dans la configuration de la grille.
@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()
}
}
}Nous pouvons désormais disposer d'un seul composable de mise en page de lecteur vidéo avec l'UI qui bascule dynamiquement entre deux configurations.
La prochaine primitive dont je veux parler est 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) }
)
}
}
}Ainsi, tandis que Grid gère les mises en page 2D en lignes et en colonnes, FlexBox, qui est désormais stable, gère les mises en page unidimensionnelles qui nécessitent toujours une distribution intelligente de l'espace, une réduction et un retour à la ligne multiligne.
Il s'agit d'une mise en page responsive pratique que vous pouvez utiliser à la place de deux composables distincts, comme FlowRow et FlowColumn, car il s'agit d'un conteneur unique dont la direction peut changer de manière dynamique dans son bloc de configuration sans recréer de composables enfants.
Il implémente l'algorithme CSS FlexBox complet, ce qui vous permet d'utiliser wrap, justify-content, align-items et gap.
Et sur chaque enfant, vous avez Modifier.flex qui vous donne la base, les facteurs de croissance et de réduction relatifs, alignSelf, etc.
Francesco Romano
Maintenant, passons de la définition au niveau de l'écran aux mises en page au niveau de l'application, c'est-à-dire à la façon dont nous structurons la navigation de premier niveau et les dispositions d'écran à plusieurs volets dans l'ensemble de l'application, et pas seulement sur un seul écran.
MediaQuery vous donne la largeur et la hauteur exactes de la fenêtre en dp. Toutefois, pour l'architecture au niveau de l'application, vous avez besoin de points d'arrêt de fenêtre d'affichage standardisés et spécifiques, qui sont les classes de taille de fenêtre.
En gros, vous voulez que chaque écran soit responsive dans chacune de ces catégories, mais vous pouvez avoir plus de changements structurels, de mises à jour et de différences entre les différentes catégories.
Par exemple, en affichant uniquement une liste sur un écran compact ou à la fois la liste et les détails dans une fenêtre agrandie.
Vous pouvez créer des classes de taille de fenêtre directement sur UiMediaScope de manière très propre, en définissant une propriété d'extension sur 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...
}Notez qu'il utilise derivedMediaQuery, et non le MediaQuery standard.
Pourquoi ?
En effet, la largeur et la hauteur de la fenêtre sont des valeurs qui changent fréquemment.
Lorsque les utilisateurs redimensionnent votre application, par exemple sur un ordinateur, ces valeurs peuvent changer à chaque frame.
De plus, vous ne souhaitez probablement pas recomposer votre échafaudage au niveau de l'application à chaque frame, mais uniquement lorsque la fenêtre franchit un point d'arrêt.
Une fois que nous avons les classes de taille de fenêtre, comment faire passer notre application d'un flux à un seul volet à des mises en page à plusieurs volets lorsque nous disposons de plus d'espace ?
C'est là que Jetpack Navigation 3 se démarque, car Nav 3 a été conçu dès le départ autour de l'état et des scènes Compose.
Tout d'abord, vous êtes propriétaire de la pile "Retour", qui est simplement une liste de NavKeys. Elle est donc très flexible et facile à enregistrer en tant qu'état.
Chaque clé de la pile correspond à une NavEntry contenant son composable et ses métadonnées.
NavDisplay ne code pas en dur la façon dont les entrées sont dessinées. À la place, il évalue une liste de stratégies de scène.
Dans la stratégie, vous pouvez inspecter les fonctionnalités de l'UI, par exemple les classes de taille de fenêtre.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}Vous pouvez inspecter la pile "Retour", puis décider d'afficher une seule NavEntry dans un seul volet, plusieurs éléments simultanément ou l'un au-dessus de l'autre.
C'est un outil très flexible.
En plus de la stratégie multipan, les classes de taille de fenêtre déterminent également les composants de navigation que vous souhaitez utiliser pour optimiser l'ergonomie et l'utilisation de l'espace de l'écran.
Par exemple, sur les largeurs compactes, vous voulez une barre de navigation inférieure, sur les largeurs moyennes, un NavRail, et sur les largeurs étendues, un panneau de navigation complet.
En combinant Nav3 pour les décisions de mise en page au niveau de l'application et des composants responsifs pour la définition au niveau de l'écran, vous obtenez des applications qui ressemblent à ceci et qui s'adaptent à pratiquement toutes les tailles de fenêtre et tous les environnements.
Pour en savoir plus sur les dernières mises à jour de Nav 3, y compris la prise en charge des liens profonds, regardez la conférence "Créer des liens profonds avec sûreté du typage avec Jetpack Navigation 3".
Francesco Romano
Nous avons abordé de nombreuses nouvelles API aujourd'hui : MediaQuery, Grid, FlexBox et Nav3.
Voilà comment Jetpack Compose vous aide à créer des applications adaptatives.
Mais à l'ère du développement agentique, l'utilisation de Jetpack Compose présente un autre avantage extrêmement important : elle est beaucoup plus efficace.
Comme vous l'avez probablement entendu lors de la conférence, nous avons constaté que les applications utilisaient plus de 30 % de jetons en moins après leur migration vers Compose, et nous nous efforçons d'optimiser encore davantage votre workflow agentique pour les applications Compose.
Aujourd'hui, je souhaite mettre en avant les mises à jour importantes de la compétence "Compose adaptatif".
android skills list
android skills add adaptiveComme je l'ai dit, en plus de migrer les mises en page vers Grid, Flexbox et MediaQuery, il gère désormais explicitement la gestion de l'état de bout en bout dans l'application, et nous testons la compétence par rapport à des bugs de perte d'état réels : préservation de la navigation, de la position de défilement, de la lecture multimédia active, des entrées de texte à moitié saisies, etc.
La compétence inclut désormais l'optimisation de la position des appareils pliables, comme la refactorisation automatique des écrans multimédias et de l'appareil photo en mode sur table. Mais lorsque votre application s'exécute sur un Googlebook ou un écran externe avec une souris et un clavier, la mise en page n'est qu'une partie du problème.
La compétence adaptative peut désormais auditer votre UI pour trouver et implémenter des raccourcis clavier, des curseurs de pointeur personnalisés, des états de pointage, des info-bulles, etc.
Voyons cela en action.
J'ai enregistré une démonstration de cette fonctionnalité.
Nous avons donc notre application.
Nous pouvons regarder nos animaux de compagnie.
Nous pouvons leur envoyer des messages.
Nous pouvons regarder une vidéo d'eux.
Oui, nous pouvons voir les animaux de compagnie les plus populaires.
Mais vous voyez déjà que ce n'est pas l'idéal.
Voyons voir… Je veux juste voir une image avec un peu de drame et des chats.
D'accord, je veux les voir en plus grand.
Ah, ça me ramène à l'accueil.
D'accord, je vais en parler à mon ami.
Oui, "Buddy", hm.
L'application n'est pas terrible.
Il est également trop large.
Vous constatez qu'il est trop large.
Essayons de la redimensionner.
Quoi ?
Euh, oui, d'accord.
Et le texte que j'ai saisi est perdu.
Il est temps de demander à Gemini de corriger cela.
Grâce à la compétence Adaptive, le prompt peut être aussi simple que "rendre l'application adaptative".
Comme vous pouvez le voir, j'utilise Gemini 3.0 Flash qui, avec la compétence, est capable de le faire.
Elle a détecté la compétence et commence à appliquer les modifications.
Il posera des questions, comme "Oh, MediaQuery est expérimental.
Voulez-vous vraiment l'utiliser ?"
Et continuez.
J'ai dû accélérer la vidéo pour la raccourcir, mais si vous venez dans la zone de démonstration, vous pourrez tester les compétences vous-même.
Et ce n'est qu'une des compétences que nous avons lancées.
Par exemple, nous avons également une nouvelle compétence qui vous aidera à créer des expériences de stylet incroyables.
Venez donc dans la zone pour l'essayer.
C'est fait.
Il a modifié 11 fichiers.
Voyons cela en action.
OK.
Grid est déjà en train de faire son travail.
La navigation a été déplacée sur le côté.
Essayons les filtres.
Je pense qu'ils vont fonctionner maintenant.
Oui, c'est vrai.
Je vais envoyer un message à mon ami pour lui dire que l'application est géniale maintenant.
Voyons ce que cela donne avec plus d'espace.
Oui, elle a migré vers la mise en page liste/détails.
Cette exécution a pris environ 20 minutes et environ 200 000 jetons.
OK.
Francesco Romano
En général, je commence mes présentations par des métriques d'entreprise et des exemples de réussite, mais pour ADS, je voulais passer directement au code.
Cependant, je souhaite prendre quelques minutes pour vous présenter le retour sur investissement de l'ingénierie.
Aujourd'hui, votre application Android peut potentiellement s'exécuter sur plus de 580 millions d'appareils à grand écran actifs. Nous savons également que les utilisateurs d'appareils pliables et multi-appareils font partie des segments les plus engagés sur Android.
Examinons deux études de cas concrètes qui montrent ce qui se passe lorsque vous déployez une compilation adaptative.
Tout d'abord, Gratitude, une application populaire de soin de soi et de journalisation quotidienne.
En adoptant des conceptions d'écran adaptatives, Gratitude a fait des tablettes et des pliables un canal d'acquisition à forte croissance.
Tout d'abord, ils ont doublé la vitesse d'acquisition sur les grands écrans.
Les installations quotidiennes provenant spécifiquement de tablettes et d'appareils pliables ont augmenté de 100 %.
La part des grands écrans dans leur base d'utilisateurs globale est passée de 7 % à 10 % du nombre total d'installations.
La deuxième est BoxBoxClub, une application associée pour la Formule 1 et les sports mécaniques.Après avoir mis à jour leur build pour les pliables et les grands écrans, leur télémétrie a montré des gains considérables : une augmentation de 7, 6 % du nombre total d'utilisateurs actifs et une augmentation de 54 % du temps d'engagement.
OK.
Nous arrivons à la fin de cette présentation, et je souhaite vous laisser avec trois points importants à retenir.
En créant une application adaptative, vous pouvez la proposer à davantage d'appareils et d'utilisateurs, tout en la préparant pour l'avenir dans cet écosystème en pleine expansion.
Le développement Android est conçu pour Compose.
Tous les nouveaux outils, bibliothèques et compétences sont conçus pour Compose.
Compose est idéal pour les mises en page adaptatives et le développement agentique.
Les codebases sont plus petits et plus faciles à comprendre, et les agents utilisent moins de jetons lorsqu'ils traitent du code Compose.
Alors, n'hésitez pas à essayer tous les nouveaux outils, compétences et API, et amusez-vous !
Merci.
<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