Cómo hacer que tu app funcione en dispositivos plegables

Las pantallas grandes desplegadas y los estados plegados únicos permiten crear nuevas experiencias del usuario en dispositivos plegables. Para que tu app funcione en dispositivos plegables, usa la biblioteca de Jetpack WindowManager, que proporciona una superficie de API para las funciones de ventanas de dispositivos plegables, como los pliegues y las bisagras. Cuando tu app funciona en dispositivos plegables, puede adaptar su diseño para evitar colocar contenido importante en el área de pliegues o bisagras y usar esos elementos como separadores naturales.

Información de la ventana

La interfaz WindowInfoTracker de Jetpack WindowManager expone la información de diseño de la ventana. El método windowLayoutInfo() de la interfaz muestra un flujo de datos WindowLayoutInfo que informa a la app sobre el estado de plegado de un dispositivo plegable. El método WindowInfoTracker getOrCreate() crea una instancia de WindowInfoTracker.

WindowManager brinda compatibilidad para recopilar datos WindowLayoutInfo mediante flujos de Kotlin y devoluciones de llamada de Java.

Flujos de Kotlin

A fin de iniciar y detener la recopilación de datos de WindowLayoutInfo, puedes usar una corrutina optimizada para ciclos de vida que se pueda reiniciar, en la que se ejecuta el bloque de código repeatOnLifecycle cuando el ciclo de vida tiene al menos STARTED y se detiene cuando el ciclo de vida tiene el estado STOPPED. La ejecución del bloque de código se reinicia automáticamente cuando el ciclo de vida vuelve a tener el estado STARTED. En el siguiente ejemplo, el bloque de código recopila y usa datos de WindowLayoutInfo:

class DisplayFeaturesActivity : AppCompatActivity() {

    private lateinit var binding: ActivityDisplayFeaturesBinding

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

        binding = ActivityDisplayFeaturesBinding.inflate(layoutInflater)
        setContentView(binding.root)

        lifecycleScope.launch(Dispatchers.Main) {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                WindowInfoTracker.getOrCreate(this@DisplayFeaturesActivity)
                    .windowLayoutInfo(this@DisplayFeaturesActivity)
                    .collect { newLayoutInfo ->
                        // Use newLayoutInfo to update the layout.
                    }
            }
        }
    }
}

Devoluciones de llamada de Java

La capa de compatibilidad de devolución de llamada incluida en la dependencia androidx.window:window-java te permite recopilar actualizaciones de WindowLayoutInfo sin usar un flujo de Kotlin. El artefacto proporciona WindowInfoTrackerCallbackAdapter, que adapta un WindowInfoTracker para admitir el registro (y la cancelación del registro) de las devoluciones de llamada para recibir actualizaciones de WindowLayoutInfo, por ejemplo:

public class SplitLayoutActivity extends AppCompatActivity {

    private WindowInfoTrackerCallbackAdapter windowInfoTracker;
    private ActivitySplitLayoutBinding binding;
    private final LayoutStateChangeCallback layoutStateChangeCallback =
            new LayoutStateChangeCallback();

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);

       binding = ActivitySplitLayoutBinding.inflate(getLayoutInflater());
       setContentView(binding.getRoot());

       windowInfoTracker =
                new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
   }

   @Override
   protected void onStart() {
       super.onStart();
       windowInfoTracker.addWindowLayoutInfoListener(
                this, Runnable::run, layoutStateChangeCallback);
   }

   @Override
   protected void onStop() {
       super.onStop();
       windowInfoTracker
           .removeWindowLayoutInfoListener(layoutStateChangeCallback);
   }

   class LayoutStateChangeCallback implements Consumer<WindowLayoutInfo> {
       @Override
       public void accept(WindowLayoutInfo newLayoutInfo) {
           SplitLayoutActivity.this.runOnUiThread( () -> {
               // Use newLayoutInfo to update the layout.
           });
       }
   }
}

Compatibilidad con RxJava

Si ya usas RxJava (versión 2 o 3), puedes aprovechar los artefactos que te permiten usar Observable o Flowable para recopilar actualizaciones WindowLayoutInfo sin usar el flujo de Kotlin.

La capa de compatibilidad incluida en el androidx.window:window-rxjava2 y androidx.window:window-rxjava3 dependencias proporciona WindowInfoTracker#windowLayoutInfoFlowable() y WindowInfoTracker#windowLayoutInfoObservable(), que permiten que tu app reciba actualizaciones de WindowLayoutInfo, por ejemplo:

class RxActivity: AppCompatActivity {

    private lateinit var binding: ActivityRxBinding

    private var disposable: Disposable? = null
    private lateinit var observable: Observable<WindowLayoutInfo>

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);

       binding = ActivitySplitLayoutBinding.inflate(getLayoutInflater());
       setContentView(binding.getRoot());

        // Create a new observable
        observable = WindowInfoTracker.getOrCreate(this@RxActivity)
            .windowLayoutInfoObservable(this@RxActivity)
   }

   @Override
   protected void onStart() {
       super.onStart();

        // Subscribe to receive WindowLayoutInfo updates
        disposable?.dispose()
        disposable = observable
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe { newLayoutInfo ->
            // Use newLayoutInfo to update the layout
        }
   }

   @Override
   protected void onStop() {
       super.onStop();

        // Dispose the WindowLayoutInfo observable
        disposable?.dispose()
   }
}

Funciones de pantallas plegables

La clase WindowLayoutInfo de Jetpack WindowManager pone a disposición las funciones de una ventana de visualización como una lista de elementos DisplayFeature.

FoldingFeature es un tipo de DisplayFeature que proporciona información sobre pantallas plegables, lo que incluye lo siguiente:

  • state: Es el estado plegado del dispositivo, FLAT o HALF_OPENED.
  • orientation: Es la orientación del pliegue o la bisagra, HORIZONTAL o VERTICAL.
  • occlusionType: Indica si el pliegue o la bisagra oculta parte de la pantalla NONE o FULL.
  • isSeparating: Indica si el pliegue o la bisagra crea dos áreas lógicas de visualización, verdadero o falso.

Un dispositivo plegable que tenga el estado HALF_OPENED siempre informa isSeparating como verdadero porque la pantalla está separada en dos áreas de visualización. Además, isSeparating siempre es verdadero en un dispositivo con pantalla doble cuando la aplicación abarca ambas pantallas.

La propiedad FoldingFeature bounds (heredada de DisplayFeature) representa el rectángulo delimitador de un elemento plegable, como un pliegue o una bisagra. Se pueden usar límites para posicionar elementos en la pantalla en relación con la función.

Usa FoldingFeature state para determinar si el dispositivo está colocado sobre una mesa o en posición de libro, y personaliza el diseño de tu app según corresponda. Por ejemplo:

Kotlin

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    lifecycleScope.launch(Dispatchers.Main) {
        // The block passed to repeatOnLifecycle is executed when the lifecycle
        // is at least STARTED and is cancelled when the lifecycle is STOPPED.
        // It automatically restarts the block when the lifecycle is STARTED again.
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            // Safely collects from windowInfoRepo when the lifecycle is STARTED
            // and stops collection when the lifecycle is STOPPED
            WindowInfoTracker.getOrCreate(this@MainActivity)
                .windowLayoutInfo(this@MainActivity)
                .collect { layoutInfo ->
                    // New posture information
                    val foldingFeature = layoutInfo.displayFeatures
                        .filterIsInstance()
                        .firstOrNull()
                    when {
                            isTableTopPosture(foldingFeature) ->
                                enterTabletopMode(foldingFeature)
                            isBookPosture(foldingFeature) ->
                                enterBookMode(foldingFeature)
                            isSeparating(foldingFeature) ->
                            // Dual-screen device
                            if (foldingFeature.orientation == HORIZONTAL) {
                                enterTabletopMode(foldingFeature)
                            } else {
                                enterBookMode(foldingFeature)
                            }
                            else ->
                                enterNormalMode()
                        }
                }

        }
    }
}

@OptIn(ExperimentalContracts::class)
fun isTableTopPosture(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
            foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
}

@OptIn(ExperimentalContracts::class)
fun isBookPosture(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
            foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
}

@OptIn(ExperimentalContracts::class)
fun isSeparating(foldFeature : FoldingFeature?) : Boolean {
    contract { returns(true) implies (foldFeature != null) }
    return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating
}

Java

private WindowInfoTrackerCallbackAdapter windowInfoTracker;
private final LayoutStateChangeCallback layoutStateChangeCallback =
                new LayoutStateChangeCallback();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    ...
    windowInfoTracker =
            new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
}

@Override
protected void onStart() {
    super.onStart();
    windowInfoTracker.addWindowLayoutInfoListener(
            this, Runnable::run, layoutStateChangeCallback);
}

@Override
protected void onStop() {
    super.onStop();
    windowInfoTracker.removeWindowLayoutInfoListener(layoutStateChangeCallback);
}

class LayoutStateChangeCallback implements Consumer<WindowLayoutInfo> {
    @Override
    public void accept(WindowLayoutInfo newLayoutInfo) {
        // Use newLayoutInfo to update the Layout
        List<DisplayFeature> displayFeatures = newLayoutInfo.getDisplayFeatures();
        for (DisplayFeature feature : displayFeatures) {
            if (feature instanceof FoldingFeature) {
                if (isTableTopPosture((FoldingFeature) feature)) {
                    enterTabletopMode(feature);
                } else if (isBookPosture((FoldingFeature) feature)) {
                    enterBookMode(feature);
                } else if (isSeparating((FoldingFeature) feature)) {
                    // Dual-screen device
                    if (((FoldingFeature) feature).getOrientation() ==
                              FoldingFeature.Orientation.HORIZONTAL) {
                        enterTabletopMode(feature);
                    } else {
                        enterBookMode(feature);
                    }
                } else {
                    enterNormalMode();
                }
            }
        }
    }
}

private boolean isTableTopPosture(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.HALF_OPENED) &&
           (foldFeature.getOrientation() == FoldingFeature.Orientation.HORIZONTAL);
}

private boolean isBookPosture(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.HALF_OPENED) &&
           (foldFeature.getOrientation() == FoldingFeature.Orientation.VERTICAL);
}

private boolean isSeparating(FoldingFeature foldFeature) {
    return (foldFeature != null) &&
           (foldFeature.getState() == FoldingFeature.State.FLAT) &&
           (foldFeature.isSeparating() == true);
}

En dispositivos con pantalla doble, usa diseños para posiciones de mesa y libro, incluso si el estado FoldingFeature es FLAT.

No coloques los controles de la IU demasiado cerca de la línea de plegado ni de la bisagra cuando isSeparating sea verdadero, ya que esos controles pueden resultar difíciles de alcanzar. Usa occlusionType para decidir si quieres colocar contenido dentro de la función plegable bounds.

Cambios en el tamaño de la ventana

El área de visualización de una app puede cambiar como resultado de un cambio en la configuración del dispositivo. Por ejemplo, cuando se pliega o se despliega el dispositivo, se rota o se cambia el tamaño de una ventana en el modo multiventana.

La clase WindowMetricsCalculator de Jetpack WindowManager te permite recuperar las métricas actuales y máximas de la ventana. Al igual que la plataforma WindowMetrics, que se introdujo en el nivel de API 30, WindowManager WindowMetrics proporciona los límites de la ventana, pero la API es retrocompatible hasta el nivel de API 14.

Usa WindowMetrics en el método onCreate() o onConfigurationChanged() de una actividad a fin de configurar el diseño de tu app para el tamaño de ventana actual, por ejemplo:

Kotlin

override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    val windowMetrics = WindowMetricsCalculator.getOrCreate()
        .computeCurrentWindowMetrics(this@MainActivity)
    val bounds = windowMetrics.getBounds()
    ...
}

Java

@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    final WindowMetrics windowMetrics = WindowMetricsCalculator.getOrCreate()
        .computeCurrentWindowMetrics(this);
    final Rect bounds = windowMetrics.getBounds();
    ...
}

Consulta también Cómo brindar compatibilidad con diferentes tamaños de pantalla.