Cómo usar widgets de colección

Prueba hacerlo con Compose
Jetpack Compose es el kit de herramientas de IU recomendado para Android. Aprende a compilar widgets con las APIs de estilo de Compose.

Los widgets de colección se especializan en mostrar muchos elementos del mismo tipo, como colecciones de imágenes de una app de galería, artículos de una app de noticias o mensajes de una app de comunicación. Por lo general, los widgets de colección se enfocan en dos casos de uso: explorar la colección y abrir un elemento de la colección en su vista de detalles. Los widgets de colección se pueden desplazar de forma vertical.

Estos widgets usan RemoteViewsService para mostrar colecciones respaldadas por datos remotos, como los de un proveedor de contenido. El widget presenta los datos con uno de los siguientes tipos de vista, que se conocen como vistas de colección:

ListView: Es una vista que muestra elementos en una lista de desplazamiento vertical.

GridView: Es una vista que muestra elementos en una cuadrícula de desplazamiento bidimensional.

StackView: Es una vista de tarjetas apiladas, similar a un tarjetero, en la que el usuario puede deslizar la tarjeta frontal hacia arriba o hacia abajo para ver la tarjeta anterior o la siguiente, respectivamente.

AdapterViewFlipper: Es un ViewAnimator respaldado por un adaptador que anima entre dos o más vistas. Solo se muestra un elemento secundario a la vez.

Debido a que estas vistas de colección muestran colecciones respaldadas por datos remotos, usan un Adapter para vincular su interfaz de usuario a sus datos. Un Adapter vincula elementos individuales de un conjunto de datos a objetos View individuales.

Además, debido a que estas vistas de colección están respaldadas por adaptadores, el framework de Android debe incluir una arquitectura adicional para admitir su uso en widgets. En el contexto de un widget, Adapter se reemplaza por un RemoteViewsFactory, que es un wrapper delgado alrededor de la interfaz de Adapter. Cuando se solicita un elemento específico de la colección, RemoteViewsFactory crea y devuelve el elemento para la colección como un objeto RemoteViews. Para incluir una vista de colección en tu widget, implementa RemoteViewsService y RemoteViewsFactory.

RemoteViewsService es un servicio que permite que un adaptador remoto solicite objetos RemoteViews. RemoteViewsFactory es una interfaz para un adaptador entre una vista de colección, como ListView, GridView y StackView, y los datos subyacentes de esa vista. En la muestra de StackWidget, se incluye un ejemplo del código estándar para implementar este servicio y esta interfaz:

class StackWidgetService : RemoteViewsService() {
    override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
        StackRemoteViewsFactory(this.applicationContext, intent)
}

class StackRemoteViewsFactory(
    private val context: Context, intent: Intent
) : RemoteViewsService.RemoteViewsFactory {

    // See the RemoteViewsFactory API reference for the full list of methods to implement.

}

Aplicación de ejemplo

Los extractos de código de esta sección también se extraen de la muestra de StackWidget:

Un StackWidget que muestra vistas apiladas
Figura 1: Un objeto StackWidget.

Esta muestra consiste en una pila de diez vistas que muestran los valores del cero al nueve. El widget de ejemplo tiene estos comportamientos principales:

  • El usuario puede deslizar verticalmente la vista superior del widget para mostrar la vista siguiente o la anterior. Este es un comportamiento integrado de StackView.

  • Sin ninguna interacción del usuario, el widget avanza automáticamente por sus vistas en secuencia, como una presentación de diapositivas. Esto se debe al parámetro de configuración android:autoAdvanceViewId="@id/stack_view" en el archivo res/xml/stackwidgetinfo.xml. Este parámetro de configuración se aplica al ID de vista, que, en este caso, es el ID de la vista de pila.

  • Si el usuario toca la vista superior, el widget muestra el mensaje de Toast "Touched view n", donde n es el índice (posición) de la vista tocada. Para obtener más información sobre cómo implementar comportamientos, consulta la sección Cómo agregar comportamiento a elementos individuales.

Cómo implementar widgets con colecciones

Para implementar un widget con colecciones, sigue el procedimiento para implementar cualquier widget y, luego, realiza algunos pasos adicionales: modifica el manifiesto, agrega una vista de colección al diseño del widget y modifica tu subclase AppWidgetProvider.

Manifiesto para widgets con colecciones

Además de los requisitos que se indican en Cómo declarar un widget en el manifiesto, debes permitir que los widgets con colecciones se vinculen a tu RemoteViewsService. Para ello, declara el servicio en tu archivo de manifiesto con el permiso BIND_REMOTEVIEWS. Esto evita que otras aplicaciones accedan libremente a los datos de tu widget.

Por ejemplo, cuando creas un widget que usa RemoteViewsService para propagar una vista de colección, la entrada del manifiesto podría verse de la siguiente manera:

<service android:name="MyWidgetService"
    android:permission="android.permission.BIND_REMOTEVIEWS" />

En este ejemplo, android:name="MyWidgetService" hace referencia a tu subclase de RemoteViewsService.

Diseño para widgets con colecciones

El requisito principal para el archivo en formato XML de diseño de tu widget es que incluya una de las vistas de colección: ListView, GridView, StackView o AdapterViewFlipper. Este es el archivo widget_layout.xml de la muestra StackWidget:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <StackView
        android:id="@+id/stack_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:loopViews="true" />
    <TextView
        android:id="@+id/empty_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:background="@drawable/widget_item_background"
        android:textColor="#ffffff"
        android:textStyle="bold"
        android:text="@string/empty_view_text"
        android:textSize="20sp" />
</FrameLayout>

Ten en cuenta que las vistas vacías deben ser elementos hermanos de la vista de colección para la que la vista vacía representa el estado vacío.

Además del archivo de diseño para todo el widget, crea otro archivo de diseño que defina el diseño de cada elemento de la colección, por ejemplo, un diseño para cada libro de una colección de libros. La muestra de StackWidget tiene solo un archivo de diseño de elementos, widget_item.xml, ya que todos los elementos usan el mismo diseño.

Clase AppWidgetProvider para widgets con colecciones

Al igual que con los widgets normales, la mayor parte del código de tu subclase AppWidgetProvider suele ir en onUpdate(). La principal diferencia en tu implementación para onUpdate() cuando creas un widget con colecciones es que debes llamar a setRemoteAdapter(). Esto le indica a la vista de colección de dónde obtener sus datos. Luego, RemoteViewsService puede mostrar tu implementación de RemoteViewsFactory, y el widget puede entregar los datos adecuados. Cuando llames a este método, pasa un intent que apunte a tu implementación de RemoteViewsService y el ID de widget que especifica el widget que se actualizará.

Por ejemplo, así es como la muestra de StackWidget implementa el método de devolución de llamada onUpdate() para establecer RemoteViewsService como el adaptador remoto de la colección de widgets:

override fun onUpdate(
    context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray
) {
    // Update each of the widgets with the remote adapter.
    appWidgetIds.forEach { appWidgetId ->

        // Set up the intent that starts the StackViewService, which
        // provides the views for this collection.
        val intent = Intent(context, StackWidgetService::class.java).apply {
            // Add the widget ID to the intent extras.
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
            data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
        }
        // Instantiate the RemoteViews object for the widget layout.
        val views = RemoteViews(context.packageName, R.layout.widget_layout).apply {
            // Set up the RemoteViews object to use a RemoteViews adapter.
            // This adapter connects to a RemoteViewsService through the
            // specified intent.
            // This is how you populate the data.
            setRemoteAdapter(R.id.stack_view, intent)

            // The empty view is displayed when the collection has no items.
            // It must be in the same layout used to instantiate the
            // RemoteViews object.
            setEmptyView(R.id.stack_view, R.id.empty_view)
        }

        // Do additional processing specific to this widget.

        appWidgetManager.updateAppWidget(appWidgetId, views)
    }
    super.onUpdate(context, appWidgetManager, appWidgetIds)
}

Cómo conservar los datos

Como se describe en esta página, la subclase RemoteViewsService proporciona el RemoteViewsFactory que se usa para propagar la vista de colección remota.

En particular, sigue estos pasos:

  1. Subclase RemoteViewsService. RemoteViewsService es el servicio a través del cual un adaptador remoto puede solicitar RemoteViews.

  2. En la subclase RemoteViewsService, incluye una clase que implemente la interfaz RemoteViewsFactory. RemoteViewsFactory es una interfaz para un adaptador entre una vista de colección remota, como ListView, GridView, StackView, y los datos subyacentes de esa vista. Tu implementación es responsable de crear un objeto RemoteViews para cada elemento del conjunto de datos. Esta interfaz es un wrapper delgado alrededor de Adapter.

No puedes confiar en que una sola instancia de tu servicio, o cualquier dato que contenga, persista. No almacenes datos en tu RemoteViewsService, a menos que sean estáticos. Si quieres que los datos de tu widget persistan, el mejor enfoque es usar un ContentProvider cuyos datos persistan más allá del ciclo de vida del proceso. Por ejemplo, un widget de tienda de comestibles puede almacenar el estado de cada elemento de la lista de compras en una ubicación persistente, como una base de datos SQL.

El contenido principal de la implementación de RemoteViewsService es su RemoteViewsFactory, que se describe en la siguiente sección.

Interfaz de RemoteViewsFactory

Tu clase personalizada que implementa la interfaz RemoteViewsFactory proporciona al widget los datos de los elementos de su colección. Para ello, combina el archivo de diseño XML del elemento del widget con una fuente de datos. Esta fuente de datos puede ser cualquier cosa, desde una base de datos hasta un array. En la muestra de StackWidget, la fuente de datos es un array de WidgetItems. El RemoteViewsFactory funciona como un adaptador para unir los datos a la vista de colección remota.

Los dos métodos más importantes que debes implementar para tu subclase RemoteViewsFactory son onCreate() y getViewAt().

El sistema llama a onCreate() cuando crea tu fábrica por primera vez. Aquí es donde configuras las conexiones o los cursores a tu fuente de datos. Por ejemplo, la muestra StackWidget usa onCreate() para inicializar un array de objetos WidgetItem. Cuando el widget está activo, el sistema accede a estos objetos con su posición de índice en el array y muestra el texto que contienen.

A continuación, se muestra un extracto de la implementación de RemoteViewsFactory de la muestra de StackWidget que incluye partes del método onCreate():

private const val REMOTE_VIEW_COUNT: Int = 10

class StackRemoteViewsFactory(
    private val context: Context
) : RemoteViewsService.RemoteViewsFactory {

    private lateinit var widgetItems: List<WidgetItem>

    override fun onCreate() {
        // In onCreate(), set up any connections or cursors to your data
        // source. Heavy lifting, such as downloading or creating content,
        // must be deferred to onDataSetChanged() or getViewAt(). Taking
        // more than 20 seconds on this call results in an ANR.
        widgetItems = List(REMOTE_VIEW_COUNT) { index -> WidgetItem("$index!") }
    }

}

El método RemoteViewsFactory getViewAt() devuelve un objeto RemoteViews que corresponde a los datos en el position especificado en el conjunto de datos. Este es un extracto de la implementación de RemoteViewsFactory de la muestra de StackWidget:

override fun getViewAt(position: Int): RemoteViews {
    // Construct a remote views item based on the widget item XML file
    // and set the text based on the position.
    return RemoteViews(context.packageName, R.layout.widget_item).apply {
        setTextViewText(R.id.widget_item, widgetItems[position].text)
    }
}

Agrega comportamiento a elementos individuales

En las secciones anteriores, se muestra cómo vincular tus datos a tu colección de widgets. Sin embargo, ¿qué sucede si quieres agregar un comportamiento dinámico a los elementos individuales de tu vista de colección?

Como se describe en Cómo controlar eventos con la clase onUpdate(), normalmente usas setOnClickPendingIntent() para establecer el comportamiento de clic de un objeto, como hacer que un botón inicie un Activity. Sin embargo, este enfoque no está permitido para las vistas secundarias en un elemento de colección individual. Por ejemplo, puedes usar setOnClickPendingIntent() para configurar un botón global en el widget de Gmail que inicie la app, pero no en los elementos de lista individuales.

En su lugar, para agregar un comportamiento de clic a elementos individuales de una colección, usa setOnClickFillInIntent(). Configura una plantilla de intent pendiente para tu vista de colección y establece un intent de relleno en cada elemento de la colección con tu RemoteViewsFactory.

En esta sección, se usa la muestra de StackWidget para describir cómo agregar comportamiento a elementos individuales. En la muestra de StackWidget, si el usuario toca la vista superior, el widget muestra el mensaje de Toast "Se tocó la vista n", donde n es el índice (posición) de la vista que se tocó. Así funciona:

  • StackWidgetProvider, una subclase de AppWidgetProvider, crea un intent pendiente con una acción personalizada llamada TOAST_ACTION.

  • Cuando el usuario toca una vista, se activa el intent y se transmite TOAST_ACTION.

  • Este anuncio es interceptado por el método onReceive() de la clase StackWidgetProvider, y el widget muestra el mensaje Toast para la vista tocada. Los datos de los elementos de la colección los proporciona el RemoteViewsFactory a través de RemoteViewsService.

Configura la plantilla de intent pendiente

StackWidgetProvider (una subclase de AppWidgetProvider) configura un intent pendiente. Los elementos individuales de una colección no pueden configurar sus propios intents pendientes. En cambio, la colección en su conjunto configura una plantilla de intent pendiente, y los elementos individuales configuran un intent de relleno para crear un comportamiento único para cada elemento.

Esta clase también recibe la transmisión que se envía cuando el usuario toca una vista. Procesa este evento en el método onReceive(). Si la acción del intent es TOAST_ACTION, el widget muestra un mensaje de Toast para la vista actual.

const val TOAST_ACTION = "com.example.android.stackwidget.TOAST_ACTION"
const val EXTRA_ITEM = "com.example.android.stackwidget.EXTRA_ITEM"

class StackWidgetProvider : AppWidgetProvider() {

    // ...

    // Called when the BroadcastReceiver receives an Intent broadcast.
    // Checks whether the intent's action is TOAST_ACTION. If it is, the
    // widget displays a Toast message for the current item.
    override fun onReceive(context: Context, intent: Intent) {
        val mgr: AppWidgetManager = AppWidgetManager.getInstance(context)
        if (intent.action == TOAST_ACTION) {
            val appWidgetId: Int = intent.getIntExtra(
                AppWidgetManager.EXTRA_APPWIDGET_ID,
                AppWidgetManager.INVALID_APPWIDGET_ID
            )
            // EXTRA_ITEM represents a custom value provided by the Intent
            // passed to the setOnClickFillInIntent() method to indicate the
            // position of the clicked item. See StackRemoteViewsFactory in
            // Set the fill-in Intent for details.
            val viewIndex: Int = intent.getIntExtra(EXTRA_ITEM, 0)
            Toast.makeText(context, "Touched view $viewIndex", Toast.LENGTH_SHORT).show()
        }
        super.onReceive(context, intent)
    }

    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        // Update each of the widgets with the remote adapter.
        appWidgetIds.forEach { appWidgetId ->

            // Sets up the intent that points to the StackViewService that
            // provides the views for this collection.
            val intent = Intent(context, StackWidgetService::class.java).apply {
                putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
                // When intents are compared, the extras are ignored, so embed
                // the extra sinto the data so that the extras are not ignored.
                data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
            }
            val rv = RemoteViews(context.packageName, R.layout.widget_layout).apply {
                setRemoteAdapter(R.id.stack_view, intent)

                // The empty view is displayed when the collection has no items.
                // It must be a sibling of the collection view.
                setEmptyView(R.id.stack_view, R.id.empty_view)
            }

            // This section makes it possible for items to have individualized
            // behavior. It does this by setting up a pending intent template.
            // Individuals items of a collection can't set up their own pending
            // intents. Instead, the collection as a whole sets up a pending
            // intent template, and the individual items set a fillInIntent
            // to create unique behavior on an item-by-item basis.
            val toastPendingIntent: PendingIntent = Intent(
                context,
                StackWidgetProvider::class.java
            ).run {
                // Set the action for the intent.
                // When the user touches a particular view, it has the effect of
                // broadcasting TOAST_ACTION.
                action = TOAST_ACTION
                putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
                data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))

                // The template must be mutable, because each item fills in its
                // own extras through setOnClickFillInIntent().
                PendingIntent.getBroadcast(
                    context, 0, this,
                    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
                )
            }
            rv.setPendingIntentTemplate(R.id.stack_view, toastPendingIntent)

            appWidgetManager.updateAppWidget(appWidgetId, rv)
        }
        super.onUpdate(context, appWidgetManager, appWidgetIds)
    }
}

Establece el intent de relleno

Tu RemoteViewsFactory debe establecer un intent de relleno en cada elemento de la colección. Esto permite distinguir la acción de clic individual de un elemento determinado. Luego, el intent de relleno se combina con la plantilla PendingIntent para determinar el intent final que se ejecuta cuando se presiona el elemento.

private const val REMOTE_VIEW_COUNT: Int = 10

class StackRemoteViewsFactory(
    private val context: Context,
    intent: Intent
) : RemoteViewsService.RemoteViewsFactory {

    private lateinit var widgetItems: List<WidgetItem>
    private val appWidgetId: Int = intent.getIntExtra(
        AppWidgetManager.EXTRA_APPWIDGET_ID,
        AppWidgetManager.INVALID_APPWIDGET_ID
    )

    override fun onCreate() {
        // In onCreate(), set up any connections or cursors to your data source.
        // Heavy lifting, such as downloading or creating content, must be
        // deferred to onDataSetChanged() or getViewAt(). Taking more than 20
        // seconds on this call results in an ANR.
        widgetItems = List(REMOTE_VIEW_COUNT) { index -> WidgetItem("$index!") }
        // ...
    }

    // ...

    override fun getViewAt(position: Int): RemoteViews {
        // Construct a remote views item based on the widget item XML file
        // and set the text based on the position.
        return RemoteViews(context.packageName, R.layout.widget_item).apply {
            setTextViewText(R.id.widget_item, widgetItems[position].text)

            // Set a fill-intent to fill in the pending intent template.
            // that is set on the collection view in StackWidgetProvider.
            val fillInIntent = Intent().apply {
                Bundle().also { extras ->
                    extras.putInt(EXTRA_ITEM, position)
                    putExtras(extras)
                }
            }
            // Make it possible to distinguish the individual on-click
            // action of a given item.
            setOnClickFillInIntent(R.id.widget_item, fillInIntent)
            // ...
        }
    }
    // ...
}

Mantén actualizados los datos de la colección

En la Figura 2, se ilustra el flujo de actualización en un widget que usa colecciones. En él, se muestra cómo el código del widget interactúa con RemoteViewsFactory y cómo puedes activar actualizaciones:

Interacción con RemoteViewsFactory durante las actualizaciones
Figura 2: Interacción con RemoteViewsFactory durante las actualizaciones.

Los widgets que usan colecciones pueden proporcionarles a los usuarios contenido actualizado. Por ejemplo, el widget de Gmail les brinda a los usuarios un resumen de su carpeta Recibidos. Para que esto sea posible, activa tu RemoteViewsFactory y la vista de colección para recuperar y mostrar datos nuevos.

Para ello, usa AppWidgetManager para llamar a notifyAppWidgetViewDataChanged(). Esta llamada genera una devolución de llamada al método onDataSetChanged() de tu objeto RemoteViewsFactory, lo que te permite recuperar cualquier dato nuevo.

Puedes realizar operaciones de procesamiento intensivo de forma síncrona dentro de la devolución de llamada onDataSetChanged(). Esta llamada se completa antes de que se recuperen los metadatos o los datos de la vista de RemoteViewsFactory. También puedes realizar operaciones de procesamiento intensivo dentro del método getViewAt(). Si esta llamada tarda mucho, la vista de carga, que se especifica con el método getLoadingView() del objeto RemoteViewsFactory, se muestra en la posición correspondiente de la vista de colección hasta que se devuelve.

Usa RemoteCollectionItems para pasar una colección directamente

Android 12 (nivel de API 31) agrega el método setRemoteAdapter(int viewId, RemoteViews.RemoteCollectionItems items), que permite que tu app pase una colección directamente cuando se propaga una vista de colección. Si configuras tu adaptador con este método, no necesitas implementar un RemoteViewsFactory ni llamar a notifyAppWidgetViewDataChanged().

Además de facilitar la propagación de tu adaptador, este enfoque también quita la latencia para propagar elementos nuevos cuando los usuarios se desplazan por la lista para revelar un elemento nuevo. Este enfoque para configurar el adaptador es el preferido, siempre y cuando el conjunto de elementos de la colección sea relativamente pequeño. Sin embargo, por ejemplo, este enfoque no funciona bien si tu colección contiene muchos Bitmaps que se pasan a setImageViewBitmap.

Si la colección no usa un conjunto constante de diseños, es decir, si algunos elementos solo están presentes algunas veces, usa setViewTypeCount para especificar la cantidad máxima de diseños únicos que puede contener la colección. Esto permite que el adaptador se reutilice en las actualizaciones del widget de tu app.

A continuación, se muestra un ejemplo de cómo implementar colecciones de RemoteViews simplificadas.

val itemLayouts = listOf(
    R.layout.item_type_1,
    R.layout.item_type_2,
    // ...
)

remoteView.setRemoteAdapter(
    R.id.list_view,
    RemoteViews.RemoteCollectionItems.Builder()
        .addItem(/* id= */ ID_1, RemoteViews(context.packageName, R.layout.item_type_1))
        .addItem(/* id= */ ID_2, RemoteViews(context.packageName, R.layout.item_type_2))
        // ...
        .setViewTypeCount(itemLayouts.count())
        .build()
)