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. Une fois que vous avez terminé, l'appli est compatible avec les appareils pliables comme le Pixel Fold, le Microsoft Surface Duo, le Samsung Galaxy Z Fold 5, etc.

Conditions préalables

Voici les conditions à remplir pour effectuer cet atelier de programmation :

Objectifs de l'atelier

Créer une application simple qui :

  • 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 :

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

9ff347a7c8483fed.png

Comme les tablettes et les autres appareils mobiles à écran unique, les appareils 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.

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

Caractéristiques principales

La version 1.1.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 (FLAT et 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, et FoldingFeature.Orientation.VERTICAL dans le cas contraire.
  • 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. Configuration

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

a5ce5c7fb033ec4c.png

Conservez les valeurs par défaut de tous les paramètres.

Déclarer des dépendances

Pour utiliser Jetpack WindowManager, ajoutez la dépendance dans le fichier build.gradle de l'application ou du module :

app/build.gradle

dependencies {
    ext.windowmanager_version = "1.1.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.6.2"
}

Utiliser WindowManager

Les fonctionnalités de fenêtre sont accessibles 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)
}

Avec l'instance WindowInfoTracker, obtenez les informations sur l'état de la fenêtre actuelle de l'appareil.

5. Configurer l'interface utilisateur de l'application

Dans Jetpack WindowManager, obtenez des informations sur les métriques de fenêtre, la mise en page et la configuration de l'affichage. Affichez cela dans la mise en page de l'activité principale, en utilisant une propriété TextView pour chacun de ces éléments.

Créez un ConstraintLayout, avec trois propriétés TextView, centré 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
   }
}

Synchronisez le projet Gradle comme suggéré par Android Studio et utilisez la liaison de vue 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 la MainActivity, appelez une fonction pour obtenir et afficher les informations WindowMetrics. Ajoutez un appel obtainWindowMetrics() dans 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()
}

Implémentez 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}"
}

Obtenez une instance de WindowMetricsCalculator via sa fonction associée getOrCreate().

À l'aide de cette instance WindowMetricsCalculator, définissez les informations dans la propriété TextView de windowMetrics. Nous utilisons les valeurs renvoyées par les fonctions computeCurrentWindowMetrics.bounds et computeMaximumWindowMetrics.bounds.

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

Exécutez l'application. Dans un émulateur double écran (voir ci-dessous), vous obtenez les propriétés CurrentWindowMetrics qui correspondent aux dimensions de l'appareil dont le comportement est simulé. Vous pouvez également consulter ces chiffres lorsque l'application s'exécute sur un seul écran.

f6f0deff678fd722.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 :

f1ce73d7198b4990.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'écran physique ou en mode multifenêtre :

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

Maintenant, enregistrez-vous 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(), utilisez la propriété lifecycleScope définie pour chaque objet Lifecycle. Toute coroutine lancée au sein de ce champ d'application est annulée lorsque le Lifecycle est détruit. Vous pouvez accéder au champ d'application de coroutine du cycle de vie via les propriétés lifecycle.coroutineScope ou lifecycleOwner.lifecycleScope.

Dans la méthode onCreate de la MainActivity, appelez une fonction pour obtenir et afficher les informations WindowInfoTracker. Commencez 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()
}

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

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, obtenez 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, obtenez le flow de WindowLayoutInfo, qui contient une liste de toutes les fonctionnalités d'affichage. Ajoutez 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)
                }
        }
    }
}

La fonction updateUI est appelée à partir de collect. Implémentez cette fonction pour afficher et imprimer les informations reçues du flow de WindowLayoutInfo. Vérifiez 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 l'application. Si les données WindowLayoutInfo n'ont pas de fonctionnalités d'affichage, cela signifie que l'application s'exécute sur un appareil ou un mode à écran unique, ou en 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"
    }
}

Exécutez l'application. Dans un émulateur double écran, vous obtenez le résultat suivant :

a6f6452155742925.png

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 ses écrans, et position de l'appareil) que lorsque l'application est diffusée sur plusieurs écrans (physiques ou non). Ainsi, sur l'image 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 affichage 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 exécuté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. Consultez l'image suivante :

eacdd758eefb6c3c.png

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'obtenez aucune information. 32e4190913b452e4.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 ses écrans (Rect (0, 0 – 1434, 1800)) et la position (l'état) de l'appareil (FLAT).

586f15def7d23ffd.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 défini par les 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 non plat 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.

cba02ab39d6d346b.png

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

Comme le montrent les images 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 :

ff2caf93916f1682.png

Ce n'est 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.

À des fins d'apprentissage, colorez ce nouvel affichage de façon à ce qu'il se trouve précisément au même endroit que le véritable écran, dans les mêmes dimensions.

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 WindowLayoutInfo comme paramètre.

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, castez-la sur FoldingFeature pour accéder à toutes les informations :

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
}

Définissez 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
}

Avec les informations sur les limites du format d'écran, vous pouvez 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é :

67b41810704d0011.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. Toutefois, si vous désactivez 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.

1a309ab775c49a6a.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, mettez à 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)
    }
}

La règle mentionnée est enchaînée à un élément ActvityScenarioRule.

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

Dans MainActivity, les éléments TextView sont alignés à gauche de la fonctionnalité de pliage. Créez un test qui vérifie que l'implémentation a été effectuée correctement.

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

}

Le test FoldingFeature est à l'état FLAT et son orientation est 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 !

Jetpack WindowManager permet aux développeurs d'utiliser de nouveaux facteurs de forme, tels que des appareils pliables.

Les informations fournies par WindowManager sont très utiles pour adapter les applications Android aux appareils pliables afin d'améliorer l'expérience utilisateur.

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
  • Effectuer des tests avec Jetpack WindowManager

En savoir plus