Cómo admitir dispositivos plegables y con pantalla doble con Jetpack WindowManager

1. Antes de comenzar

En este práctico codelab, te enseñaremos los aspectos básicos del desarrollo para dispositivos plegables y con pantalla doble. Cuando termines, la app podrá admitir dispositivos plegables, como Pixel Fold, Microsoft Surface Duo, Samsung Galaxy Z Fold 5, etcétera.

Requisitos previos

Para completar este codelab, necesitas lo siguiente:

Actividades

Crea una app simple con las siguientes características:

  • Muestra las funciones del dispositivo.
  • Detecta si la app se ejecuta en un dispositivo plegable o con pantalla doble.
  • Determina el estado del dispositivo.
  • Usa Jetpack WindowManager para trabajar con nuevos dispositivos de factores de forma.

Requisitos

Android Emulator v30.0.6 y versiones posteriores incluye compatibilidad con dispositivos plegables con sensor de bisagra virtual y vista 3D. Puedes usar algunos emuladores de dispositivos plegables, como se muestra en la siguiente imagen:

ca76200cc00b6ce6.png

  • Si deseas usar un emulador de dispositivos con pantalla doble, puedes descargar el emulador de Microsoft Surface Duo para tu plataforma (Windows, MacOS o GNU/Linux)

2. Dispositivos con una sola pantalla frente a dispositivos plegables

Los dispositivos plegables les brindan a los usuarios una pantalla más grande y una interfaz de usuario más versátil en comparación con las mismas características que se ofrecían anteriormente en dispositivos móviles. Cuando se pliegan, estos dispositivos suelen ser más pequeños que una tablet de tamaño común, lo que los hace más portátiles y funcionales.

Al momento de la redacción de este codelab, existen dos tipos de dispositivos plegables:

  • Dispositivos plegables con una sola pantalla, con una pantalla que se puede plegar. Los usuarios pueden ejecutar varias apps en la misma pantalla al mismo tiempo con el modo multi-window.
  • Dispositivos plegables con pantalla doble, con dos pantallas unidas por una bisagra. Estos dispositivos también se pueden plegar, pero cuentan con dos regiones lógicas de pantalla diferentes.

9ff347a7c8483fed.png

Al igual que las tablets y otros dispositivos móviles con una sola pantalla, los dispositivos plegables pueden hacer lo siguiente:

  • Ejecutar una app en una de las regiones de la pantalla
  • Ejecutar dos apps, una al lado de la otra, en una región diferente de la pantalla (con el modo multi-window)

A diferencia de los dispositivos con una sola pantalla, los dispositivos plegables también admiten diferentes posiciones. Las posiciones se pueden usar para mostrar contenido de la pantalla de maneras diferentes.

bac1d8089687c0c2.png

Los dispositivos plegables pueden ofrecer diferentes posiciones de apertura cuando una app se extiende (se muestra) en toda la región de la pantalla (usando todas las regiones de la pantalla en dispositivos plegables con pantalla doble).

Los dispositivos plegables también pueden ofrecer posiciones plegadas, como el modo de mesa, que permite tener una división lógica entre la parte de la pantalla plana y la parte que se inclina hacia el usuario, y el modo carpa, para que pueda verse el contenido como si el dispositivo contase con un gadget de soporte.

3. Jetpack WindowManager

La biblioteca de Jetpack WindowManager ayuda a los desarrolladores de apps a admitir factores de forma de dispositivos nuevos y proporciona una plataforma de API común para varias funciones de WindowManager en las versiones anteriores y nuevas de la plataforma.

Funciones clave

Jetpack WindowManager versión 1.1.0 contiene la clase FoldingFeature que describe un pliegue en una pantalla flexible o una bisagra entre dos paneles físicos de la pantalla. Su API brinda acceso a información importante que se relaciona con el dispositivo:

  • state(): Proporciona la posición actual del dispositivo a partir de una lista de posiciones definidas (FLAT y HALF_OPENED).
  • isSeparating(): Calcula si se debe considerar que un FoldingFeature divide la ventana en varias áreas físicas que los usuarios pueden ver como separadas de forma lógica.
  • occlusionType(): Calcula el modo de oclusión para determinar si un FoldingFeature ocluye una parte de la ventana.
  • orientation(): Devuelve FoldingFeature.Orientation.HORIZONTAL si el ancho de FoldingFeature es mayor que la altura; de lo contrario, devuelve FoldingFeature.Orientation.VERTICAL.
  • bounds(): Proporciona una instancia de Rect que contiene los límites de la función del dispositivo, por ejemplo, los límites de una bisagra física.

Mediante la interfaz de WindowInfoTracker, puedes acceder a windowLayoutInfo() para recopilar un Flow de WindowLayoutInfo que contiene todos los DisplayFeature disponibles.

4. Configuración

Crea un proyecto nuevo y selecciona la plantilla "Empty Activity".

a5ce5c7fb033ec4c.png

Deja todos los parámetros de configuración predeterminados.

Cómo declarar dependencias

Para usar Jetpack WindowManager, agrega la dependencia en el archivo build.gradle de la app o el módulo:

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"
}

Usa WindowManager

Se puede acceder a las funciones de ventana a través de la interfaz WindowInfoTracker de WindowManager.

Abre el archivo fuente MainActivity.kt y llama a WindowInfoTracker.getOrCreate(this@MainActivity) para inicializar la instancia de WindowInfoTracker asociada con la actividad actual:

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)
}

Con la instancia WindowInfoTracker, obtén la información sobre el estado actual de la ventana del dispositivo.

5. Configura la IU de la app

Desde Jetpack WindowManager, obtén información sobre las métricas de las ventanas, el diseño y la configuración de la pantalla. Puedes ver esto en el diseño de la actividad principal con un TextView para cada uno de ellos.

Crea un objeto ConstraintLayout, con tres TextView centrados en la pantalla.

Abre el archivo activity_main.xml y pega el siguiente contenido:

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>

Ahora, conectaremos estos elementos de la IU en el código mediante la vinculación de vistas. Para ello, comenzamos con su habilitación en el archivo build.gradle de la app:

app/build.gradle

android {
   // Other configurations

   buildFeatures {
      viewBinding true
   }
}

Sincroniza el proyecto de Gradle como lo sugiere Android Studio y usa la vinculación de vistas en MainActivity.kt con el siguiente código:

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. Visualiza la información de WindowMetrics

En el método onCreate de MainActivity, llama a una función para obtener y mostrar la información de WindowMetrics. Agrega una llamada obtainWindowMetrics() al método onCreate:

MainActivity.kt

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

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
}

Implementa el método 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}"
}

Obtén una instancia de WindowMetricsCalculator a través de su función complementaria getOrCreate().

Con esa instancia de WindowMetricsCalculator, configura la información en windowMetrics TextView. Usa los valores que devuelven las funciones computeCurrentWindowMetrics.bounds y computeMaximumWindowMetrics.bounds.

Estos valores brindan información útil sobre las métricas del área que abarca la ventana.

Ejecuta la app. En un emulador para dispositivos con pantalla doble (que se muestra a continuación), obtienes el elemento CurrentWindowMetrics que se ajusta a las dimensiones del dispositivo que replica el emulador. También puedes ver las métricas cuando la app se ejecuta en el modo de solo una pantalla:

f6f0deff678fd722.png

Cuando la app se extiende por las pantallas, las métricas de la ventana cambian como en la siguiente imagen, por lo que ahora reflejan el área de ventana más grande que usa la app:

f1ce73d7198b4990.png

Las métricas actuales y máximas de la ventana tienen los mismos valores, ya que la app siempre está activa y abarca toda el área disponible de la pantalla, tanto en pantalla doble como en solo una pantalla.

En un emulador de dispositivos plegables, con un pliegue horizontal, los valores difieren cuando la app abarca toda la pantalla física y cuando se ejecuta en el modo multiventana:

d00e53154f32d7df.png

Como puedes ver en la imagen a la izquierda, ambas métricas tienen el mismo valor, ya que la app que se ejecuta usa toda el área actual y máxima disponible de la pantalla.

Sin embargo, en la imagen de la derecha, en la que se observa una app ejecutándose en el modo multiventana, puedes ver cómo las métricas actuales muestran las dimensiones del área en la que se ejecuta la app en esa área específica (arriba) del modo de pantalla dividida y puedes ver cómo las métricas máximas muestran el área máxima de la pantalla que tiene el dispositivo.

Las métricas que proporciona WindowMetricsCalculator son muy útiles para determinar el área de la ventana que la app usa o puede usar.

7. Visualiza la información de FoldingFeature

Ahora regístrate para recibir cambios de diseño de la ventana junto con las características y los límites de DisplayFeatures del emulador o dispositivo.

Para recopilar la información de WindowInfoTracker#windowLayoutInfo(), usa el elemento lifecycleScope que se define para cada objeto Lifecycle. Se cancelan todas las corrutinas iniciadas en este alcance cuando se destruye el Lifecycle. Puedes acceder al alcance de las corrutinas de Lifecycle a través de las propiedades lifecycle.coroutineScope o lifecycleOwner.lifecycleScope.

En el método onCreate de MainActivity, llama a una función para obtener y mostrar la información de WindowInfoTracker. Para comenzar, agrega una llamada onWindowLayoutInfoChange() al método 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()
}

Usa la implementación de esa función para obtener información cada vez que cambie una nueva configuración de diseño.

Define la firma de la función y el esqueleto.

MainActivity.kt

private fun onWindowLayoutInfoChange() {
}

Con el parámetro que recibe la función, un objeto WindowInfoTracker, obtén sus datos de WindowLayoutInfo. WindowLayoutInfo contiene la lista de DisplayFeature que se ubican dentro de la ventana. Por ejemplo, una bisagra o plegado de pantalla puede atravesar la ventana, en cuyo caso, puede tener sentido separar el contenido visual y los elementos interactivos en dos grupos (por ejemplo, lista detallada o controles de vista).

Solo se informan las funciones que están presentes dentro de los límites de la ventana actual. Sus posiciones y tamaños pueden cambiar si se mueve la ventana o se cambia su tamaño en la pantalla.

Con el objeto lifecycleScope definido en la dependencia lifecycle-runtime-ktx, obtén un elemento flow de WindowLayoutInfo que contenga una lista de todas las características de la pantalla. Agrega el cuerpo 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)
                }
        }
    }
}

Se llama a la función updateUI desde collect . Implementa esta función para mostrar e imprimir la información recibida de flow de WindowLayoutInfo. Verifica si los datos de WindowLayoutInfo tienen características de la pantalla. Si es así, entonces la característica de la pantalla está interactuando de alguna manera con la IU de la app. Si los datos de WindowLayoutInfo no tienen ninguna función de pantalla, significa que la app se está ejecutando en un dispositivo o modo de una sola pantalla o en el modo multiventana.

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"
    }
}

Ejecuta la app. En un emulador de dispositivos con pantalla doble, tienes lo siguiente:

a6f6452155742925.png

WindowLayoutInfo está vacío. Tiene un elemento List<DisplayFeature> vacío. Pero si tienes un emulador con una bisagra en el medio, ¿por qué no obtienes la información de WindowManager?

WindowManager (mediante WindowInfoTracker) brindará los datos de WindowLayoutInfo (la posición del dispositivo, el tipo de característica y los límites de esa característica) solo cuando la app se extienda por las pantallas (físicas o no). Por lo tanto, en la figura anterior, en donde la app se ejecuta en el modo de solo una pantalla, el elemento WindowLayoutInfo está vacío.

Con esa información, puedes saber en qué modo se está ejecutando la app (modo de una sola pantalla o distribuido en pantallas), de forma que puedas hacer cambios en tu IU y UX, lo que proporcionará una mejor experiencia del usuario que se adapte a estas configuraciones específicas.

En los dispositivos que no cuentan con dos pantallas físicas (en general, no tienen una bisagra física), las apps pueden ejecutarse una al lado de la otra con el modo multiventana. En estos dispositivos, cuando la app se ejecuta en el modo multiventana, funcionará como lo haría en una sola pantalla, como en el ejemplo anterior. Cuando la app se ejecute y ocupe todas las pantallas lógicas, actuará de la misma manera que cuando se expanda. Observa la siguiente imagen:

eacdd758eefb6c3c.png

Cuando la app se ejecuta en el modo multiventana, WindowManager proporciona un List<LayoutInfo> vacío.

En resumen, obtienes datos de WindowLayoutInfo solo cuando la app se ejecuta y ocupa todas las pantallas lógicas, con una intersección entre la función del dispositivo (pliegue o bisagra). En todos los demás casos, no obtendrás información. 32e4190913b452e4.png

¿Qué sucede cuando la app se extiende por las pantallas? En un emulador de dispositivos con pantalla doble, WindowLayoutInfo tendrá un objeto FoldingFeature que brinda datos sobre la característica del dispositivo: un elemento HINGE, los límites de esa característica: Rect (0, 0 - 1434, 1800) y la posición (estado) del dispositivo (FLAT).

586f15def7d23ffd.png

Veamos qué significa cada campo:

  • type = TYPE_HINGE: Este emulador de dispositivos con pantalla doble duplica un dispositivo Surface Duo real que cuenta con una bisagra física, y es lo que informa WindowManager.
  • Bounds [0, 0 - 1434, 1800]: Representa el rectángulo delimitador de la característica dentro de la ventana de la aplicación en el espacio de coordenadas de la ventana. Si lees las especificaciones de las dimensiones del dispositivo Surface Duo, verás que la bisagra se encuentra en la posición exacta informada por estos límites (izquierda, superior, derecha o inferior).
  • State: Existen dos valores diferentes que representan la posición (estado) del dispositivo.
  • HALF_OPENED: Es la bisagra del dispositivo plegable, que se encuentra en una posición intermedia entre el estado abierto y cerrado; hay un ángulo no llano entre las partes de la pantalla flexible o entre los paneles de las pantallas físicas.
  • FLAT: El dispositivo plegable está abierto por completo, y el espacio de pantalla que se le presenta al usuario es plano.

El emulador predeterminado está abierto a 180 grados, por lo que la posición que devuelve WindowManager es FLAT.

Si cambias la posición del emulador con la opción de sensores virtuales a la posición semiabierta, WindowManager te notificará sobre la nueva posición: HALF_OPENED.

cba02ab39d6d346b.png

Cómo usar WindowManager para adaptar tu IU y UX

Como se ve en las figuras que muestran la información del diseño de la ventana, la característica de la pantalla cortó la información que se mostraba. Lo mismo sucede aquí:

ff2caf93916f1682.png

Esta no es una experiencia del usuario óptima. Puedes usar la información que proporciona WindowManager para ajustar tu IU y UX.

Como se observó anteriormente, cuando la app se extiende por todas las regiones diferentes de la pantalla, también tu app se superpone con la característica del dispositivo, de modo que WindowManager brinda la información del diseño de la ventana como el estado de la pantalla y los límites de la pantalla. Por lo tanto, cuando se extiende la app, deberás usar esta información para ajustar tu IU y UX.

Luego, deberás ajustar la IU/UX que tienes actualmente en el tiempo de ejecución cuando se extiende la app, de manera que la función característica de la pantalla no corte ni oculte la información importante. Crearás una vista que replique la característica de pantalla del dispositivo y se usará como referencia para restringir el elemento TextView que está oculto o cortado, de modo que ya no te falte información.

Para fines de aprendizaje, colorea esta vista nueva para que sea fácil ver que se encuentra, específicamente, en el mismo lugar en el que se encuentra la característica de la pantalla del dispositivo real y con las mismas dimensiones.

Agrega la vista nueva que usarás como referencia de la característica del dispositivo en 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" />

En MainActivity.kt, ve a la función updateUI() que usaste para mostrar la información de WindowLayoutInfo y agrega una nueva llamada a la función en el caso "if-else" en el que tenías una característica de la pantalla:

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"
   }
}

Agregaste la función alignViewToFoldingFeatureBounds que recibe como parámetro el elemento WindowLayoutInfo.

Crea esa función. Dentro de la función nueva, crea tu elemento ConstraintSet para aplicar restricciones nuevas a tus vistas. Luego, obtén los límites de la característica de la pantalla con el elemento WindowLayoutInfo. Dado que WindowLayoutInfo devuelve una lista de DisplayFeature que es solo una interfaz, transfiérela a FoldingFeature para obtener acceso a toda la información:

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
}

Define una función getFeatureBoundsInWindow() para traducir los límites de los atributos al espacio de coordenadas de la vista y la posición actual en la ventana.

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
}

Con la información sobre los límites de la característica de la pantalla, puedes usarla para establecer el tamaño de altura correcto para tu vista de referencia y moverla según corresponda.

El código completo de alignViewToFoldingFeatureBounds será el siguiente:

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)
    }
}

Ahora, el elemento TextView que generó un conflicto con la característica de la pantalla del dispositivo tiene en cuenta la ubicación de la característica, por lo que su contenido nunca se cortará ni se ocultará:

67b41810704d0011.png

En el emulador de dispositivos con pantalla doble (arriba o izquierda), puedes ver que el objeto TextView que mostraba el contenido en todas las pantallas y que estaba cortado por la bisagra ya no está cortado, por lo que no falta información.

En un emulador de dispositivos plegables (derecha), verás una línea roja que representa el lugar donde se encuentra la característica de la pantalla plegable; el elemento TextView se ubica debajo de esta línea, de modo que cuando se pliega el dispositivo (p. ej., a 90 grados en una posición de laptop), la característica no afecta la información.

Si te preguntas dónde se encuentra la característica de la pantalla en el emulador de dispositivos con pantalla doble, ya que se trata un dispositivo tipo bisagra, la vista que representa la característica se oculta con la bisagra. Sin embargo, si la app cambia de un intervalo a otro, la verás en la misma posición que el componente con la altura y el ancho correctos.

1a309ab775c49a6a.png

8. Otros artefactos de Jetpack WindowManager

WindowManager también incluye otros artefactos útiles, además del principal, que te ayudarán a interactuar con el componente de manera diferente, teniendo en cuenta el entorno actual que usas cuando compilas tus apps.

Artefacto de Java

Si usas el lenguaje de programación Java en lugar de Kotlin, o si resulta un mejor enfoque para tu arquitectura escuchar eventos a través de devoluciones de llamada, el artefacto Java de WindowManager puede resultar útil, ya que proporciona una API compatible con Java para registrar y cancelar el registro de objetos de escucha en los eventos mediante devoluciones de llamada.

Artefactos de RxJava

Si ya usas RxJava (versión 2 o 3), puedes usar artefactos específicos que te ayudan a mantener la coherencia en tu código, ya sea que uses Observables o Flowables.

9. Cómo realizar pruebas con Jetpack WindowManager

Probar posiciones plegables en cualquier emulador o dispositivo puede ser muy útil para probar cómo se pueden colocar los elementos de la IU alrededor de FoldingFeature.

Para lograrlo, WindowManager incluye un artefacto muy útil para las pruebas instrumentadas.

Veamos cómo podemos usarlo.

Junto con la dependencia principal de WindowManager, agregamos el artefacto de prueba al archivo build.gradle de la app: androidx.window:window-testing

El artefacto window-testing viene con un nuevo TestRule útil llamado WindowLayoutInfoPublisherRule que ayudará a probar el consumo de un flujo de valores WindowLayoutInfo. WindowLayoutInfoPublisherRule te permite enviar diferentes valores de WindowLayoutInfo a pedido.

Para usarlo, y a partir de allí crear una muestra que pueda ayudarte a probar tu IU con este artefacto nuevo, actualiza la clase de prueba creada por la plantilla de Android Studio. Reemplaza todo el código de la clase ExampleInstrumentedTest por lo siguiente:

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 regla mencionada se encadena con un ActvityScenarioRule.

Para simular un elemento FoldingFeature, el nuevo artefacto incluye un par de funciones muy útiles. Este es el más simple que proporciona algunos valores predeterminados.

En MainActivity, los objetos TextView se alinean a la izquierda de la función plegable. Crea una prueba que verifique que se implementó correctamente.

Crea una prueba llamada 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

}

La prueba FoldingFeature tiene un estado FLAT y su orientación es VERTICAL. Definimos un tamaño específico porque queremos que el FoldingFeature falso se muestre en la IU en nuestras pruebas para que podamos ver dónde se encuentra en el dispositivo.

Usamos el WindowLayoutInfoPublishRule del que creamos una instancia antes para publicar el FoldingFeaure falso, de modo que podamos obtenerlo como lo haríamos con datos reales de WindowLayoutInfo:

El último paso es probar que nuestros elementos de la IU estén ubicados donde deben alinearse y así evitar el FoldingFeature. Para ello, simplemente usamos EspressoMatchers y agregamos la aserción al final de la prueba:

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))
)

La prueba completa será la siguiente:

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
)

Ahora, puedes ejecutar la prueba en un dispositivo o emulador para comprobar que la aplicación se comporte como se espera. Ten en cuenta que no necesitas un dispositivo plegable ni un emulador para ejecutar esta prueba.

10. ¡Felicitaciones!

Jetpack WindowManager ayuda a los desarrolladores con dispositivos nuevos de factores de forma, como dispositivos plegables.

La información que proporciona WindowManager es muy útil para adaptar las apps para Android a dispositivos plegables y brindar una mejor experiencia del usuario.

En resumen, en este codelab aprendiste lo siguiente:

  • Qué son los dispositivos plegables
  • Las diferencias entre dispositivos plegables
  • Las diferencias entre los dispositivos plegables, los que tienen una sola pantalla y las tablets
  • La API de Jetpack WindowManager
  • Cómo usar Jetpack WindowManager y adaptar nuestras apps a nuevos factores de forma de dispositivos
  • Cómo realizar pruebas con Jetpack WindowManager

Más información