Applications adaptatives

1. Avant de commencer

Conditions préalables

  • Vous maîtrisez la création d'applis Android.
  • Vous maîtrisez Jetpack Compose.

Ce dont vous avez besoin

Points abordés

  • Mises en page adaptatives et principes de base de Navigation 3
  • Implémenter le glisser-déposer
  • Raccourcis clavier compatibles
  • Activer les menus contextuels

2. Configuration

Pour commencer, procédez comme suit :

  1. Lancez Android Studio.
  2. Cliquez sur File > New > Project from Version control (Fichier > Nouveau > Project from Version control).
  3. Collez l'URL :
https://github.com/android/socialite.git
  1. Cliquez sur Clone.

Attendez que le projet soit entièrement chargé.

  1. Ouvrez le terminal et exécutez :
$ git checkout codelab-adaptive-apps-start
  1. Exécuter une synchronisation Gradle

Dans Android Studio, sélectionnez File (Fichier) > Sync Project with Gradle Files (Synchroniser le projet avec les fichiers Gradle).

  1. (Facultatif) Télécharger l'émulateur pour les grands ordinateurs

Dans Android Studio, sélectionnez Tools > Device Manager > + > Create Virtual Device > New hardware profile (Outils > Gestionnaire d'appareils > + > Créer un appareil virtuel > Nouveau profil matériel).

Sélectionnez le type d'appareil : Ordinateur

Taille de l'écran : 14 pouces

Résolution : 1920 x 1080 px

Cliquez sur Finish (Terminer).

  1. Exécuter l'application sur une tablette ou un émulateur d'ordinateur de bureau

3. Comprendre l'application exemple

Dans ce tutoriel, vous travaillerez avec un exemple d'application de chat appelée Socialite, créée avec Jetpack Compose. e9e4541f0f76d669.png

Dans cette application, vous pouvez discuter avec différents animaux, qui répondent à vos messages chacun à leur manière.

Pour le moment, il s'agit d'une application destinée aux appareils mobiles qui n'est pas optimisée pour les grands appareils tels que les tablettes ou les ordinateurs.

Nous allons adapter l'application aux grands écrans et ajouter quelques fonctionnalités pour améliorer l'expérience sur tous les facteurs de forme.

Voyons cela plus en détail.

4. Mises en page adaptatives + Principes de base de Navigation 3

$ git checkout codelab-adaptive-apps-step-1

Actuellement, l'application n'affiche qu'un seul volet à la fois, quelle que soit la surface d'écran disponible.

Nous allons résoudre ce problème en utilisant adaptive layouts, qui affiche un ou plusieurs volets en fonction de la taille de la fenêtre. Dans cet atelier de programmation, nous allons utiliser des mises en page adaptatives pour afficher automatiquement les écrans chat list et chat detail côte à côte, lorsqu'il y a suffisamment d'espace sur la fenêtre.

c549fd9fa64589e9.gif

Les mises en page adaptatives sont conçues pour s'intégrer parfaitement à n'importe quelle application.

Dans ce tutoriel, nous allons nous concentrer sur leur utilisation avec la bibliothèque Navigation 3, sur laquelle l'application Socialite est compilée.

Pour comprendre Navigation 3, commençons par expliquer quelques termes :

  • NavEntry : contenu affiché dans une application vers lequel un utilisateur peut naviguer. Il est identifié de manière unique par une clé. Un élément NavEntry n'a pas besoin de remplir toute la fenêtre disponible pour l'application. Plusieurs NavEntry peuvent être affichés en même temps (nous y reviendrons plus tard).
  • Key : identifiant unique d'un NavEntry. Les clés sont stockées dans la pile "Retour".
  • Pile "Retour" : pile de clés représentant les éléments NavEntry qui ont déjà été affichés ou qui le sont actuellement. Pour naviguer, ajoutez des clés à la pile ou supprimez-en de la pile.

Dans Socialite, le premier écran que nous voulons afficher lorsque l'utilisateur lance l'application est la liste des discussions. Nous créons donc la pile "Retour" et l'initialisons avec la clé représentant cet écran.

Main.kt

// Create a new back stack
val backStack = rememberNavBackStack(ChatsList)

...

// Navigate to a particular chat
backStack.add(ChatThread(chatId = chatId))

...

// Navigate back
backStack.removeLastOrNull()

Nous allons implémenter Navigation 3 directement dans le composable du point d'entrée Main.

Annulez la mise en commentaire de l'appel de fonction MainNavigation pour associer la logique de navigation.

Commençons maintenant à développer l'infrastructure de navigation.

Commencez par créer la pile "Retour". Il s'agit de la pierre angulaire de Navigation 3.

Jusqu'à présent, nous avons abordé plusieurs concepts de Navigation 3. Mais comment la bibliothèque détermine-t-elle l'objet qui représente la pile "Retour" et la façon dont ses éléments sont transformés en UI ?

Découvrez NavDisplay. Il s'agit du composant qui rassemble tout et affiche la pile "Retour". Il nécessite quelques paramètres importants. Passons-les en revue un par un.

Paramètre 1 : pile "Retour"

NavDisplay a besoin d'accéder à la pile "Retour" pour afficher son contenu. Transmettons-le.

Paramètre 2 : EntryProvider

EntryProvider est un lambda qui transforme les clés de la pile "Retour" en contenu d'UI composable. Il prend une clé et renvoie un NavEntry, qui contient le contenu à afficher et des métadonnées sur la manière de l'afficher (nous y reviendrons plus tard).

NavDisplay appelle ce lambda chaque fois qu'il doit obtenir du contenu pour une clé donnée, par exemple lorsqu'une nouvelle clé est ajoutée à la pile "Retour".

Actuellement, si vous cliquez sur l'icône Timeline dans Socialite, le message "Unknown back stack key: Timeline" (Clé de pile "Retour" inconnue : Timeline) s'affiche.

532134900a30c9c.gif

En effet, même si la clé Timeline est ajoutée à la pile "Retour", EntryProvider ne sait pas comment l'afficher et utilise donc l'implémentation par défaut. La même chose se produit lorsque nous cliquons sur l'icône Settings. Corrigeons cela en nous assurant que EntryProvider gère correctement les clés Timeline et Settings de la pile "Retour".

Paramètre 3 : SceneStrategy

Le prochain paramètre important de NavDisplay est SceneStrategy. Ce paramètre est utilisé lorsque nous souhaitons afficher plusieurs éléments NavEntry en même temps. Chaque stratégie définit la façon dont plusieurs éléments NavEntry sont affichés côte à côte ou superposés les uns sur les autres.

Par exemple, si nous utilisons l'élément DialogSceneStrategy et que nous marquons certains NavEntry avec des métadonnées spéciales, il s'affichera sous la forme d'une boîte de dialogue au-dessus du contenu actuel au lieu de s'afficher en plein écran.

Dans notre cas, nous allons utiliser un autre élément SceneStrategy : ListDetailSceneStrategy. Il est conçu pour la mise en page standard liste/détails.

Commençons par l'ajouter dans le constructeur NavDisplay.

sceneStrategy = rememberListDetailSceneStrategy(),

Nous devons maintenant marquer la ChatList NavEntry comme un volet de liste et le ChatThread NavEntry comme un volet de détails. La stratégie pourra ainsi déterminer quand ces deux éléments NavEntry se trouveront dans la pile "Retour" et devront être affichés côte à côte.

À l'étape suivante, marquez NavEntry de ChatsList comme volet de liste.

entryProvider = { backStackKey ->
   when (backStackKey) {
      is ChatsList -> NavEntry(
         key = backStackKey,
         metadata = ListDetailSceneStrategy.listPane(),
      ) {
         ...
      }
      ...
   }
}

De même, marquez NavEntry de ChatThread comme volet de détails.

entryProvider = { backStackKey ->
   when (backStackKey) {
      is ChatThread -> NavEntry(
         key = backStackKey,
         metadata = ListDetailSceneStrategy.detailPane(),
      ) {
         ...
      }
      ...
   }
}

Nous avons ainsi intégré des mises en page adaptatives à notre application.

5. Glisser-déposer

$ git checkout codelab-adaptive-apps-step-2

Au cours de cette étape, nous allons ajouter la prise en charge du glisser-déposer, ce qui permettra aux utilisateurs de faire glisser des images depuis l'application Files vers Socialite.

78fe1bb6689c9b93.gif

Notre objectif est d'activer le glisser-déposer dans la zone message list, qui est définie par le composable MessageList, situé dans le fichier ChatScreen.kt.

Dans Jetpack Compose, la prise en charge du glisser-déposer est implémentée par le modificateur dragAndDropTarget. Nous l'appliquons aux composables qui doivent accepter les éléments déposés.

Modifier.dragAndDropTarget(
   shouldStartDragAndDrop = { event ->
       // condition to accept dragged item
   },
   target = // DragAndDropTarget
)

Le modificateur comporte deux paramètres.

  • Le premier, shouldStartDragAndDrop, permet au composable de filtrer les événements de glisser-déposer. Dans notre cas, nous ne voulons accepter que les images et ignorer tous les autres types de données.
  • Le second, target, est un rappel qui définit la logique de gestion des événements de glisser-déposer acceptés.

Commençons par ajouter dragAndDropTarget au composable MessageList.

.dragAndDropTarget(
   shouldStartDragAndDrop = { event ->
       event.mimeTypes().any { it.startsWith("image/") }
   },
   target = remember {
       object : DragAndDropTarget {
           override fun onDrop(event: DragAndDropEvent): Boolean {
               TODO("Not yet implemented")
           }
       }
   }
),

L'objet de rappel target doit implémenter la méthode onDrop(), qui accepte un DragAndDropEvent comme argument.

Cette méthode est appelée lorsque l'utilisateur dépose un élément sur le composable. Elle renvoie true si l'élément a été traité et false s'il a été refusé.

Chaque DragAndDropEvent contient un objet ClipData, qui encapsule les données qui sont en train d'être déplacées.

Les données dans ClipData correspondent à un tableau composé d'objets Item. Étant donné que plusieurs éléments peuvent être déplacés à la fois, chaque Item en représente un.

target = remember {
   object : DragAndDropTarget {
       override fun onDrop(event: DragAndDropEvent): Boolean {
           val clipData = event.toAndroidDragEvent().clipData
           if (clipData != null && clipData.itemCount > 0) {
               repeat(clipData.itemCount) { i ->
                   val item = clipData.getItemAt(i)
                   // TODO: Implement Item handling
               }
               return true
           }
           return false
       }
   }
}

Un Item peut contenir des données sous la forme d'un URI, de texte ou d'Intent.

Dans notre cas, comme nous n'acceptons que les images, nous recherchons spécifiquement un URI.

Si un Item en contient un, nous devons :

  1. Demander l'autorisation de glisser-déposer pour accéder à l'URI.
  2. Gérer l'URI (dans notre cas, en appelant la fonction onMediaItemAttached() déjà implémentée).
  3. Supprimer l'autorisation.
override fun onDrop(event: DragAndDropEvent): Boolean {
   val clipData = event.toAndroidDragEvent().clipData
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
       && clipData != null && clipData.itemCount > 0) {
       repeat(clipData.itemCount) { i ->
           val item = clipData.getItemAt(i)
           val passedUri = item.uri?.toString()
           if (!passedUri.isNullOrEmpty()) {
               val dropPermission = activity
                   .requestDragAndDropPermissions(
                       event.toAndroidDragEvent()
                   )
               try {
                   val mimeType = context.contentResolver
                       .getType(passedUri.toUri()) ?: ""
                   onMediaItemAttached(MediaItem(passedUri, mimeType))
               } finally {
                   dropPermission.release()
               }
           }
       }
       return true
   }
   return false
}

À ce stade, le glisser-déposer est entièrement implémenté. Vous pouvez donc faire glisser des photos depuis l'application Files vers Socialite.

Améliorons son apparence en ajoutant une bordure visuelle pour indiquer que la zone peut accepter des éléments déposés.

Pour ce faire, nous pouvons utiliser des hooks supplémentaires qui correspondent aux différentes étapes de la session de glisser-déposer :

  1. onStarted() : appelé lorsqu'une session de glisser-déposer commence et que ce DragAndDropTarget peut recevoir des éléments. C'est le bon moment pour préparer l'état de l'UI pour la session à venir.
  2. onEntered() : déclenché lorsqu'un élément déplacé entre dans les limites de ce DragAndDropTarget.
  3. onMoved() : appelé lorsque l'élément glissé se déplace dans les limites de cette DragAndDropTarget.
  4. onExited() : appelé lorsque l'élément glissé sort des limites de ce DragAndDropTarget.
  5. onChanged() : appelé lorsque quelque chose change dans la session de glisser-déposer, dans les limites de cette cible (par exemple, si une touche de modification est enfoncée ou relâchée).
  6. onEnded() : appelé lorsque la session de glisser-déposer se termine. Tout DragAndDropTarget ayant précédemment reçu un événement onStarted recevra ce hook. Utile pour réinitialiser l'état de l'UI.

Pour ajouter la bordure visuelle, nous devons procéder comme suit :

  1. Créer une variable booléenne mémorisée qui est définie sur true au début d'une opération de glisser-déposer, puis rétablie sur false lorsqu'elle se termine.
  2. Appliquer un modificateur au composable MessageList qui affiche une bordure lorsque cette variable est true.
override fun onEntered(event: DragAndDropEvent) {
   super.onEntered(event)
   isDraggedOver = true
}

override fun onEnded(event: DragAndDropEvent) {
   super.onExited(event)
   isDraggedOver = false
}

6. Raccourcis clavier

$ git checkout codelab-adaptive-apps-step-3

Lorsqu'ils utilisent une application de chat sur ordinateur, les utilisateurs s'attendent à retrouver des raccourcis clavier qui leurs sont familiers, par exemple, envoyer un message avec la touche Entrée.

Lors de cette étape, nous allons ajouter ce comportement à notre application.

Dans Compose, les événements de clavier sont gérés avec des modificateurs.

Il en existe deux principaux :

  • onPreviewKeyEvent intercepte l'événement de clavier avant qu'il ne soit géré par l'élément sélectionné. Lors de l'implémentation, nous décidons de propager l'événement ou de l'utiliser.
  • onKeyEvent intercepte l'événement de clavier après qu'il a été géré par l'élément sélectionné. Il ne se déclenche que si les autres gestionnaires n'ont pas utilisé l'événement.

Dans notre cas, l'utilisation de onKeyEvent sur un TextField ne fonctionnerait pas, car le gestionnaire par défaut utilise l'événement de touche Entrée et déplace le curseur vers la nouvelle ligne.

.onPreviewKeyEvent { keyEvent ->
   //TODO: implement key event handling
},

Le lambda dans le modificateur sera appelé deux fois pour chaque frappe : une fois lorsque l'utilisateur appuie sur la touche et une fois lorsqu'il la relâche.

Pour le déterminer, nous pouvons vérifier la propriété type de l'objet KeyEvent. L'objet d'événement expose également des indicateurs de modification, y compris :

  • isAltPressed
  • isCtrlPressed
  • isMetaPressed
  • isShiftPressed

Le renvoi de true à partir du lambda indique à Compose que notre code a géré l'événement de touche et empêche le comportement par défaut, tel que l'insertion d'une nouvelle ligne.

Implémentez maintenant le modificateur onPreviewKeyEvent. Vérifiez que l'événement correspond à la touche Entrée et qu'aucun des modificateurs Maj, Alt, Ctrl ou Meta n'est appliqué. Appelez ensuite la fonction onSendClick().

.onPreviewKeyEvent { keyEvent ->
   if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown
       && keyEvent.isShiftPressed == false
       && keyEvent.isAltPressed == false
       && keyEvent.isCtrlPressed == false
       && keyEvent.isMetaPressed == false) {
       onSendClick()
       true
   } else {
       false
   }
},

7. Menus contextuels

$ git checkout codelab-adaptive-apps-step-4

Les menus contextuels sont des éléments importants d'une interface utilisateur adaptative.

Lors de cette étape, nous allons ajouter un menu pop-up Reply (Répondre) qui s'affiche lorsque l'utilisateur effectue un clic droit sur un message.

d9d30ae7e0230422.gif

De nombreux gestes sont déjà compatibles. Par exemple, le modificateur clickable permet de détecter facilement un clic.

Pour les gestes personnalisés, comme les clics droits, nous pouvons utiliser le modificateur pointerInput, qui nous donne accès aux événements de pointeur bruts et un contrôle total sur la détection des gestes.

Commençons par ajouter l'UI qui répondra à un clic droit. Dans notre cas, nous voulons afficher DropdownMenu avec un seul élément : un bouton Reply (Répondre). Nous aurons besoin de deux variables remember :

  • rightClickOffset stocke la position du clic afin que nous puissions déplacer le bouton Relpy (Répondre) près du curseur.
  • isMenuVisible contrôle l'affichage ou le masquage du bouton Répondre

Leurs valeurs seront mises à jour dans le cadre de la gestion des gestes de clic droit.

Nous devons également encapsuler le composable de message dans un Box afin que le DropdownMenu puisse apparaître par-dessus.

@Composable
internal fun MessageBubble(
   ...
) {
   var rightClickOffset by remember { mutableStateOf<DpOffset>(DpOffset.Zero) }
   var isMenuVisible by remember { mutableStateOf(false) }
   val density = LocalDensity.current

   Box(
       modifier = Modifier
           .pointerInput(Unit) {
               // TODO: Implement right click handling
           }
           .then(modifier),
   ) {
       AnimatedVisibility(isMenuVisible) {
           DropdownMenu(
               expanded = true,
               onDismissRequest = { isMenuVisible = false },
               offset = rightClickOffset,
           ) {
               DropdownMenuItem(
                   text = { Text("Reply") },
                   onClick = {
                       // Custom Reply functionality
                   },
               )
           }
       }
       MessageBubbleSurface(
           ...
       ) {
           ...
       }
   }
}

Implémentons maintenant le modificateur pointerInput. Tout d'abord, nous ajoutons awaitEachGesture, qui lance un nouveau champ d'application chaque fois que l'utilisateur commence un nouveau geste. Dans ce champ d'application, nous devons :

  1. Obtenir l'événement de pointeur qui suit : awaitPointerEvent() fournit un objet représentant l'événement de pointeur.
  2. Filtrer pour un clic droit précis : nous vérifions que seul le bouton secondaire est enfoncé.
  3. Capturer la position du clic : prenez la position en pixels et convertissez-la en DpOffset pour que le placement du menu soit indépendant du nombre de points par pouce
  4. Afficher le menu : définissez isMenuVisible sur true et conservez le décalage afin que DropdownMenu s'affiche exactement à l'emplacement du pointeur.
  5. Consommer l'événement : appelez consume() en appuyant et en relâchant les touches correspondantes, ce qui empêche les autres gestionnaires de réagir.
.pointerInput(Unit) {
   awaitEachGesture { // Start listening for pointer gestures
       val event = awaitPointerEvent()

       if (
           event.type == PointerEventType.Press
           && !event.buttons.isPrimaryPressed
           && event.buttons.isSecondaryPressed
           && !event.buttons.isTertiaryPressed
           // all pointer inputs just went down
           && event.changes.fastAll { it.changedToDown() }
       ) {
           // Get the pressed pointer info
           val press = event.changes.find { it.pressed }
           if (press != null) {
               // Convert raw press coordinates (px) to dp for positioning the menu
               rightClickOffset = with(density) {
                   isMenuVisible = true // Show the context menu
                   DpOffset(
                       press.position.x.toDp(),
                       press.position.y.toDp()
                   )
               }
           }
           // Consume the press event so it doesn't propagate further
           event.changes.forEach {
               it.consume()
           }
           // Wait for the release and consume it as well
           waitForUpOrCancellation()?.consume()
       }
   }
}

8. Félicitations

Félicitations ! Vous avez migré l'application vers Navigation 3 et ajouté ce qui suit :

  • Mises en page adaptatives
  • Glisser-déposer
  • Raccourcis clavier
  • Menu contextuel

Vous disposez d'une base solide pour créer une application entièrement adaptative.

En savoir plus