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

  • Principes de base des mises en page adaptatives et de Navigation 3
  • Implémenter le glisser-déposer
  • Prise en charge des raccourcis clavier
  • 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 se charge complètement.

  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 > Sync Project with Gradle Files (Fichier > 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 et 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.

Pour résoudre ce problème, nous allons utiliser adaptive layouts, qui affiche un ou plusieurs volets en fonction de la taille de la fenêtre actuelle. 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 laquelle 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 des éléments NavEntry qui ont déjà été affichés ou qui sont actuellement affichés. Pour naviguer, ajoutez des clés à la pile ou supprimez-en de la pile.

Dans Socialite, le premier écran que nous souhaitons afficher lorsque l'utilisateur lance l'application est la liste de chat. Nous allons donc créer la pile "Retour" et l'initialiser 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 à créer l'infrastructure de navigation.

Tout d'abord, créez 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. C'est le 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 utilise une clé et renvoie un NavEntry, qui contient le contenu à afficher, ainsi que 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 (Paramètres). Pour résoudre ce problème, nous allons nous assurer que EntryProvider gère correctement les clés de la pile "Retour" pour Timeline et Settings.

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 manière dont plusieurs éléments NavEntry sont affichés côte à côte ou superposés.

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 le NavEntry de ChatList comme volet de liste et le NavEntry de ChatThread comme volet de détails. La stratégie pourra ainsi déterminer quand ces deux éléments NavEntry se trouvent dans la pile "Retour" et doivent ê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, 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 utilise 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 contenues dans ClipData sont un tableau d'objets Item. Étant donné que plusieurs éléments peuvent être déplacés en même temps, chaque Item représente l'un d'entre eux.

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. Libérer 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 DragAndDropTarget peut recevoir des éléments. C'est un bon endroit pour préparer l'état de l'interface utilisateur pour la session entrante.
  2. onEntered() : déclenché lorsqu'un élément déplacé entre dans les limites de ce DragAndDropTarget.
  3. onMoved() : appelé lorsque l'élément déplacé se déplace dans les limites de DragAndDropTarget.
  4. onExited() : appelé lorsque l'élément déplacé sort des limites de 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é à la fin de la session de glisser-déposer. Tout DragAndDropTarget ayant précédemment reçu un événement onStarted recevra ce hook. Utile pour réinitialiser l'état de l'interface utilisateur.

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.

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

Les événements de clavier dans Compose sont gérés avec des modificateurs.

Il en existe deux principaux :

  • onPreviewKeyEvent : intercepte l'événement de clavier avant qu'il ne soit traité 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é traité 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.

Nous pouvons déterminer lequel en vérifiant 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 informe Compose que notre code a traité l'événement de touche et empêche le comportement par défaut, comme 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.

Au cours de cette étape, nous allons ajouter un menu pop-up 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, tels que les clics droit, 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'interface utilisateur qui répondra à un clic droit. Dans notre cas, nous souhaitons afficher DropdownMenu avec un seul élément : un bouton Répondre. Nous aurons besoin de deux variables remember :

  • rightClickOffset stocke la position du clic afin que nous puissions déplacer le bouton Répondre à proximité 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 du geste 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 pur : 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é les éléments suivants :

  • 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