Compatibilité avec les appareils pliables et double écran grâce à Jetpack WindowManager

Cet atelier de programmation pratique vous expliquera les bases du développement pour les deux types d'appareils. Lorsque vous aurez terminé, vous pourrez optimiser votre application pour qu'elle accepte des appareils tels que le Microsoft Surface Duo ou le Samsung Galaxy ZFold 2.

Conditions préalables

Voici les conditions à réunir pour effectuer cet atelier de programmation :

  • Expérience dans la création d'applis Android
  • Expérience des concepts suivants : Activités, Fragments, ViewBinding et xml-layouts
  • Expérience de l'ajout de dépendances à vos projets
  • Expérience dans l'installation et l'utilisation des émulateurs d'appareils Pour cet atelier de programmation, vous utiliserez un émulateur d'appareil pliable et/ou double écran.

Objectifs de l'atelier

  • Créer une application simple, puis l'optimiser pour assurer la compatibilité avec les appareils pliables et double écran.
  • Utiliser Jetpack WindowManager pour travailler sur de nouveaux facteurs de forme d'appareil.

Prérequis

  • Android Studio version 4.2 ou ultérieure
  • Un appareil pliable ou un émulateur de ce type d'appareils. Si vous utilisez Android Studio 4.2, différents émulateurs pliables peuvent être utilisés, comme illustré ci-dessous :

7a0db14df3576a82.png

  • Si vous souhaitez utiliser un émulateur double écran, vous pouvez télécharger l'émulateur Microsoft Surface Duo pour votre plate-forme (Windows, MacOS ou GNU/Linux) sur cette page.

Les appareils pliables offrent aux utilisateurs un écran plus grand et une interface utilisateur plus flexible que sur un appareil mobile traditionnel. Autre avantage : lorsqu'ils sont pliés, ces appareils sont souvent plus petits qu'une tablette de taille standard, ce qui les rend plus portables et pratiques.

Au moment de la rédaction de ce document, il existe deux types d'appareils pliables :

  • Appareils pliables à écran unique, dont l'écran peut être plié. Les utilisateurs peuvent exécuter plusieurs applications à la fois sur le même écran à l'aide du mode Multi-Window.
  • Appareils pliables à deux écrans, avec deux écrans joints par une charnière. Ces appareils peuvent également être pliés, mais leur affichage logique s'effectue sur deux régions distinctes.

affbd6daf04cfe7b.png

Comme les tablettes et les autres appareils mobiles à écran unique, les pliables disposent des fonctionnalités suivantes :

  • Exécuter une appli dans l'une des régions d'affichage.
  • Exécuter deux applis côte à côte, chacune dans une région d'affichage différente (en mode Multi-Window).

Contrairement aux appareils à écran unique, les appareils pliables acceptent également différentes positions. Ces positions permettent d'afficher des contenus de différentes façons.

f2287b68f32b59e3.png

Les appareils pliables peuvent proposer différentes positions ouvertes lorsqu'une application utilise l'ensemble de la région d'affichage (en exploitant toutes les régions d'affichage sur les appareils pliables à double écran).

Les appareils pliables peuvent également adopter des positions pliées, comme le mode sur table, où une division logique est faite entre la partie à plat et celle qui fait face à l'utilisateur, ou le mode tente, qui permet de visualiser des contenus comme si l'appareil reposait sur un support.

La bibliothèque Jetpack WindowManager a été conçue pour aider les développeurs à modifier leurs applis afin d'exploiter la nouvelle expérience utilisateur offerte par ces appareils. Jetpack WindowManager permet aux développeurs d'applications d'assurer une compatibilité avec de nouveaux facteurs de forme d'appareils et de bénéficier d'une surface d'API commune à différentes fonctionnalités de WindowManager, qu'il s'agisse de l'ancienne ou de la nouvelle version de la plate-forme.

Caractéristiques principales

La version 1.0.0-alpha03 de Jetpack WindowManager contient la classe FoldingFeature, qui décrit le pli d'un écran flexible ou la charnière entre deux écrans physiquement distincts. Son API permet d'accéder à des informations importantes concernant l'appareil :

Grâce à la classe principale WindowManager, vous pouvez accéder à des informations importantes, telles que :

  • getCurrentWindowMetrics() : renvoie la valeur WindowMetrics en fonction de l'état actuel du système. La valeur de ce paramètre est basée sur l'état actuel du fenêtrage du système.
  • getMaximumWindowMetrics() : renvoie la valeur maximale de WindowMetrics en se basant sur l'état actuel du système. Cette valeur est basée sur l'état potentiel de fenêtrage le plus élevé pour le système. Par exemple, pour les activités en mode multifenêtre, les statistiques renvoyées sont basées sur les limites correspondant à un développement de la fenêtre couvrant l'intégralité de l'écran.

Clonez le dépôt GitHub ou téléchargez l'exemple de code de l'application que vous allez modifier :

git clone https://github.com/googlecodelabs/android-foldable-codelab

Déclarer des dépendances

Pour utiliser Jetpack WindowManager, vous devez y ajouter la dépendance.

  1. Commencez par ajouter le dépôt Google Maven à votre projet.
  2. Ajoutez la dépendance pour l'artefact dans le fichier build.gradle pour votre application ou votre module :
dependencies {
    implementation "androidx.window:window:1.0.0-alpha03"
}

Utiliser WindowManager

Vous pouvez utiliser Jetpack WindowManager très facilement en enregistrant votre application afin de détecter des modifications de configuration.

Pour commencer, initialisez l'instance WindowManager afin de pouvoir accéder à son API. Pour initialiser l'instance WindowManager, implémentez le code suivant dans votre activité :

private lateinit var wm: WindowManager

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        wm = WindowManager(this)
}

Le constructeur principal autorise uniquement un paramètre : un contexte visuel, tel que Activity ou ContextWrapper pour une activité donnée. En arrière-plan, ce constructeur utilise l'élément par défaut WindowBackend. Il s'agit d'une classe de serveur backend qui fournira des informations pour cette instance.

Une fois que vous disposez de votre instance WindowManager, vous pouvez enregistrer un rappel afin d'être informé des changements de position, du format d'écran de l'appareil et des dimensions de ce format (le cas échéant). Comme indiqué précédemment, les valeurs actuelles et maximales de ces paramètres s'affichent en se basant sur l'état actuel du système.

  1. Ouvrez Android Studio.
  2. Cliquez sur File > New > New Project > Empty Activity (Fichier > Nouveau > Nouveau projet > Activité vide) pour créer un projet.
  3. Cliquez sur Next (Suivant), acceptez les propriétés et valeurs par défaut, puis cliquez sur Finish (Terminer).

Maintenant, créez une mise en page simple pour afficher les informations issues de WindowManager. Pour cela, vous devez créer le dossier de mise en page et le fichier de mise en page spécifique :

  1. Cliquez sur File > New > Android resource directory (Fichier > Nouveau > Répertoire des ressources Android).
  2. Dans la fenêtre qui s'affiche, sélectionnez une mise en page Resource Type (Type de ressource), puis cliquez sur OK.
  3. Accédez à la structure du projet et, dans src/main/res/layout, créez un fichier de ressources de mise en page (File > New > Layout resource file [Fichier > Nouveau > Fichier de ressources de mise en page]) appelé activity_main.xml.
  4. Ouvrez le fichier, puis ajoutez le contenu suivant en tant que mise en page :

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/constraint_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/window_metrics"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/window_metrics"
       android:textSize="20sp"
       app:layout_constraintBottom_toTopOf="@+id/layout_change"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       app:layout_constraintVertical_chainStyle="packed" />

   <TextView
       android:id="@+id/layout_change"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/layout_change_text"
       android:textSize="20sp"
       app:layout_constrainedWidth="true"
       app:layout_constraintBottom_toTopOf="@+id/configuration_changed"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/window_metrics" />

   <TextView
       android:id="@+id/configuration_changed"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:padding="20dp"
       android:text="@string/configuration_changed"
       android:textSize="20sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/layout_change" />

</androidx.constraintlayout.widget.ConstraintLayout>

Vous venez de créer une mise en page simple basée sur un élément ConstraintLayout contenant trois composantes d'interface TextViews. Ces affichages sont délimités par ces éléments afin d'être centrés sur le parent (et dans l'écran).

  1. Ouvrez le fichier MainActivity.kt, puis ajoutez le code suivant :

window_manager/MainActivity.kt

class MainActivity : AppCompatActivity() {
  1. Créez une classe interne qui vous aidera à gérer le résultat à partir des rappels :
inner class LayoutStateChangeCallback : Consumer<WindowLayoutInfo> {
   override fun accept(newLayoutInfo: WindowLayoutInfo) {
       printLayoutStateChange(newLayoutInfo)
   }
}

Les fonctions utilisées par les classes internes sont des fonctions simples qui afficheront les informations obtenues auprès de WindowManager à l'aide des composants de l'interface utilisateur (TextView) :

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}
  1. Déclarez une variable WindowManager lateinit :
private lateinit var wm: WindowManager
  1. Créez une variable qui gérera les rappels à l'aide de WindowManager via les classes internes que vous avez déjà créées :
private val layoutStateChangeCallback = LayoutStateChangeCallback()
  1. Ajoutez une liaison pour nous permettre d'accéder aux différents affichages :
private lateinit var binding: ActivityMainBinding
  1. Créez maintenant une fonction basée sur Executor afin de la fournir au rappel en tant que premier paramètre. Cette fonction sera utilisée lors de l'activation du rappel. Dans ce cas, vous allez en créer une qui s'exécute sur le thread UI. Si vous le souhaitez, vous pouvez également en créer une autre qui ne s'exécute pas dans le thread UI.
private fun runOnUiThreadExecutor(): Executor {
   val handler = Handler(Looper.getMainLooper())
   return Executor() {
       handler.post(it)
   }
}
  1. Dans la fonction onCreate de l'activité MainActivity, initialisez WindowManager lateinit :
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   wm = WindowManager(this)
}

L'élément Activity est désormais le seul paramètre de l'instance WindowManager. Il utilise l'implémentation par défaut du backend WindowManager.

  1. Recherchez la fonction que vous avez ajoutée à l'étape 5. Ajoutez, juste après l'en-tête de la fonction, cette ligne :
binding.windowMetrics.text =
   "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
       "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

Ici, vous définissez la valeur de l'élément TextView window_metrics en utilisant les valeurs que les fonctions currentWindowMetrics.bounds.flattenToString() et maximumWindowMetrics.bounds.flattenToString() contiennent.

Ces valeurs fournissent des informations utiles sur les coordonnées de la zone occupée par la fenêtre. Comme le montre l'illustration ci-dessous, dans un émulateur double écran, vous obtenez les valeurs CurrentWindowMetrics qui correspondent aux dimensions de l'appareil dont il simule le fonctionnement. Vous pouvez également consulter ces chiffres lorsque l'application s'exécute sur un seul écran.

b032c729d6dce292.png

L'illustration ci-dessous montre comment ces chiffres changent quand l'application s'affiche sur plusieurs écrans. Ainsi, la taille de la fenêtre de l'application est désormais plus grande :

b72ca8a63b65e4c1.png

Les dimensions de fenêtres actuelles et maximales ont toutes les mêmes valeurs, car l'application est toujours en cours d'exécution et occupe la totalité de la zone d'affichage disponible, que ce soit sur un seul écran ou en mode double écran.

Dans un émulateur d'appareil pliable où le pli s'effectue sur un axe horizontal, les valeurs ne sont pas les mêmes lorsque l'application est exécutée sur l'ensemble de l'affichage physique ou en mode multifenêtre :

5cb5270ee0e42320.png

Comme vous pouvez le voir sur l'image de gauche, les valeurs sont identiques dans les deux cas, car l'application en cours d'exécution utilise l'ensemble de la zone d'affichage, c'est-à-dire la zone actuelle et le maximum disponible.

Cependant, dans l'image de droite, avec l'application exécutée en mode multifenêtre, les chiffres affichés indiquent les dimensions de la zone du mode multifenêtre (en haut) où s'exécute l'application, tandis que les valeurs maximales indiquent la zone d'affichage maximale de l'appareil.

Les chiffres fournis par WindowManager sont très utiles pour identifier la zone de la fenêtre utilisée ou pouvant être utilisée par l'application.

Vous pouvez désormais enregistrer les modifications de mise en page qui vous permettront d'identifier le type d'appareil (à charnière ou pliable) les dimensions associées.

La fonction à utiliser a la signature suivante :

public void registerLayoutChangeCallback (
                Executor executor,
                Consumer<WindowLayoutInfo> callback)

Cette fonction utilise le type WindowLayoutInfo. Cette classe dispose des données que vous devez examiner lorsque le rappel est activé. Cette classe contient en interne l'élément List< DisplayFeature>. Cette liste renvoie une liste d'éléments DisplayFeature propres à l'appareil qui présentent une intersection avec l'application. La liste peut être vide si le format d'écran ne présente d'intersection avec l'application.

Cette classe implémente DisplayFeature et, une fois que List<DisplayFeature> apparaît, vous pouvez envoyer les éléments dans FoldingFeature, où vous obtiendrez des informations telles que la position de l'appareil, le format d'écran de l'appareil et ses dimensions.

Voyons comment vous pouvez utiliser ce rappel et visualiser les informations qu'il fournit. Voici les opérations à effectuer sur le code que vous avez déjà ajouté à l'étape précédente (Créer votre exemple d'application) :

  1. Remplacez la méthode onAttachedToWindow :
override fun onAttachedToWindow() {
   super.onAttachedToWindow()
  1. Utilisez l'instance WindowManager pour enregistrer le rappel de modification de la mise en page à l'aide de l'exécuteur que vous avez déjà implémenté en tant que premier paramètre :
   wm.registerLayoutChangeCallback(
       runOnUiThreadExecutor(),
       layoutStateChangeCallback
   )
}

Voyons à quoi ressemblent les informations fournies par ce rappel. Si vous exécutez ce code dans l'émulateur de double écran, vous obtiendrez le résultat suivant :

49a85b4d10245a9d.png

Comme vous pouvez le voir, WindowLayoutInfo est vide. Il contient une liste vide (DisplayFeature), mais si vous avez un émulateur d'appareil avec charnière centrale, pourquoi ne pas obtenir les informations avec WindowManager ?

WindowManager ne fournit les données LayoutInfo (format d'écran de l'appareil, dimensions de son ou ses écrans, et position de l'appareil) que lorsque l'application est diffusée sur plusieurs écrans (physiques ou non). Ainsi, dans la figure précédente, où l'application s'exécute en mode écran unique, WindowLayoutInfo est vide.

En tenant compte de ces informations, vous connaîtrez le mode d'exécution de l'application (sur un seul ou plusieurs écrans). Vous pourrez ainsi apporter des modifications à votre interface utilisateur ou à l'expérience utilisateur, et ainsi offrir une meilleure expérience à vos utilisateurs, en fonction de ces configurations spécifiques.

Sur les appareils ne disposant pas de deux écrans physiques (les charnières physiques sont plutôt rares), les applications peuvent être diffusées côte à côte avec le mode multifenêtre. Sur de tels appareils, lorsque l'application s'exécute en mode multifenêtre, elle se comporte comme sur un seul écran (comme dans l'exemple précédent), alors que si elle s'exécute sur tous les écrans logiques, elle se comporte comme si elle s'affichait sur plusieurs écrans. L'illustration suivante illustre ce processus :

ecdada42f6df1fb8.png

Comme vous pouvez le constater, lorsque l'application s'exécute en mode multifenêtre, elle ne présente pas d'intersection avec le format de l'appareil pliable. Par conséquent, WindowManager renvoie une liste vide <LayoutInfo>.

En résumé, vous obtenez des données LayoutInfo uniquement lorsque l'application présente une intersection avec le format d'écran de l'appareil (pliable ou à charnière). Si ce n'est pas le cas, vous ne recevez aucune information. 564eb78fc85f6d3e.png

Que se passe-t-il lorsque l'application s'affiche sur plusieurs écrans ? Dans un émulateur d'appareil double écran, LayoutInfo dispose d'un objet FoldingFeature qui fournit des données sur le format d'écran : un HINGE, les dimensions de son ou ses écrans : Rect (0, 0 – 1434, 1800) et la position (l'état) de l'appareil : FLAT.

13edea3ff94baae4.png

Comme indiqué précédemment, le type d'appareil accepte deux valeurs : FOLD et HINGE, comme indiqué dans le code source :

@IntDef({
       TYPE_FOLD,
       TYPE_HINGE,
})
  • type = TYPE_HINGE. Cet émulateur d'appareil double écran se comporte comme un modèle réel Surface Duo doté d'une charnière physique, comme l'indique WindowManager.
  • Rect (0, 0 – 1434, 1800), représente l'enceinte rectangulaire du type d'écran dans la fenêtre de l'application qui se trouve à l'intérieur de l'espace des coordonnées des fenêtres. Si vous consultez les dimensions de l'appareil Surface Duo, vous verrez que la charnière se situe aux limites des dimensions indiquées (gauche, haut, droite, bas).
  • Trois valeurs différentes représentent la position de l'appareil (état) :
  • STATE_HALF_OPENED : la charnière de l'appareil pliable se trouve dans une position intermédiaire entre l'état ouvert et fermé, formant un angle entre les parties de l'écran flexible ou entre les deux écrans physiques.
  • STATE_FLAT : l'appareil pliable est entièrement ouvert. L'utilisateur fait face à une surface d'écran plate.
  • STATE_FLIPPED : l'appareil pliable est retourné et les parties de l'écran flexible ou les écrans physiques font face à des directions opposées.
@IntDef({
       STATE_HALF_OPENED,
       STATE_FLAT,
       STATE_FLIPPED,
})

Par défaut, l'émulateur est ouvert à 180 degrés. La position affichée par WindowManager est donc STATE_FLAT.

Si vous modifiez la position de l'émulateur à l'aide des capteurs virtuels pour qu'il se trouve dans un position intermédiaire, WindowManager vous informera de la nouvelle position : STATE_HALF_OPENED.

7cfb0b26d251bd1.png

Vous pouvez supprimer ce rappel lorsque vous n'en avez plus besoin. Il suffit pour cela d'appeler la fonction suivante à partir de l'API WindowManager :

public void unregisterDeviceStateChangeCallback (Consumer<DeviceState> callback)

Les méthodes onDestroy ou onDetachedFromWindow constituent de bonnes options pour mettre fin à l'enregistrement de ce rappel :

override fun onDetachedFromWindow() {
   super.onDetachedFromWindow()
   wm.unregisterLayoutChangeCallback(layoutStateChangeCallback)
}

Utiliser WindowManager pour adapter votre interface utilisateur ou votre expérience utilisateur

Comme l'ont montré les illustrations présentant les informations de mise en page des fenêtres, les informations affichées étaient partiellement occultées par le format d'écran. C'est également le cas ici :

4ee805070989f322.png

Ceci n'offre pas une expérience utilisateur optimale. Vous pouvez utiliser les informations fournies par WindowManager pour ajuster l'UI ou l'expérience utilisateur.

Comme nous l'avons vu plus tôt, lorsque votre application utilise toutes les régions d'affichage disponibles, elle présente forcément une intersection avec le format d'écran de l'appareil. WindowManager fournit alors des informations sur la mise en page des fenêtres contenant le format d'écran et les limites d'affichage. Ainsi, lorsque l'application utilise toute la surface d'affichage, vous devez exploiter ces informations afin d'ajuster votre UI ou votre expérience utilisateur.

Vous allez donc ajuster l'UI ou l'expérience utilisateur lors de l'exécution de votre application afin qu'aucune information importante ne soit occultée ni tronquée par le format d'écran. Vous allez créer un affichage identique au format d'écran de l'appareil qui vous servira de référence pour délimiter la zone TextView occultée ou tronquée afin de ne plus perdre d'informations.

En guise d'exercice, vous allez utiliser une couleur sur ce nouvel affichage afin de bien montrer que son positionnement correspond exactement au format d'écran de l'appareil réel, en reprenant les dimensions de celui-ci.

  1. Ajoutez le nouvel affichage à utiliser comme référence pour le format d'écran dans activity_main.xml.

res/layout/activity_main.xml

<View
   android:id="@+id/device_feature"
   android:layout_width="0dp"
   android:layout_height="0dp"
   android:background="@android:color/holo_red_dark"
   android:visibility="gone" />
  1. Dans MainActivity.kt, accédez à la fonction que vous avez utilisée pour afficher les informations de nos rappels WindowManager, puis ajoutez un nouvel appel de fonction dans le cas if-else incluant des données de format d'écran :

window_manager/MainActivity.kt

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.windowMetrics.text =
       "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
           "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
       alignViewToDeviceFeatureBoundaries(newLayoutInfo)
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}

Vous avez ajouté la fonction alignViewToDeviceFeatureBoundaries qui reçoit le paramètre WindowLayoutInfo.

  1. Dans la nouvelle fonction, créez l'ensemble ConstraintSet afin d'appliquer de nouvelles délimitations à vos affichages :
private fun alignViewToDeviceFeatureBoundaries(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)
  1. Vous pouvez alors obtenir les limites du format d'écran à l'aide de WindowLayoutInfo:
val rect = newLayoutInfo.displayFeatures[0].bounds
  1. Utilisez alors les données WindowLayoutInfo pour définir la hauteur correcte de votre affichage de référence dans la variable "rect" :
set.constrainHeight(
   R.id.device_feature,
   rect.bottom - rect.top
)
  1. Ajustez votre affichage à la largeur du format d'écran en soustrayant la coordonnée de gauche de celle de droite pour calculer cette largeur.
set.constrainWidth(R.id.device_feature, rect.right - rect.left)
  1. Définissez les contraintes d'alignement sur votre référence d'affichage afin qu'elle soit alignée sur son parent sur les côtés "start" et "top" :
set.connect(
   R.id.device_feature, ConstraintSet.START,
   ConstraintSet.PARENT_ID, ConstraintSet.START, 0
)
set.connect(
   R.id.device_feature, ConstraintSet.TOP,
   ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
)

Vous pouvez également ajouter ces données directement dans le code XML en tant qu'attributs de votre affichage, plutôt que dans le code ci-dessus.

Il vous reste ensuite à couvrir tous les types de formats d'écran : appareils aux écrans disposés verticalement (comme l'émulateur d'appareil double écran) et les appareils dont les écrans sont disposés horizontalement (comme l'émulateur pliable selon un axe horizontal).

  1. Dans le premier scénario, " top == 0" indique que le format d'écran de votre appareil présente une disposition verticale (comme notre émulateur double écran) :
if (rect.top == 0) {
  1. Vous pouvez maintenant appliquer la marge à votre affichage de référence en la plaçant exactement à l'endroit où se situe la séparation correspondant au format d'écran.
  2. Ensuite, appliquez les délimitations à l'élément TextView dont vous souhaitez optimiser le placement pour éviter la séparation due au format d'écran de façon à ce que cette séparation soit intégrée dans les délimitations :
set.setMargin(R.id.device_feature, ConstraintSet.START, rect.left)
set.connect(
   R.id.layout_change, ConstraintSet.END,
   R.id.device_feature, ConstraintSet.START, 0
)

Formats d'écran horizontaux

L'appareil de l'utilisateur peut présenter un format d'écran au pli horizontal (comme notre émulateur pliable selon un axe horizontal).

Selon votre UI, vous pouvez disposer d'une barre d'outils ou d'une barre d'état à afficher. Nous vous conseillons alors d'obtenir leur hauteur et d'ajuster la représentation du format d'écran pour qu'elle corresponde exactement à votre UI.

Notre application dispose bien d'une barre d'état et d'une barre d'outils :

val statusBarHeight = calculateStatusBarHeight()
val toolBarHeight = calculateToolbarHeight()

Voici une implémentation simple des fonctions permettant de réaliser ces calculs (situées hors de notre fonction actuelle) :

private fun calculateToolbarHeight(): Int {
   val typedValue = TypedValue()
   return if (theme.resolveAttribute(android.R.attr.actionBarSize, typedValue, true)) {
       TypedValue.complexToDimensionPixelSize(typedValue.data, resources.displayMetrics)
   } else {
       0
   }
}

private fun calculateStatusBarHeight(): Int {
   val rect = Rect()
   window.decorView.getWindowVisibleDisplayFrame(rect)
   return rect.top
}

Revenez à la fonction principale de la déclaration "else", dans laquelle vous gérez le format d'écran horizontal. Pour la marge, vous pouvez utiliser la hauteur de la barre d'état et celle de la barre d'outils, car les limites du format d'écran ne tiennent compte d'aucun élément d'interface existant et ont pour référence les coordonnées (0,0). Pour que l'affichage de référence soit placé au bon endroit, vous devez tenir compte des éléments suivants :

} else {
   //Device feature is placed horizontally
   val statusBarHeight = calculateStatusBarHeight()
   val toolBarHeight = calculateToolbarHeight()
   set.setMargin(
       R.id.device_feature, ConstraintSet.TOP,
       rect.top - statusBarHeight - toolBarHeight
   )
   set.connect(
       R.id.layout_change, ConstraintSet.TOP,
       R.id.device_feature, ConstraintSet.BOTTOM, 0
   )
}

L'étape suivante consiste à définir la visibilité de l'affichage de référence sur "Visible", de sorte que vous puissiez la voir dans votre exemple (en rouge) et surtout, pour que les délimitations soient appliquées. Si l'affichage disparaît, aucune contrainte ne peut s'appliquer :

set.setVisibility(R.id.device_feature, View.VISIBLE)

La dernière étape consiste à appliquer l'ensemble de délimitations ConstraintSet que vous avez créé à votre ConstraintLayout, afin de mettre en œuvre toutes les modifications et tous les ajustements de l'interface utilisateur :

    set.applyTo(constraintLayout)
}

Désormais, l'affichage TextView qui n'était pas adapté au format d'écran tient compte de l'espace de séparation afin d'éviter que le contenu soit occulté ou tronqué :

80993d3695a9a60.png

L'émulateur d'appareil double écran (à gauche) montre comment l'affichage TextView, qui avait disposé un contenu à cheval sur les deux écrans et l'avait tronqué au niveau de la charnière, a modifié la disposition de ce contenu pour éviter toute perte d'information.

Dans un émulateur d'appareil pliable (à droite), une ligne rouge clair représente l'emplacement du pli du format d'écran, avec le contenu TextView désormais placé sous cette séparation. Ainsi, lorsque l'appareil est plié (par exemple, à 90 degrés, dans la position d'un appareil portable), aucune information n'est affectée par le format d'écran.

Si vous vous demandez où se trouve la séparation sur l'émulateur de l'appareil double écran, comme il s'agit d'un appareil à charnière, la barre qui représente cette séparation est masquée par la charnière. Si nous désactivons l'affichage de l'application sur les deux écrans, cette barre s'affiche à l'emplacement de la séparation, avec la largeur et la hauteur correspondantes.

4dbe464ac71b498e.png

Jusqu'à présent, vous avez découvert la différence entre les appareils pliables et les appareils à écran unique.

L'une des fonctionnalités offertes par les appareils pliables est la possibilité d'exécuter deux applications côte à côte pour plus de productivité. Par exemple, les utilisateurs peuvent afficher leur application de messagerie d'un côté et leur agenda de l'autre, ou répondre à un appel vidéo sur un écran tout en prenant des notes sur l'autre. Les possibilités sont nombreuses.

Il est possible de profiter d'un appareil à deux écrans simplement en utilisant les API existantes incluses dans le framework Android. Voici quelques améliorations que vous pouvez apporter.

Lancer une activité dans la fenêtre adjacente

Cette amélioration vous permet d'autoriser votre application à lancer une nouvelle activité dans la fenêtre adjacente, afin de pouvoir utiliser facilement plusieurs zones d'écran à la fois.

Imaginons que l'application permette le lancement d'une nouvelle activité en appuyant sur un bouton :

  1. Créez d'abord la fonction qui gérera l'événement de clic :

intent/MainActivity.kt

private fun openActivityInAdjacentWindow() {
}
  1. Dans la fonction, créez l'Intent qui sera utilisé pour lancer la nouvelle activité (dans ce cas, "SecondActivity". Il s'agit d'une activité simple avec un objet TextView en tant que message) :
val intent = Intent(this, SecondActivity::class.java)
  1. Ensuite, définissez les indicateurs qui lanceront la nouvelle activité lorsque l'écran adjacent est vide :
intent.addFlags(
   Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or
       Intent.FLAG_ACTIVITY_NEW_TASK
)

Voici les activités effectuées par ces indicateurs :

  • FLAG_ACTIVITY_NEW_TASK : si ce paramètre est défini, cette activité devient le début d'une nouvelle tâche sur cette pile d'historique.
  • FLAG_ACTIVITY_LAUNCH_ADJACENT : cet indicateur est utilisé pour le mode multifenêtre sur écran partagé (et fonctionne également pour les appareils à double écran ayant des écrans physiques indépendants). La nouvelle activité s'affiche à côté de celle qui la lance.

Lorsqu'une nouvelle tâche est détectée par la plate-forme, elle tente de l'affecter à la fenêtre adjacente. La nouvelle tâche est lancée en plus de votre tâche actuelle. Votre nouvelle activité s'ajoute donc à votre activité en cours.

  1. La dernière étape consiste simplement à lancer la nouvelle activité à l'aide de l'intent que nous avons créé :
     startActivity(intent)

L'application de test obtenue doit se comporter comme dans les animations ci-dessous, où une pression sur un bouton lance une nouvelle activité dans la fenêtre adjacente.

Vous pouvez la voir en action sur un appareil double écran et sur un appareil pliable fonctionnant en mode multifenêtre :

9696f7fa2ee1e35f.gif a2dc98dae26e3045.gif

Glisser-déposer

L'ajout d'une fonctionnalité glisser-déposer à vos applications peut être particulièrement apprécié par vos utilisateurs. Cette fonctionnalité permet à votre application de fournir du contenu à d'autres applications (implémenter la fonctionnalité "faire glisser"), d'accepter le contenu d'autres applications (implémenter "déposer"), voire d'inclure les deux fonctionnalités, afin que votre application puisse fournir et accepter des contenus provenant d'autres applications ou d'elle même (par exemple, un contenu situé à différents emplacements d'une même application).

La fonctionnalité glisser-déposer est disponible dans le framework Android depuis la version 11 de l'API, mais n'est devenue utile qu'après l'arrivée de la compatibilité Multi-Window au niveau d'API 24, puisqu'il est alors devenu possible de glisser-déposer des éléments entre des applications s'exécutant côte à côte sur le même écran.

Désormais, avec l'arrivée des appareils pliables qui disposent de plus d'espace pour les activités multifenêtres, voire des appareils comportant deux écrans logiques différents, le glisser-déposer devient une fonctionnalité plus intéressante. On peut ainsi imaginer une application de liste de tâches qui accepte (déposer) du texte et le convertit en nouvelle tâche lorsqu'il est déposé, ou une application d'agenda qui accepte (déposer) un contenu dans une date ou un créneau horaire/heure et le convertit en événement, etc.

Pour bénéficier de cette fonctionnalité, les applications doivent implémenter un comportement "faire glisser" pour devenir des consommatrices de données et/ou un comportement "déposer" pour être des productrices de données.

Dans votre exemple, vous allez implémenter la fonctionnalité "faire glisser" dans une application et "déposer" dans une autre, sachant que vous êtes libre d'implémenter à la fois glisser et déposer dans la même application.

Implémenter la fonctionnalité "faire glisser"

Votre application "faire glisser" dispose simplement d'un élément TextView et déclenche l'action de déplacement lorsque l'utilisateur appuie de manière prolongée.

  1. Commencez par créer une application en sélectionnant File > New > New Project > Empty Activity (Fichier > Nouveau > Nouveau projet > Activité vide).
  2. Accédez ensuite à l'élément activity_main.xml qui a déjà été créé. Remplacez la mise en page existante par celle-ci :

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/drag_text_view"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_margin="20dp"
       android:text="@string/drag_text"
       android:textSize="30sp" />
</LinearLayout>
  1. À présent, ouvrez le fichier MainActivity.kt, puis ajoutez la balise et appelez sa fonction setOnLongClickListener :

drag/MainActvity.kt

class MainActivity : AppCompatActivity(), View.OnLongClickListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)

       binding.dragTextView.tag = "text_view"
       binding.dragTextView.setOnLongClickListener(this)
   }
  1. Ignorez maintenant la fonction onLongClick afin que votre TextView puisse utiliser cette fonctionnalité ignorée pour son événement onLongClickListener.
override fun onLongClick(view: View): Boolean {
  1. Vérifiez si le paramètre de destinataire est le type d'objet View auquel vous ajoutez la fonctionnalité "faire glisser". Dans votre cas, il s'agit d'un élément TextView :
return if (view is TextView) {
  1. Créez un élément ClipData.item à partir du texte figurant dans l'élément TextView :
val text = ClipData.Item(view.text)
  1. Définissons le MimeType que nous allons utiliser :
val mimeType = arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)
  1. Réunissez les éléments que vous avez déjà créés pour créer le groupe (une instance ClipData) que vous utiliserez pour partager ces données :
val dataToShare = ClipData(view.tag.toString(), mimeType, text)

Il est très important de fournir un feedback aux utilisateurs. Il est donc judicieux de donner des informations visuelles sur l'objet auquel la fonctionnalité s'applique.

  1. Créez une ombre du contenu que nous faisons glisser afin que les utilisateurs voient le contenu sous le doigt pendant l'interaction "faire glisser" :
val dragShadowBuilder = View.DragShadowBuilder(view)
  1. Pour pouvoir glisser-déposer des éléments entre différentes applications, vous devez d'abord définir un ensemble d'indicateurs indispensables à cette fonctionnalité :
val flags =
   View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ

Voici ce que signifient ces indicateurs d'après nos documents de référence :

  • DRAG_FLAG_GLOBAL : indicateur précisant qu'un déplacement peut dépasser les limites d'une fenêtre.
  • DRAG_FLAG_GLOBAL_URI_READ : lorsque cet indicateur est utilisé avec DRAG_FLAG_GLOBAL, le destinataire du déplacement aura la possibilité de demander un accès en lecture aux URI de contenus inclus dans l'objet ClipData.
  1. Enfin, appelez la fonction startDragAndDrop dans l'affichage avec les composants que vous avez créés, afin de lancer l'interaction "faire glisser" :
view.startDragAndDrop(dataToShare, dragShadowBuilder, view, flags)
  1. Terminez en fermant la fonction onLongClick function et l'activité MainActivity :
         true
       } else {
           false
       }
   }
}

Implémenter la fonctionnalité "déposer"

Dans votre exemple, vous créez une application simple dotée de la fonctionnalité "déposer" associée à un élément EditText. Cet affichage acceptera les données de texte (qui peuvent provenir de l'élément TextView de notre application "faire glisser").

Notre élément EditText (ou zone "déposer") modifie son arrière-plan en fonction de la phase de l'action "faire glisser" en cours. Ceci permet d'informer l'utilisateur sur l'état de l'interaction "glisser-déposer" afin qu'il sache quand il peut déposer le contenu.

  1. Commencez par créer une application en sélectionnant File > New > New Project > Empty Activity (Fichier > Nouveau > Nouveau projet > Activité vide).
  2. Ensuite, accédez au fichier activity_main.xml qui a déjà été créé. Remplacez la mise en page existante par celle-ci :

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<EditText
   android:id="@+id/drop_edit_text"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:background="@android:color/holo_blue_dark"
   android:gravity="top"
   android:hint="@string/drop_text"
   android:textColor="@android:color/white"
   android:textSize="30sp" />

</RelativeLayout>
  1. Ouvrez le fichier MainActivity.kt et ajoutez un écouteur à la fonction setOnDragListener de l'élément EditText :

drop/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnDragListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)
       binding.dropEditText.setOnDragListener(this)
   }
  1. Modifiez la fonction onDrag de sorte que l'élément EditText écrit ci-dessus puisse utiliser ce rappel modifié avec sa fonction onDragListener.

Cette fonction sera appelée chaque fois qu'un nouvel événement DragEvent se produit, par exemple lorsque le doigt de l'utilisateur entre dans la zone "déposer" ou la quitte, lorsque l'utilisateur libère l'élément dans la zone "déposer" pour effectuer le déplacement, ou quand il libère l'élément hors de la zone "déposer" afin d'annuler l'interaction "glisser-déposer".

override fun onDrag(v: View, event: DragEvent): Boolean {
  1. Pour réagir au déclenchement de différents événements DragEvents, ajoutez une instruction when :
return when (event.action) {
  1. Gérez le ACTION_DRAG_STARTED qui est déclenché au démarrage de l'interaction. Lorsque cet événement se déclenche, la couleur de la zone "déposer" change pour indiquer à l'utilisateur que votre EditText peut accepter le contenu déposé :
DragEvent.ACTION_DRAG_STARTED -> {
       setDragStartedBackground()
       true
}
  1. Gérez l'événement de déplacement ACTION_DRAG_ENTERED qui est déclenché quand le doigt de l'utilisateur entre dans la zone "déposer". Modifiez à nouveau la couleur d'arrière-plan de la zone "déposer" pour indiquer à l'utilisateur qu'elle peut recevoir le contenu déplacé. (Il est bien sûr possible d'ignorer cet événement : le changement d'arrière-plan n'a qu'un but informatif.)
DragEvent.ACTION_DRAG_ENTERED -> {
   setDragEnteredBackground()
   true
}
  1. Gérez l'événement ACTION_DROP ici. Cet événement est déclenché lorsque l'utilisateur relâche le doigt utilisé pour faire glisser le contenu dans la zone "déposer" afin d'effectuer le déplacement.
DragEvent.ACTION_DROP -> {
   handleDrop(event)
   true
}

Nous verrons plus tard comment gérer l'action "déposer".

  1. Ensuite, gérez l'événement ACTION_DRAG_ENDED. Cet événement est déclenché après ACTION_DROP, ce qui termine l'action de glisser-déposer.

Profitez de l'occasion pour restaurer les modifications que vous avez apportées, par exemple, en remplaçant l'arrière-plan de la zone "déposer" par sa valeur d'origine.

DragEvent.ACTION_DRAG_ENDED -> {
   clearBackgroundColor()
   true
}
  1. Ensuite, gérez l'événement ACTION_DRAG_EXITED. Cet événement se déclenche lorsque l'utilisateur quitte la zone "déposer" (quand son doigt entre dans la zone, puis la quitte sans libérer le contenu).

Si vous avez modifié l'arrière-plan pour signaler l'entrée dans la zone "déposer", nous vous conseillons de restaurer ici sa valeur précédente.

DragEvent.ACTION_DRAG_EXITED -> {
   setDragStartedBackground()
   true
}
  1. Enfin, indiquez l'issue du cas "else" de votre instruction "when", puis fermez la fonction onDrag :
      else -> false
   }
}

Voyons maintenant comment l'action "déposer" est gérée. Nous avons vu précédemment que lorsque l'événement ACTION_DROP est déclenché, nous devons gérer la fonctionnalité "déposer". Voici comment procéder.

  1. Utilisez l'événement DragEvent en tant que paramètre, car il s'agit de l'objet qui contient les données à déplacer :
private fun handleDrop(event: DragEvent) {
  1. Dans la fonction, demandez des autorisations de glisser-déposer. C'est indispensable quand une action glisser-déposer s'effectue entre différentes applications.
val dropPermissions = requestDragAndDropPermissions(event)
  1. Dans le paramètre DragEvent, vous pouvez accéder à l'élément clipData créé précédemment à l'étape "faire glisser" :
val item = event.clipData.getItemAt(0)
  1. Pour l'élément à faire glisser, accédez au texte qui le contient et qui a été partagé. Voici le texte affiché dans l'exemple d'action "faire glisser" TextView :
val dragData = item.text.toString()
  1. Une fois identifiées les données réelles partagées (le texte), vous pouvez simplement les définir dans votre zone "déposer" (notre EditText) en suivant la méthode habituelle pour ajouter du texte dans un élément EditText du code :
binding.dropEditText.setText(dragData)
  1. La dernière étape consiste à annuler les autorisations demandées pour glisser-déposer. Si vous ne le faites pas après la fin de l'action "déposer", ces autorisations seront automatiquement annulées une fois l'activité détruite. Fermez la fonction et la classe :
      dropPermissions?.release()
   }
}

Une fois que vous avez implémenté cette fonctionnalité dans l'application, nous pouvons exécuter les deux applications côte à côte et observer le fonctionnement du glisser-déposer.

L'animation ci-dessous illustre le fonctionnement de cette opération et le déclenchement des différents événements de déplacement. Elle montre également le résultat de vos instructions pour gérer ces événements (modification de l'arrière-plan de la zone "déposer" en fonction du DragEvent actif et quand le contenu est déposé) :

d66c5c24c6ea81b3.gif

Comme nous l'avons constaté dans ce bloc de contenus, l'utilisation de Jetpack WindowManager nous permet d'utiliser les appareils aux nouveaux facteurs de forme tels que les modèles pliables.

Ces informations sont très utiles pour adapter nos applications à ces appareils en vue d'offrir une meilleure expérience utilisateur lorsque nos applications s'exécutent sur ces appareils.

En résumé, voici tout ce que vous avez appris au cours de cet atelier de programmation :

  • Ce que sont les appareils pliables.
  • Les différences entre les différents types d'appareils pliables.
  • Les différences entre les appareils pliables, les appareils à écran unique et les tablettes.
  • Jetpack WindowManager. Que propose cette API ?
  • Comment utiliser Jetpack WindowManager et adapter nos applications à de nouveaux facteurs de forme d'appareils.
  • Comment améliorer les applications en ajoutant des modifications mineures pour lancer des activités dans la fenêtre adjacente et en implémentant une fonctionnalité glisser-déposer entre deux applications.

En savoir plus