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

1. Avant de commencer

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 3.

Prérequis

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

Objectifs de l'atelier

Créez une application simple qui effectue les opérations suivantes :

  • Affiche les fonctionnalités de l'appareil
  • Détecte quand l'application s'exécute sur un appareil pliable ou double écran
  • Détermine l'état de l'appareil
  • Utilise Jetpack WindowManager pour travailler sur de nouveaux facteurs de forme d'appareil

Ce dont vous avez besoin

L'outil Android Emulator v30.0.6+ est compatible avec les appareils pliables, et comprend un capteur à charnière virtuelle et une vue 3D. Différents émulateurs pliables peuvent être utilisés, comme illustré ci-dessous :

7203779994e5c01d.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).

2. Appareils à écran unique ou appareils pliables

Les appareils pliables offrent aux utilisateurs un écran plus grand et une interface utilisateur plus flexible que sur un appareil mobile traditionnel. 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.

3. Jetpack WindowManager

La bibliothèque 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.

Principales fonctionnalités

La version 1.0.0 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 :

  • state() : indique la position actuelle de l'appareil à partir d'une liste d'options ( STATE_FLAT et STATE_HALF_OPENED).
  • isSeparating() : calcule si une propriété FoldingFeature doit être considérée comme la division de la fenêtre en plusieurs zones physiques, que les utilisateurs verront comme étant séparées logiquement.
  • occlusionType() : calcule le mode d'occlusion pour déterminer si une propriété FoldingFeature masque une partie de la fenêtre.
  • orientation() : renvoie FoldingFeature.Orientation.HORIZONTAL si la largeur de la propriété FoldingFeature est supérieure à la hauteur. Dans le cas contraire, renvoie FoldingFeature.Orientation.VERTICAL.
  • bounds() : fournit une instance Rect contenant les dimensions du format d'écran (par exemple, les dimensions d'une charnière physique).

Vous pouvez accéder à windowLayoutInfo() via l'interface WindowInfoTracker pour récupérer une propriété Flow de WindowLayoutInfo contenant toutes les propriétés DisplayFeature disponibles.

4. Configurer

Créez un projet et sélectionnez le modèle "Empty Activity" (Activité vide) :

e266da71551681b8.png

Vous pouvez conserver tous les paramètres par défaut.

Déclarer des dépendances

Pour utiliser Jetpack WindowManager, vous devez ajouter la dépendance dans le fichier build.gradle de votre application ou module :

app/build.gradle

dependencies {
    ext.windowmanager_version = "1.0.0"

    implementation "androidx.window:window:$windowmanager_version"
    androidTestImplementation "androidx.window:window-testing:$windowmanager_version"

    // Needed to use lifecycleScope to collect the WindowLayoutInfo flow
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"
}

Utiliser WindowManager

Vous pouvez accéder aux fonctionnalités de la fenêtre via l'interface WindowInfoTracker de WindowManager.

Ouvrez le fichier source MainActivity.kt et appelez WindowInfoTracker.getOrCreate(this@MainActivity) pour initialiser l'instance WindowInfoTracker associée à l'activité actuelle :

MainActivity.kt

import androidx.window.layout.WindowInfoTracker

private lateinit var windowInfoTracker: WindowInfoTracker

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

        windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
}

Une fois que vous disposez de votre instance WindowInfoTracker, vous pouvez obtenir des informations sur l'état de la fenêtre active de l'appareil.

5. Configurer l'interface utilisateur de l'application

Jetpack WindowManager nous permet d'obtenir des informations sur les dimensions de fenêtres, la mise en page et la configuration de l'affichage. Affichons cela dans la mise en page de l'activité principale, en utilisant une propriété TextView pour chacun de ces éléments.

Pour ce faire, nous avons besoin d'une propriété ConstraintLayout, avec trois propriétés TextView, centrées sur l'écran.

Ouvrez le fichier activity_main.xml et collez le contenu suivant :

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"
        tools:text="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"
        tools:text="Layout change"
        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"
        tools:text="Using one logic/physical display - unspanned"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/layout_change" />

</androidx.constraintlayout.widget.ConstraintLayout>

À présent, nous allons connecter ces éléments d'interface utilisateur dans le code à l'aide du composant View Binding. Pour ce faire, nous allons commencer par l'activer dans le fichier build.gradle de l'application :

app/build.gradle

android {
   // Other configurations

   buildFeatures {
      viewBinding true
   }
}

Nous pouvons maintenant synchroniser le projet Gradle comme suggéré par Android Studio et utiliser le composant View Binding dans MainActivity.kt à l'aide du code suivant :

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var windowInfoTracker: WindowInfoTracker
    private lateinit var binding: ActivityMainBinding

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

        windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
    }
}

6. Visualiser les informations sur WindowMetrics

Dans la méthode onCreate de MainActivity, nous allons appeler une fonction qui sera mise en œuvre dans les étapes suivantes. La fonction permettra d'obtenir et d'afficher les informations WindowMetrics. Nous allons commencer par ajouter un appel obtainWindowMetrics() à la méthode onCreate :

MainActivity.kt

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

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
}

Nous allons maintenant implémenter la méthode obtainWindowMetrics :

MainActivity.kt

import androidx.window.layout.WindowMetricsCalculator

private fun obtainWindowMetrics() {
   val wmc = WindowMetricsCalculator.getOrCreate()
   val currentWM = wmc.computeCurrentWindowMetrics(this).bounds.flattenToString()
   val maximumWM = wmc.computeMaximumWindowMetrics(this).bounds.flattenToString()
   binding.windowMetrics.text =
       "CurrentWindowMetrics: ${currentWM}\nMaximumWindowMetrics: ${maximumWM}"
}

Comme vous pouvez le voir ci-dessus, nous obtenons une instance de WindowMetricsCalculator via sa fonction associée getOrCreate().

À l'aide de cette instance WindowMetricsCalculator, nous allons définir les informations dans le champ windowMetrics TextView. Nous utilisons les valeurs renvoyées par les fonctions computeCurrentWindowMetrics.bounds et computeMaximumWindowMetrics.bounds.

Ces valeurs fournissent des informations utiles sur les coordonnées de la zone occupée par la fenêtre.

Exécutez l'application. Les résultats obtenus varient en fonction de l'appareil pliable utilisé. Par exemple, dans un émulateur double écran (voir ci-dessous), vous obtenez les propriétés CurrentWindowMetrics qui correspondent aux dimensions de l'appareil dont il simule le comportement. Vous pouvez également consulter ces chiffres lorsque l'application s'exécute sur un seul écran :

b032c729d6dce292.png

Lorsque l'application s'affiche sur plusieurs écrans, les dimensions des fenêtres changent comme illustré sur l'image ci-dessous. Ainsi, la taille de la fenêtre de l'application est désormais plus grande :

882fc97252d1483b.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 s'affiche sur l'ensemble de l'affichage physique ou en mode multifenêtre :

b260d1cbe8238976.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 valeurs affichées indiquent les dimensions de la zone du mode Écran partagé (en haut) où s'exécute l'application, tandis que les valeurs maximales indiquent la zone d'affichage maximale de l'appareil.

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

7. Visualiser les informations sur FoldingFeature

Nous allons maintenant nous enregistrer afin de recevoir les modifications de mise en page des fenêtres, ainsi que les caractéristiques et les limites de la propriété DisplayFeatures de l'émulateur ou de l'appareil.

Pour collecter les informations de WindowInfoTracker#windowLayoutInfo(), nous allons utiliser la propriété lifecycleScope définie pour chaque objet Lifecycle. Toute coroutine lancée au sein de cette portée est annulée lorsque le cycle de vie est détruit. Vous pouvez accéder à la portée de coroutine du cycle de vie via les propriétés lifecycle.coroutineScope ou lifecycleOwner.lifecycleScope.

Dans la méthode onCreate de MainActivity, nous allons appeler une fonction qui sera mise en œuvre dans les étapes suivantes. La fonction permettra d'obtenir et d'afficher les informations WindowInfoTracker. Nous allons commencer par ajouter un appel onWindowLayoutInfoChange() à la méthode onCreate :

MainActivity.kt

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

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
   onWindowLayoutInfoChange()
}

Nous allons utiliser l'implémentation de cette fonction pour obtenir des informations chaque fois qu'une nouvelle configuration de mise en page est modifiée.

Voyons comment procéder :

Définissez la signature et le squelette de la fonction.

MainActivity.kt

private fun onWindowLayoutInfoChange() {
}

Avec le paramètre reçu par la fonction, WindowInfoTracker, nous pouvons obtenir ses données WindowLayoutInfo. WindowLayoutInfo contient la liste des propriétés DisplayFeature situées dans la fenêtre. Par exemple, une charnière ou un pli d'écran peuvent traverser la fenêtre, auquel cas il peut être judicieux de séparer le contenu visuel et les éléments interactifs en deux groupes (par exemple, listes détaillées ou commandes d'affichage).

Seules les fonctionnalités présentes dans les dimensions de la fenêtre actuelle sont signalées. Leur position et leur taille peuvent changer si la fenêtre est déplacée ou redimensionnée à l'écran.

Grâce à la propriété lifecycleScope définie dans la dépendance lifecycle-runtime-ktx, nous pouvons obtenir le flow de WindowLayoutInfo qui, comme nous l'avons mentionné, contient une liste de toutes les fonctionnalités d'affichage. Ajoutons maintenant le corps de onWindowLayoutInfoChange :

MainActivity.kt

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

private fun onWindowLayoutInfoChange() {
    lifecycleScope.launch(Dispatchers.Main) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            windowInfoTracker.windowLayoutInfo(this@MainActivity)
                .collect { value ->
                    updateUI(value)
                }
        }
    }
}

Comme vous avez pu le constater lors de l'étape précédente, dans collect, nous avons appelé la fonction updateUI. Nous allons maintenant mettre en œuvre cette fonction pour afficher et imprimer les informations obtenues depuis le flow de WindowLayoutInfo. Comme vous pouvez le constater, la logique est très simple : nous vérifions simplement si les données WindowLayoutInfo comportent des fonctionnalités d'affichage. Le cas échéant, cela signifie que la fonctionnalité d'affichage interagit avec l'interface utilisateur de notre application. Si les données WindowLayoutInfo n'ont pas de fonctionnalités d'affichage, nous utilisons un appareil ou un mode à écran unique, ou le mode multifenêtre.

MainActivity.kt

import androidx.window.layout.WindowLayoutInfo

private fun updateUI(newLayoutInfo: WindowLayoutInfo) {
    binding.layoutChange.text = newLayoutInfo.toString()
    if (newLayoutInfo.displayFeatures.isNotEmpty()) {
        binding.configurationChanged.text = "Spanned across displays"
    } else {
        binding.configurationChanged.text = "One logic/physical display - unspanned"
    }
}

Voyons le résultat lorsque nous exécutons l'intégralité du nouveau code. Dans un é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 (List<DisplayFeature>). Mais puisque vous avez un émulateur d'appareil avec charnière centrale, pourquoi ne pas obtenir les informations avec WindowManager ?

WindowManager (via WindowInfoTracker) ne fournit les données WindowLayoutInfo (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.

Ces informations vous permettent de savoir dans quel mode l'application s'exécute (mode écran unique ou écran multiécran). Vous pouvez ainsi apporter des modifications à votre interface/expérience utilisateur, afin d'offrir une expérience optimisée et adaptée à 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. Et lorsque l'application 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, WindowManager renvoie une liste vide (List<LayoutInfo>).

En résumé, vous obtenez des données WindowLayoutInfo uniquement lorsque l'application s'exécute sur tous les écrans logiques, en présentant une intersection avec le format d'écran de l'appareil (pliable ou à charnière). Dans tous les autres cas, vous n'obtiendrez aucune information. 564eb78fc85f6d3e.png

Que se passe-t-il lorsque l'application s'affiche sur plusieurs écrans ? Dans un émulateur d'appareil double écran, WindowLayoutInfo 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).

faab87600a42a484.png

Voyons la signification de chaque champ :

  • 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.
  • Bounds [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 exactes des dimensions indiquées (gauche, haut, droite, bas).
  • State : deux valeurs différentes représentent la position de l'appareil (état).
  • 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.
  • FLAT : l'appareil pliable est entièrement ouvert. L'utilisateur fait face à une surface d'écran plate.

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

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

bbfbab436850fb4e.png

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 :

422aa9714bdb2892.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 l'état 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.

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

activity_main.xml

<!-- It's not important where this view is placed by default, it will be positioned dynamically at runtime -->
<View
    android:id="@+id/folding_feature"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:background="@android:color/holo_red_dark"
    android:visibility="gone"
    tools:ignore="MissingConstraints" />

Dans MainActivity.kt, accédez à la fonction updateUI() que vous avez utilisée pour afficher les informations d'une propriété WindowLayoutInfo donnée, puis ajoutez un nouvel appel de fonction dans le cas if-else incluant des données de format d'écran :

MainActivity.kt

private fun updateUI(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.isNotEmpty()) {
       binding.configurationChanged.text = "Spanned across displays"
       alignViewToFoldingFeatureBounds(newLayoutInfo)
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}

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

Créez cette fonction. Dans la fonction, créez l'ensemble ConstraintSet afin d'appliquer de nouvelles délimitations à vos affichages. Vous pouvez alors obtenir les limites du format d'écran à l'aide de WindowLayoutInfo Étant donné que WindowLayoutInfo renvoie une liste de DisplayFeature qui n'est qu'une interface, nous devons la caster sur FoldingFeature pour accéder à toutes les informations dont nous avons besoin :

MainActivity.kt

import androidx.constraintlayout.widget.ConstraintSet
import androidx.window.layout.FoldingFeature

private fun alignViewToFoldingFeatureBounds(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)

   // Get and translate the feature bounds to the View's coordinate space and current
   // position in the window.
   val foldingFeature = newLayoutInfo.displayFeatures[0] as FoldingFeature
   val bounds = getFeatureBoundsInWindow(foldingFeature, binding.root)

   // Rest of the code to be added in the following steps
}

Nous définissons une fonction getFeatureBoundsInWindow() afin de traduire les limites de format dans l'espace de coordonnées de l'affichage et dans la position actuelle de la fenêtre.

MainActivity.kt

import android.graphics.Rect
import android.view.View
import androidx.window.layout.DisplayFeature

/**
 * Get the bounds of the display feature translated to the View's coordinate space and current
 * position in the window. This will also include view padding in the calculations.
 */
private fun getFeatureBoundsInWindow(
    displayFeature: DisplayFeature,
    view: View,
    includePadding: Boolean = true
): Rect? {
    // Adjust the location of the view in the window to be in the same coordinate space as the feature.
    val viewLocationInWindow = IntArray(2)
    view.getLocationInWindow(viewLocationInWindow)

    // Intersect the feature rectangle in window with view rectangle to clip the bounds.
    val viewRect = Rect(
        viewLocationInWindow[0], viewLocationInWindow[1],
        viewLocationInWindow[0] + view.width, viewLocationInWindow[1] + view.height
    )

    // Include padding if needed
    if (includePadding) {
        viewRect.left += view.paddingLeft
        viewRect.top += view.paddingTop
        viewRect.right -= view.paddingRight
        viewRect.bottom -= view.paddingBottom
    }

    val featureRectInView = Rect(displayFeature.bounds)
    val intersects = featureRectInView.intersect(viewRect)

    // Checks to see if the display feature overlaps with our view at all
    if ((featureRectInView.width() == 0 && featureRectInView.height() == 0) ||
        !intersects
    ) {
        return null
    }

    // Offset the feature coordinates to view coordinate space start point
    featureRectInView.offset(-viewLocationInWindow[0], -viewLocationInWindow[1])

    return featureRectInView
}

Maintenant que nous disposons des informations sur les limites du format d'écran, elles peuvent nous servir à définir la hauteur appropriée pour votre affichage de référence et à la modifier en conséquence.

Le code complet pour alignViewToFoldingFeatureBounds sera le suivant :

MainActivity.kt - alignViewToFoldingFeatureBounds

private fun alignViewToFoldingFeatureBounds(newLayoutInfo: WindowLayoutInfo) {
    val constraintLayout = binding.constraintLayout
    val set = ConstraintSet()
    set.clone(constraintLayout)

    // Get and Translate the feature bounds to the View's coordinate space and current
    // position in the window.
    val foldingFeature = newLayoutInfo.displayFeatures[0] as FoldingFeature
    val bounds = getFeatureBoundsInWindow(foldingFeature, binding.root)

    bounds?.let { rect ->
        // Some devices have a 0px width folding feature. We set a minimum of 1px so we
        // can show the view that mirrors the folding feature in the UI and use it as reference.
        val horizontalFoldingFeatureHeight = (rect.bottom - rect.top).coerceAtLeast(1)
        val verticalFoldingFeatureWidth = (rect.right - rect.left).coerceAtLeast(1)

        // Sets the view to match the height and width of the folding feature
        set.constrainHeight(
            R.id.folding_feature,
            horizontalFoldingFeatureHeight
        )
        set.constrainWidth(
            R.id.folding_feature,
            verticalFoldingFeatureWidth
        )

        set.connect(
            R.id.folding_feature, ConstraintSet.START,
            ConstraintSet.PARENT_ID, ConstraintSet.START, 0
        )
        set.connect(
            R.id.folding_feature, ConstraintSet.TOP,
            ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
        )

        if (foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL) {
            set.setMargin(R.id.folding_feature, ConstraintSet.START, rect.left)
            set.connect(
                R.id.layout_change, ConstraintSet.END,
                R.id.folding_feature, ConstraintSet.START, 0
            )
        } else {
            // FoldingFeature is Horizontal
            set.setMargin(
                R.id.folding_feature, ConstraintSet.TOP,
                rect.top
            )
            set.connect(
                R.id.layout_change, ConstraintSet.TOP,
                R.id.folding_feature, ConstraintSet.BOTTOM, 0
            )
        }

        // Set the view to visible and apply constraints
        set.setVisibility(R.id.folding_feature, View.VISIBLE)
        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é :

5f671f3a33054970.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 (haut, 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.

5318e7a182ee9281.png

8. Autres artefacts Jetpack WindowManager

En plus de l'artefact principal, WindowManager comprend d'autres artefacts utiles qui vous aideront à interagir avec le composant différemment, en tenant compte de l'environnement que vous utilisez actuellement lors de la création de vos applications.

Artefact Java

Si vous utilisez le langage de programmation Java au lieu de Kotlin ou si vous préférez l'écoute d'événements via des rappels comme approche pour votre architecture, l'artefact Java de WindowManager peut être utile, car il fournit une API compatible avec Java permettant d'enregistrer et de supprimer des écouteurs pour des événements via des rappels.

Artefact(s) RxJava

Si vous utilisez déjà RxJava (version 2 ou 3), vous pouvez utiliser des artefacts spécifiques afin de garantir la cohérence du code, que vous utilisiez Observables ou Flowables.

9. Effectuer des tests avec Jetpack WindowManager

Il peut être très utile de tester des positions pliables sur un émulateur ou un appareil afin d'évaluer la façon dont les éléments d'interface utilisateur peuvent être placés autour de la propriété FoldingFeature.

Pour y parvenir, WindowManager comprend un artefact très utile pour les tests d'instrumentation.

Voyons comment l'utiliser.

En plus de la dépendance principale de WindowManager, nous avons ajouté l'artefact de test dans le fichier build.gradle de l'application : androidx.window:window-testing

L'artefact window-testing est fourni avec un nouveau fichier TestRule utile nommé WindowLayoutInfoPublisherRule, qui permettra de tester la consommation d'un flux de valeurs WindowLayoutInfo. WindowLayoutInfoPublisherRule vous permet de transmettre différentes valeurs WindowLayoutInfo à la demande.

Afin de l'utiliser et de pouvoir vous en servir pour créer un exemple utile au test de votre interface utilisateur avec ce nouvel artefact, nous allons mettre à jour la classe de test créée par le modèle Android Studio. Remplacez l'intégralité du code de la classe ExampleInstrumentedTest par le code suivant :

ExampleInstrumentedTest.kt

import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.window.testing.layout.WindowLayoutInfoPublisherRule
import org.junit.Rule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    private val activityRule = ActivityScenarioRule(MainActivity::class.java)
    private val publisherRule = WindowLayoutInfoPublisherRule()

    @get:Rule
    val testRule: TestRule

    init {
        testRule = RuleChain.outerRule(publisherRule).around(activityRule)
    }
}

Comme vous pouvez le voir ci-dessus, nous avons également créé la propriété ActvityScenarioRule avec la règle mentionnée, puis nous les avons associées.

Pour simuler une propriété FoldingFeature, le nouvel artefact est fourni avec deux fonctions très utiles. Nous utiliserons la plus simple, qui fournit des valeurs par défaut.

Dans MainActivity, nous avons aligné les TextView à gauche de la fonctionnalité de pliage. Nous allons créer un test pour vérifier le bon fonctionnement de la mise en œuvre.

Créez un test nommé testText_is_left_of_Vertical_FoldingFeature :

ExampleInstrumentedTest.kt

import androidx.window.layout.FoldingFeature.Orientation.Companion.VERTICAL
import androidx.window.layout.FoldingFeature.State.Companion.FLAT
import androidx.window.testing.layout.FoldingFeature
import androidx.window.testing.layout.TestWindowLayoutInfo
import org.junit.Test

@Test
fun testText_is_left_of_Vertical_FoldingFeature() {
   activityRule.scenario.onActivity { activity ->
       val hinge = FoldingFeature(
           activity = activity,
           state = FLAT,
           orientation = VERTICAL,
           size = 2
       )

       val expected = TestWindowLayoutInfo(listOf(hinge))
       publisherRule.overrideWindowLayoutInfo(expected)
   }

   // Add Assertion with EspressoMatcher here

}

Comme vous pouvez le constater, nous créons un test FoldingFeature qui aura un état FLAT et dont l'orientation sera VERTICAL. Nous avons défini une taille spécifique, car nous souhaitons que le faux FoldingFeature s'affiche dans l'interface utilisateur lors des tests, ce qui nous permet de voir sa localisation sur l'appareil.

Nous utilisons la propriété WindowLayoutInfoPublishRule que nous avons instanciée avant de publier le faux FoldingFeaure, afin de pouvoir l'obtenir comme avec des données WindowLayoutInfo réelles :

La dernière étape consiste à vérifier que les éléments de notre interface utilisateur se trouvent au bon endroit, en évitant la propriété FoldingFeature. Pour ce faire, nous utilisons simplement EspressoMatchers, en ajoutant l'assertion à la fin du test que nous venons de créer :

ExampleInstrumentedTest.kt

import androidx.test.espresso.assertion.PositionAssertions
import androidx.test.espresso.matcher.ViewMatchers.withId

onView(withId(R.id.layout_change)).check(
    PositionAssertions.isCompletelyLeftOf(withId(R.id.folding_feature))
)

Le test complet se présentera comme suit :

ExampleInstrumentedTest.kt

@Test
fun testText_is_left_of_Vertical_FoldingFeature() {
    activityRule.scenario.onActivity { activity ->
        val hinge = FoldingFeature(
            activity = activity,
            state = FoldingFeature.State.FLAT,
            orientation = FoldingFeature.Orientation.VERTICAL,
            size = 2
        )
        val expected = TestWindowLayoutInfo(listOf(hinge))
        publisherRule.overrideWindowLayoutInfo(expected)
    }
    onView(withId(R.id.layout_change)).check(
        PositionAssertions.isCompletelyLeftOf(withId(R.id.folding_feature))
    )
}
val horizontal_hinge = FoldingFeature(
   activity = activity,
   state = FLAT,
   orientation = HORIZONTAL,
   size = 2
)

Vous pouvez désormais exécuter votre test sur un appareil ou un émulateur pour vérifier que l'application se comporte comme prévu. Notez que vous n'avez pas besoin d'appareil pliable ni d'émulateur pour effectuer ce test.

10. Félicitations !

Comme nous l'avons constaté dans cet atelier de programmation, Jetpack WindowManager nous permet d'utiliser de nouveaux facteurs de forme, tels que des modèles pliables.

Les informations fournies par WindowManager sont très utiles pour adapter nos applications aux appareils pliables, et offrir ainsi une expérience utilisateur optimale.

Voici un résumé des concepts 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
  • L'API Jetpack WindowManager
  • L'utilisation de Jetpack WindowManager et l'adaptation de nos applications à de nouveaux facteurs de forme d'appareils
  • La réalisation de tests avec Jetpack WindowManager

En savoir plus