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

En este práctico codelab, te enseñaremos los aspectos básicos del desarrollo para dispositivos plegables y con pantalla doble. Cuando hayas terminado, podrás mejorar tu app para admitir dispositivos, como Microsoft Surface Duo y Samsung Galaxy Z Fold 2.

Requisitos previos

Para completar este codelab, necesitarás lo siguiente:

  • Experiencia en el desarrollo de apps para Android
  • Experiencia con actividades, fragmentos, vinculación de vista, diseños xml
  • Experiencia en la incorporación de dependencias a tus proyectos
  • Experiencia en la instalación y el uso de emuladores de dispositivos. En este codelab, usarás un emulador de dispositivos plegables y con pantalla doble.

Actividades

  • Crear una app simple y mejorarla para admitir dispositivos plegables y con pantalla doble.
  • Usa Jetpack WindowManager para trabajar con nuevos dispositivos de factores de forma.

Requisitos

  • Android Studio 4.2 o una versión posterior
  • Un dispositivo plegable o emulador; si usas Android Studio 4.2, existen algunos emuladores de dispositivos plegables que puedes usar, como se muestra en la siguiente imagen:

7a0db14df3576a82.png

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

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. También ofrecen otro beneficio: cuando se pliegan, estos dispositivos suelen ser más pequeños que una tablet de tamaño regular, lo que permite que sean 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.

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

f2287b68f32b59e3.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 para 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.

La biblioteca Jetpack WindowManager se diseñó para permitir que los desarrolladores realicen ajustes en sus apps y aprovechen la nueva experiencia que estos dispositivos les brindan a los usuarios. Jetpack WindowManager permite que los desarrolladores de aplicaciones admitan nuevos factores de forma de dispositivos y proporciona una superficie de API común para características diferentes de WindowManager en las versiones nuevas y anteriores de la plataforma.

Funciones clave

La versión 1.0.0-alpha03 de Jetpack WindowManager incluye la clase FoldingFeature que describe un pliegue en la 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:

Mediante la clase WindowManager principal, puedes acceder a información importante, por ejemplo:

  • getCurrentWindowMetrics(): Muestra el elemento WindowMetrics según el estado actual del sistema. El valor se basa en el estado actual de la renderización en ventanas del sistema.
  • getMaximumWindowMetrics(): Muestra el elemento WindowMetrics más grande según el estado actual del sistema. El valor se basa en el estado posible más grande de la renderización en ventanas del sistema. Por ejemplo, para las actividades en el modo multiventana, las métricas que se muestran se basan en los límites que tendría si el usuario expandiese la ventana con el objeto de cubrir toda la pantalla.

Clona el repositorio de GitHub o descarga el código de ejemplo para la app que mejorarás:

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

Cómo declarar dependencias

Para usar Jetpack WindowManager, debes agregarle la dependencia.

  1. Primero, agrega el repositorio de Maven de Google a tu proyecto.
  2. Agrega la dependencia del artefacto en el archivo build.gradle para tu app o módulo:
dependencies {
    implementation "androidx.window:window:1.0.0-alpha03"
}

Cómo usar WindowManager

Jetpack WindowManager se puede usar de manera muy sencilla. Para ello, registra tu app a fin de que detecte los cambios de configuración.

Primero, inicializa la instancia de WindowManager para tener acceso a su API. Para inicializar la instancia de WindowManager, implementa el siguiente código en tu actividad:

private lateinit var wm: WindowManager

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

        wm = WindowManager(this)
}

El constructor principal solo permite un parámetro: un contexto visual, como un elemento Activity o un parámetro ContextWrapper alrededor de una actividad. Internamente, este constructor usará un elemento WindowBackend predeterminado. Esta es una clase de servidor de respaldo que brinda información para esta instancia.

Una vez que tengas tu instancia de WindowManager, podrás registrar una devolución de llamada para saber cuándo se producen los cambios de posición, con qué características cuenta el dispositivo y los límites de esa característica (si hubiera). Además, como se mencionó anteriormente, puedes ver las métricas actuales y máximas según el estado actual del sistema.

  1. Abre Android Studio.
  2. Haz clic en File > New > New Project > Empty Activity para crear un proyecto nuevo.
  3. Haz clic en Next, acepta las propiedades y los valores predeterminados, y haz clic en Finish.

Ahora, crea un diseño simple para que puedas ver la información que informará WindowManager. Para ello, deberás crear la carpeta del diseño y el archivo del diseño específico:

  1. Haz clic en File > New > Android resource directory.
  2. En la ventana nueva, selecciona Resource Type layout y haz clic en OK.
  3. Ve a la estructura del proyecto y en src/main/res/layout crea un archivo nuevo de recurso de diseño (File > New > Layout resource file) que se denomina activity_main.xml.
  4. Abre el archivo y agrega este contenido como tu diseño:

res/layout/activity_main.xml

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

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

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

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

</androidx.constraintlayout.widget.ConstraintLayout>

Ahora, creaste un diseño simple basado en un elemento ConstraintLayout con tres parámetros TextViews. Las vistas están limitadas entre ellas a fin de alinearse con el centro del elemento superior (y la pantalla).

  1. Abre el archivo MainActivity.kt y agrega el siguiente código:

window_manager/MainActivity.kt

class MainActivity : AppCompatActivity() {
  1. Crea una clase interna que te permita controlar el resultado de las devoluciones de llamada:
inner class LayoutStateChangeCallback : Consumer<WindowLayoutInfo> {
   override fun accept(newLayoutInfo: WindowLayoutInfo) {
       printLayoutStateChange(newLayoutInfo)
   }
}

Las funciones que usan las clases internas son funciones simples que imprimirán la información que obtengas de WindowManager con tus componentes de IU (TextView):

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}
  1. Declara una variable lateinit WindowManager:
private lateinit var wm: WindowManager
  1. Crea una variable que controle las devoluciones de llamada con WindowManager a través de las clases internas que ya creaste:
private val layoutStateChangeCallback = LayoutStateChangeCallback()
  1. Agrega una vinculación para que podamos acceder a las vistas diferentes:
private lateinit var binding: ActivityMainBinding
  1. Ahora, crea una función que se extienda de Executor para que se la puedas brindar a la devolución de llamada como el primer parámetro y que se usará cuando se llame a la devolución de llamada. En este caso, crearás una que se ejecute en el subproceso de IU. Puedes crear una diferente que no se ejecute en el subproceso de IU. Depende de ti.
private fun runOnUiThreadExecutor(): Executor {
   val handler = Handler(Looper.getMainLooper())
   return Executor() {
       handler.post(it)
   }
}
  1. En el elemento onCreate de MainActivity, inicializa el parámetro WindowManager lateinit:
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   wm = WindowManager(this)
}

Ahora, la instancia de WindowManager tiene el elemento Activity como el único parámetro y usará la implementación predeterminada de backend de WindowManager.

  1. Busca la función que agregaste en el paso 5. Justo debajo del encabezado de la función, agrega esta línea:
binding.windowMetrics.text =
   "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
       "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

Aquí, configuras el valor de window_metrics TextView con los valores que incluyen las funciones currentWindowMetrics.bounds.flattenToString() y maximumWindowMetrics.bounds.flattenToString().

Estos valores brindan información útil sobre las métricas del área que abarca la ventana. Como se muestra en la siguiente imagen, en un emulador de dispositivos con pantalla doble, se obtiene el elemento CurrentWindowMetrics que se ajusta a las dimensiones del dispositivo que se duplica. También puedes ver las métricas cuando la app se ejecuta en el modo de solo una pantalla:

b032c729d6dce292.png

Además, a continuación, puedes ver cómo cambian las métricas cuando la app se extiende por las pantallas, por lo que ahora reflejan el área de ventana más grande que usa la app:

b72ca8a63b65e4c1.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 se ejecuta, se extiende por toda la pantalla física y usa el modo multiventana:

5cb5270ee0e42320.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 multiventana 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 brinda WindowManager son muy útiles para conocer el área de la ventana que la app usa o puede usar.

Ahora, te registrarás para realizar los cambios de diseño, de modo que puedas conocer la característica del dispositivo (si tiene bisagra o es plegable) y los límites de la característica.

La función que debemos usar tiene la siguiente firma:

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

Esta función usa el tipo WindowLayoutInfo. Esta clase tiene los datos que necesitas analizar cuando se llama a la devolución de llamada. Esta clase contiene internamente una lista List< DisplayFeature> que mostrará una lista de DisplayFeatures en el dispositivo que se superpone con la app. Si ninguna característica de la pantalla se superpone con la app, la lista puede estar vacía.

Esta clase implementa DisplayFeature y, una vez que obtienes el elemento List<DisplayFeature> como resultado, puedes transmitir (los elementos) a FoldingFeature, donde obtendrás información del dispositivo, como la posición, el tipo de característica del dispositivo y los límites.

Veamos cómo puedes usar esta devolución de llamada y visualizar la información que brinda. En el código que ya agregaste en el paso anterior (Cómo compilar tu app de ejemplo), haz lo siguiente:

  1. Reemplaza el método onAttachedToWindow:
override fun onAttachedToWindow() {
   super.onAttachedToWindow()
  1. Usa la instancia de WindowManager que se registra en la devolución de llamada de los cambios de diseño, mediante el ejecutor que implementaste antes como primer parámetro:
   wm.registerLayoutChangeCallback(
       runOnUiThreadExecutor(),
       layoutStateChangeCallback
   )
}

Veamos cómo es la información que brinda esta devolución de llamada. Si ejecutas este código en el emulador de dispositivos con pantalla doble, tendrás lo siguiente:

49a85b4d10245a9d.png

Como puedes observar, el elemento 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 brindará los datos de LayoutInfo (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.

Si tienes en cuenta esto, sabrás el modo en el que se ejecuta la app (modo de solo una pantalla o extendido) y podrás realizar cambios en tu IU y UX, y brindarles una mejor experiencia a tus usuarios, en función de 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 si estuviese abierta en una sola pantalla, como en el ejemplo anterior, y cuando la app se ejecuta y abarca todas las pantallas lógicas, funcionará como si la app se extendiera, lo que se puede observar en la siguiente figura:

ecdada42f6df1fb8.png

Como puedes apreciar, cuando la app se ejecuta en el modo multiventana, no se superpone con la característica plegable, por lo que WindowManager mostrará un elemento List<LayoutInfo> vacío.

En resumen, obtendrás datos de LayoutInfo solo cuando la app se superponga con la característica del dispositivo (pliegue o bisagra). De lo contrario, no obtendrás información. 564eb78fc85f6d3e.png

¿Qué sucede cuando la app se extiende por las pantallas? En un emulador de dispositivos con pantalla doble, LayoutInfo 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

13edea3ff94baae4.png

El tipo de dispositivo, como se mencionó anteriormente, puede tener dos valores: FOLD y HINGE, como también se expone en su código fuente:

@IntDef({
       TYPE_FOLD,
       TYPE_HINGE,
})
  • 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.
  • Rect (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, observarás que la bisagra se ubica en el lugar en el que se une con los límites informados (izquierda, superior, derecha, inferior).
  • Existen tres valores diferentes que representan la posición (estado) del dispositivo:
  • STATE_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.
  • STATE_FLAT: Es el dispositivo plegable está abierto por completo, y el espacio de pantalla que se le presenta al usuario es plano.
  • STATE_FLIPPED: Se giró el dispositivo plegable con las partes de la pantalla flexible o las pantallas físicas ubicadas en direcciones opuestas.
@IntDef({
       STATE_HALF_OPENED,
       STATE_FLAT,
       STATE_FLIPPED,
})

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

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

7cfb0b26d251bd1.png

Puedes cancelar el registro de esta devolución de llamada cuando ya no la necesites. Solo debes llamar a esta función desde la API de WindowManager:

public void unregisterDeviceStateChangeCallback (Consumer<DeviceState> callback)

El método onDestroy o onDetachedFromWindow sería un buen lugar para cancelar el registro de tu devolución de llamada:

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

Cómo usar WindowManager para adaptar tu IU y UX

Como se observó en las figuras que muestran la información del diseño de la ventana, la característica de la pantalla cortaba la información que se mostraba, como se puede ver aquí:

4ee805070989f322.png

Esta no es la mejor experiencia que puedes ofrecerles a los usuarios. 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 la característica 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 y UX que tienes actualmente en tiempo de ejecución cuando se extiende la app, de manera de que la característica de la pantalla no corte ni oculte información importante. Crearás una vista que duplique la característica de la pantalla del dispositivo y que se usará como referencia para restringir el elemento TextView que está oculto o cortado, de modo que ya no te falte información.

Por fines didácticos, elegirás un color para esta vista nueva, de manera que puedas ver, con facilidad, que se encuentra, específicamente, en el mismo lugar que la característica de la pantalla del dispositivo real y con las mismas dimensiones.

  1. Agrega la vista nueva que usarás como referencia de la característica del dispositivo en activity_main.xml.

res/layout/activity_main.xml

<View
   android:id="@+id/device_feature"
   android:layout_width="0dp"
   android:layout_height="0dp"
   android:background="@android:color/holo_red_dark"
   android:visibility="gone" />
  1. En MainActivity.kt, ve a la función que usaste para mostrar la información de las devoluciones de llamada de WindowManager 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:

window_manager/MainActivity.kt

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

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

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

  1. Dentro de la función nueva, crea tu elemento ConstraintSet a fin de aplicar restricciones nuevas a tus vistas:
private fun alignViewToDeviceFeatureBoundaries(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)
  1. Ahora, obtén los límites de la característica de la pantalla con el elemento WindowLayoutInfo:
val rect = newLayoutInfo.displayFeatures[0].bounds
  1. Ahora, con el elemento WindowLayoutInfo aprovisionado en tu variable rect, establece el tamaño de altura correcto para tu vista de referencia:
set.constrainHeight(
   R.id.device_feature,
   rect.bottom - rect.top
)
  1. Ahora, ajusta la vista al ancho de la característica de la pantalla, en función de las coordenadas de la derecha - las coordenadas de la izquierda, de manera que sepas el ancho de la característica del dispositivo:
set.constrainWidth(R.id.device_feature, rect.right - rect.left)
  1. Establece las restricciones de alineación para tu referencia de vista, de modo que se alinee su elemento superior en el inicio y en las partes superiores:
set.connect(
   R.id.device_feature, ConstraintSet.START,
   ConstraintSet.PARENT_ID, ConstraintSet.START, 0
)
set.connect(
   R.id.device_feature, ConstraintSet.TOP,
   ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
)

También puedes agregar estas restricciones directamente en xml como atributos para nuestra vista, en lugar de hacerlo en el código.

A continuación, querrás cubrir toda la posición posible de la característica del dispositivo: dispositivos que cuentan con una característica de la pantalla en posición vertical (como el emulador de dispositivos con pantalla doble) y dispositivos que cuentan con la característica de la pantalla en posición horizontal (como el emulador de dispositivos plegables con pliegue horizontal).

  1. En la primera situación, top == 0 indica que la característica del dispositivo se ubicará en posición vertical (como nuestro emulador de dispositivos con pantalla doble):
if (rect.top == 0) {
  1. Ahora, es donde le aplicas el margen a tu vista de referencia, de modo que se ubique en la misma posición en la que está en nuestra característica de la pantalla real.
  2. Luego, aplica la restricción al elemento TextView que deseas que se ubique mejor a fin de evitar la característica de la pantalla. De esta manera, su restricción tiene en cuenta la característica:
set.setMargin(R.id.device_feature, ConstraintSet.START, rect.left)
set.connect(
   R.id.layout_change, ConstraintSet.END,
   R.id.device_feature, ConstraintSet.START, 0
)

Características de la pantalla horizontal

Es posible que los dispositivos de tus usuarios cuenten con una característica de pantalla de posición horizontal (como nuestro emulador de dispositivos plegables con pliegue horizontal).

Según tu IU, es posible que tengas una barra de herramientas o una barra de estado para mostrar. Por lo tanto, te recomendamos que consultes sus alturas, de modo que la representación de la característica de tu pantalla se adapte a la perfección a tu IU.

Nuestra app de ejemplo cuenta con la barra de estado y la barra de herramientas:

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

Una implementación simple de las funciones para realizar estos cálculos (que se ubican fuera de nuestra función actual) es la siguiente:

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

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

De regreso a la función principal en tu declaración "else", donde controlas la característica del dispositivo horizontal, para el margen, puedes usar la altura de la barra de estado y la altura de la barra de herramientas, ya que los límites de la característica de la pantalla no tienen en cuenta ningún elemento de la IU que tenemos y se toman de las coordenadas (0,0). Debes tener en cuenta estos elementos para ubicar nuestra vista de referencia en el lugar correcto:

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

El siguiente paso consiste en cambiar la visibilidad de la vista de referencia, de modo que puedas verla en tu ejemplo (en color rojo) y, sobre todo, que se apliquen las restricciones. Si la vista desapareció, no se aplicarán restricciones:

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

En el último paso, aplica el parámetro ConstraintSet que compilaste para ConstraintLayout, a fin de implementar todos los cambios y los ajustes de la IU:

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

80993d3695a9a60.png

En el emulador de dispositivos con pantalla doble (izquierda), puedes observar cómo el elemento TextView que mostraba el contenido en todas las pantallas y que fue cortado por la bisagra dejó de estar cortado. Por lo tanto, 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 contraemos la app expandida, verás la bisagra en la misma posición de la característica, con la altura y el ancho correctos.

4dbe464ac71b498e.png

Hasta ahora, aprendiste la diferencia entre los dispositivos plegables y los dispositivos con una sola pantalla.

Una de las características que ofrecen los dispositivos plegables es la opción de ejecutar dos apps, una al lado de la otra, para que puedas lograr más con menos. Por ejemplo, los usuarios pueden mostrar su app de correo electrónico de un lado y la app de calendario en el otro o realizar una videollamada en una pantalla y tomar notas en la otra. Existen tantas posibilidades.

Puedes aprovechar dos pantallas con solo usar las API existentes que se incluyen en el framework de Android. Veamos algunas mejoras que puedes hacer.

Cómo iniciar una actividad en la ventana adyacente

Esta mejora permite que tu app inicie una actividad nueva en la ventana adyacente, a fin de aprovechar las áreas de varias ventanas al mismo tiempo, sin necesidad de mucho esfuerzo.

Imagina que tienes un botón y que, si haces clic en él, la app inicia la actividad nueva:

  1. Primero, crea la función que controlará el evento de clic:

intent/MainActivity.kt

private fun openActivityInAdjacentWindow() {
}
  1. Dentro de la función, crea el elemento Intent que se usará para iniciar la actividad nueva (en este caso, se denomina SecondActivity. Es solo una actividad simple con un elemento TextView como mensaje):
val intent = Intent(this, SecondActivity::class.java)
  1. Luego, configura las marcas que iniciarán la actividad nueva cuando la pantalla adyacente esté vacía:
intent.addFlags(
   Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or
       Intent.FLAG_ACTIVITY_NEW_TASK
)

Estas marcas hacen lo siguiente:

  • FLAG_ACTIVITY_NEW_TASK = Si se establece, esta actividad se convertirá en el inicio de una tarea nueva en esta pila de historial.
  • FLAG_ACTIVITY_LAUNCH_ADJACENT = Esta marca se usa para el modo multiventana de pantalla dividida (y también para dispositivos de pantalla doble con pantallas físicas independientes). La actividad nueva se mostrará junto a la que la inicie.

La plataforma, cuando detecte una tarea nueva, intentará usar la ventana adyacente para asignarla allí. La tarea nueva se iniciará sobre tu tarea actual, por lo que la actividad nueva se iniciará sobre la actual.

  1. El último paso consiste simplemente en iniciar la actividad nueva con el elemento Intent que creamos:
     startActivity(intent)

La app de prueba resultante se comportará como se muestra en las siguientes animaciones, en las que, cuando se hace clic en un botón, se inicia una actividad nueva en la ventana adyacente vacía.

Puedes observar cómo se ejecuta en un dispositivo con doble pantalla y en un dispositivo plegable en el modo multiventana:

9696f7fa2ee1e35f.gif a2dc98dae26e3045.gif

Arrastrar y soltar

Agregar la función de arrastrar y soltar a tus apps puede brindar una funcionalidad muy útil que a tus usuarios les podría encantar. Esta funcionalidad permite que tu app brinde contenido a otras apps (con la implementación de la función de arrastrar), acepte contenido de otras apps (con la implementación de la función de soltar), o puede incluir ambas características. De esta forma, tu app puede brindar y aceptar contenido de otras apps y propio (p. ej., contenido en lugares diferentes dentro de la misma app).

La función de arrastrar y soltar está disponible en el framework de Android desde la API 11, pero, solo cuando se introdujo la compatibilidad con Multi-Window en el nivel 24 de API, la función de arrastrar y soltar cobró más sentido, ya que podías arrastrar y soltar elementos entre las apps que se ejecutaban, una al lado de la otra, en la misma pantalla.

Ahora, con la introducción de dispositivos plegables que pueden tener más área para fines del modo multiventana o incluso dos pantallas lógicas diferentes, la función de arrastrar y soltar tiene más sentido. Entre las situaciones prácticas, se incluye una app de tareas pendientes que acepta texto (con la función de soltar) que se convierte en una tarea nueva cuando se suelta, o una app de calendario que acepta contenido (con la función de soltar) en un horario disponible de día/hora y se convierte en un evento, etcétera.

Las apps deben implementar el comportamiento de la función de arrastrar para convertir consumidores de datos o el comportamiento de la función de soltar a fin de convertir productores de datos con el objeto de aprovechar esta funcionalidad.

En tu ejemplo, implementarás la función de arrastrar en una app y la función de soltar en otra app diferente. Sin embargo, puedes implementar, sin duda, la acción de arrastrar y soltar en la misma app.

Cómo implementar la función de arrastrar

Tu app con la función de arrastrar simplemente tendrá un elemento TextView y activará la acción de arrastrar cuando el usuario realice un clic largo en este elemento.

  1. Primero, para crear una app nueva, ve a File > New > New Project > Empty Activity.
  2. Luego, ve al elemento activity_main.xml que ya se creó. Allí, reemplaza el diseño existente por este:

res/layout/activity_main.xml

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

   <TextView
       android:id="@+id/drag_text_view"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_margin="20dp"
       android:text="@string/drag_text"
       android:textSize="30sp" />
</LinearLayout>
  1. Ahora, abre el archivo MainActivity.kt, agrega la etiqueta y llama a su función setOnLongClickListener:

drag/MainActivity.kt

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

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

       binding.dragTextView.tag = "text_view"
       binding.dragTextView.setOnLongClickListener(this)
   }
  1. Ahora, anula la función onLongClick, de modo que tu elemento TextView pueda usar esta funcionalidad anulada para su evento onLongClickListener.
override fun onLongClick(view: View): Boolean {
  1. Comprueba si el parámetro del receptor es el tipo de View al que le estás agregando la funcionalidad de arrastrar. En tu caso, es un elemento TextView:
return if (view is TextView) {
  1. Crea un parámetro ClipData.item a partir del texto que contiene TextView:
val text = ClipData.Item(view.text)
  1. Ahora, definimos el elemento MimeType que usaremos:
val mimeType = arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)
  1. Con los elementos anteriores que creaste, crea el paquete (una instancia de ClipData) que usarás para compartir los datos:
val dataToShare = ClipData(view.tag.toString(), mimeType, text)

Es muy importante que brindes comentarios a nuestros usuarios. Por eso, te recomendamos que proporciones información visual sobre el elemento que se arrastra.

  1. Crea una sombra del contenido que arrastramos para que los usuarios lo vean debajo del dedo cuando se ejecute la interacción de arrastrar:
val dragShadowBuilder = View.DragShadowBuilder(view)
  1. Ahora, como querrás permitir la función de arrastrar y soltar entre diferentes apps, primero, debes definir un conjunto de marcas que permitan esa funcionalidad:
val flags =
   View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ

Según la documentación, las marcas significan lo siguiente:

  • DRAG_FLAG_GLOBAL: Indica que la función de arrastrar puede cruzar los límites de las ventanas.
  • DRAG_FLAG_GLOBAL_URI_READ: Cuando esta marca se usa con DRAG_FLAG_GLOBAL, el destinatario de la función de arrastrar podrá solicitar acceso de lectura a los URI de contenido que se encuentran en el objeto ClipData.
  1. Por último, llama a la función startDragAndDrop en la vista con los componentes que creaste para que comience la interacción de arrastrar:
view.startDragAndDrop(dataToShare, dragShadowBuilder, view, flags)
  1. Finaliza y cierra los elementos onLongClick function y MainActivity:
         true
       } else {
           false
       }
   }
}

Cómo implementar la función de soltar

En tu ejemplo, crearás una app simple que cuenta con la funcionalidad de soltar adjunta a un elemento EditText. Esta vista aceptará datos de texto (que pueden provenir de nuestra app con la función de arrastrar desde su TextView).

Nuestro elemento EditText (o área para soltar) cambiará su fondo según la etapa de la función de arrastrar en la que se encuentre, de modo que se les puede brindar información a los usuarios acerca del estado de la interacción de arrastrar y soltar, y los usuarios pueden ver cuándo se les permite soltar el contenido.

  1. Primero, para crear una app nueva, ve a File > New > New Project > Empty Activity.
  2. Después, ve al elemento activity_main.xml que ya se creó. Reemplaza el diseño existente por este:

res/layout/activity_main.xml

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

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

</RelativeLayout>
  1. Ahora, abre el archivo MainActivity.kt y agrega un objeto de escucha a la función EditText setOnDragListener:

drop/MainActivity.kt

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

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)
       binding.dropEditText.setOnDragListener(this)
   }
  1. Ahora, anula la función onDrag, de modo que nuestro elemento EditText, como se escribió anteriormente, pueda usar esta devolución de llamada anulada para su función onDragListener.

Se llamará a esta función cada vez que se produzca un elemento DragEvent nuevo, por ejemplo, cuando el dedo de un usuario ingrese en el área de la acción de soltar o salga de ella; cuando suelte el dedo en el área de la función de soltar, de modo que se ejecute la acción de soltar, o cuando suelte el dedo fuera del área de la función de soltar, y se cancele la interacción de arrastrar y soltar.

override fun onDrag(v: View, event: DragEvent): Boolean {
  1. Para reaccionar ante elementos DragEvents diferentes que se activarán, agrega una instrucción when a fin de controlar los eventos diferentes:
return when (event.action) {
  1. Controla el elemento ACTION_DRAG_STARTED que se activa cuando se inicia la interacción de arrastrar. Cuando se activa este evento, el color del área de la función de soltar cambia para que los usuarios sepan que tu elemento EditText acepta el contenido que se soltó:
DragEvent.ACTION_DRAG_STARTED -> {
       setDragStartedBackground()
       true
}
  1. Controla el evento de arrastre ACTION_DRAG_ENTERED que se activa cuando un dedo ingresa al área de la función de soltar. Vuelve a cambiar el color del fondo del área de la función de soltar para indicarle al usuario que esta área está lista. (Por supuesto, puedes omitir este evento y no cambiar el evento de fondo; es solo para fines informativos).
DragEvent.ACTION_DRAG_ENTERED -> {
   setDragEnteredBackground()
   true
}
  1. Ahora, controla el evento ACTION_DROP. Este evento se activa cuando los usuarios sueltan el dedo con el contenido de arrastre en el área de la función de soltar, por lo que se puede realizar la acción de soltar.
DragEvent.ACTION_DROP -> {
   handleDrop(event)
   true
}

Más adelante, veremos cómo controlar la acción de soltar.

  1. Luego, controla el evento ACTION_DRAG_ENDED. Este evento se activa después de ACTION_DROP, por lo que se finalizó la acción completa de arrastrar y soltar.

Este es un buen momento para restablecer los cambios que realizaste, por ejemplo, si cambiaste el fondo del área de la función de soltar a sus valores originales.

DragEvent.ACTION_DRAG_ENDED -> {
   clearBackgroundColor()
   true
}
  1. Luego, controla el evento ACTION_DRAG_EXITED. Este evento se activa cuando los usuarios salen del área de la función de soltar (cuando el dedo está en esta área, pero luego lo sacan).

Aquí, si cambias el fondo a fin de destacar el ingreso al área de la función de soltar, es un buen momento para restablecerlo a su valor anterior.

DragEvent.ACTION_DRAG_EXITED -> {
   setDragStartedBackground()
   true
}
  1. Por último, trata el caso "else" de tu sentencia "when" y cierra la función onDrag:
      else -> false
   }
}

Ahora, veamos cómo se controla la acción de soltar. Antes, analizamos que, cuando se activa el evento ACTION_DROP, debes controlar la funcionalidad de la acción de soltar, así que ahora aprenderás cómo hacerlo.

  1. Pasa el elemento DragEvent como parámetro, ya que este es el objeto que contiene los datos de la función de arrastrar:
private fun handleDrop(event: DragEvent) {
  1. Dentro de la función, solicita los permisos de arrastrar y soltar. Esta acción es necesaria cuando arrastras y sueltas entre apps diferentes.
val dropPermissions = requestDragAndDropPermissions(event)
  1. Mediante el parámetro DragEvent, puedes acceder al elemento clipData que se creó anteriormente en el paso de la función de arrastrar:
val item = event.clipData.getItemAt(0)
  1. Ahora, con el elemento para arrastrar, accede al texto que lo contiene y que se compartió. Este es el texto que tenía tu elemento TextView en el ejemplo de la función de arrastrar:
val dragData = item.text.toString()
  1. Ahora que tienes los datos reales que se compartieron (el texto), puedes establecerlo en tu área de la función de soltar (nuestro elemento EditText), como lo haces normalmente cuando estableces el texto en un elemento EditText, en el código:
binding.dropEditText.setText(dragData)
  1. El último paso consiste en retirar los permisos de arrastrar y soltar solicitados. Si no lo haces una vez finalizada la acción de soltar, cuando se destruya la actividad, los permisos se retirarán automáticamente. Cierra la función y la clase:
      dropPermissions?.release()
   }
}

Una vez que apliques la implementación de la función de soltar en nuestra app sencilla con la acción de soltar, podemos ejecutar ambas apps, una al lado de la otra, y comprobar cómo funciona la acción de arrastrar y soltar.

En la siguiente animación, observa cómo funciona y cómo se activan los diferentes eventos de arrastre, y lo que haces cuando los controlas (cuando cambias el fondo del área de la función de soltar, según el elemento DragEvent específico, y cuando sueltas el contenido):

d66c5c24c6ea81b3.gif

Como podemos observar en este bloque de contenido, el uso de Jetpack WindowManager nos permitirá trabajar con nuevos factores de forma, como dispositivos plegables.

La información que brinda es muy útil para adaptar nuestras apps a estos dispositivos. De esta manera, podemos ofrecer una mejor experiencia cuando nuestras apps se ejecutan en estos dispositivos.

En resumen, con este codelab completo, 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
  • Jetpack WindowManager: ¿Qué brinda esta API?
  • Cómo usar Jetpack WindowManager y adaptar nuestras apps a nuevos factores de forma
  • Cómo agregar cambios mínimos para iniciar actividades en la ventana adyacente vacía, y cómo implementar la acción de arrastrar y soltar que funcione entre apps, a fin de mejorarlas.

Más información