Contextual Action Mode En Android

El Contextual Action Mode en Android es una forma de proveer acciones contextuales a través de un menú que es inflado sobre un componente emergente llamado Contextual Action Bar:

Apariencia de Contextual Action Mode en Android

Esta barra reemplaza momentáneamente a la app bar de la actividad donde te encuentres para aportar acciones relacionados a un contexto. Viene en una presentación primaria o flotante.

El framework de Android usa la clase ActionMode para manejar este componente. Y al igual que los menús contextuales flotantes, el evento más popular para mostrar este elemento sería un click prolongado sobre el view.

No obstante, en el caso de las listas, el click simple sobre elementos como checkboxes, también es viable para iniciar el modo de acción para administrar una selección múltiple.

Ejemplo De Contextual Action Mode En Android

En este tutorial aprenderás a crear, iniciar y manejar eventos del modo de acción contextual al interactuar con un ítem individual o una lista de elementos:

La idea es responder a la selección de uno o múltiples elementos. Puedes descargar el proyecto en Android Studio de estos ejemplo aquí:

Te recomiendo realizar primero la lectura de Crear Listas Con RecyclerView En Android para no perderte en los detalles de cómo fue creada la grilla.

Teniendo esto claro, a continuación te mostraré los pasos para iniciar el contextual action mode sobre la galería de fotos.


1. Crear Recurso De Menú

El primer paso es crear el recurso de menú desde el cual se inflarán las acciones contextuales que se inflarán sobre la contextual action bar:

Previsualización del recurso de menú para grilla de fotos

Para ello presiona click derecho sobre tu modulo en Android Studio y selecciona New>Android Resource File. En la ventana que aparezca selecciona menu y pon el nombre que desees al archivo XML. Para mi caso será cab_photos_menu:

Recurso de menú para Contextual Action Mode

Luego añade las etiquetas <item> para materializar las acciones del menú. Para la galería tendremos: «Eliminar», «Etiquetar», «Mover» y «Calcular tamaño»:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/delete"
        android:icon="@drawable/ic_delete"
        android:title="@string/delete"
        app:showAsAction="ifRoom" />
    <item
        android:id="@+id/tag"
        android:icon="@drawable/ic_label"
        android:title="@string/tag"
        app:showAsAction="ifRoom" />
    <item
        android:id="@+id/move"
        android:icon="@drawable/ic_move"
        android:title="@string/move"
        app:showAsAction="ifRoom" />
    <item
        android:id="@+id/size"
        android:icon="@drawable/ic_size"
        app:showAsAction="ifRoom"
        android:title="@string/size" />
</menu>

2. Implementar ActionMode.Callback

La interfaz ActionMode.Callback se encarga de manejar y configurar los eventos generados por la interacción del usuario con el modo de acción contextual.

Tu objetivo es crear un ejemplar concreto que implemente este tipo con el fin de pasarlo al método View.startActionMode(), el cual inicia el modo de acción.

De manera que ve a la actividad principal, crea una propiedad llamada actionModeCallback y asígnale una expresión de objeto para la callback:

private val actionModeCallback = object : ActionMode.Callback {
    override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
        TODO("Not yet implemented")
    }

    override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
        TODO("Not yet implemented")
    }

    override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
        TODO("Not yet implemented")
    }

    override fun onDestroyActionMode(mode: ActionMode) {
        TODO("Not yet implemented")
    }
}

Los métodos de control son similares a los que hemos visto en los demás tipos de menú, puesto que son invocados para cubrir los comportamientos del menú.

Veamos cómo implementarlos.

onCreateActionMode()

Aquí aplicas la construcción del menú que se presentará en la contextual action bar. Es decir, la llamada a MenuInflater.inflate() o la creación programática de la instancia Menu.

Ya que en este ejemplo usamos un recurso, entonces inflamos su contenido y retornamos true (confirmación del consumo):

override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
    menuInflater.inflate(R.menu.cab_photos_menu, menu)
    return true
}

onPrepareActionMode()

Es similar a onPrepareOptionsMenu() del menú de opciones. Se llama luego de onCreateActionMode() o cuando se invalida el modo de acción, con el objetivo de actualizar el contenido del menú.

Como no tendremos cambios en las opciones iniciales asociadas a nuestra galería, retornamos false para indicar inacción:

override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
    return false
}

onActionItemClicked()

Este método es llamado cada que el usuario realiza un click sobre los ítems del menú. Así que a partir del id del argumento MenuItem puedes realizar una comparación con la expresión when y decidir que sentencias ejecutar.

override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
    return when (item.itemId) {
        R.id.delete -> {
            // Eliminar fotos
            true
        }
        R.id.tag, R.id.move, R.id.size -> {
            // Mostrar Toast
            true
        }
        else -> false
    }
}

Concretamente, la eliminación de fotos será la acción que aplicamos realmente en nuestra lista. Por simplicidad, las demás acciones muestran un Toast con la cantidad de fotos afectadas en selección.

onDestroyActionMode()

Se llama cuando el modo de acción contextual es cerrado. Las acciones que desencadenan el cierre de la contextual action bar son:

  1. El usuario deshace la selección de todos los ítems
  2. El usuario presiona el Back button
  3. El usuario presiona el Done button ubicado en la parte izquierda de la contextual action bar
  4. Invocas programáticamente al método ActionMode.finish()

Visto que este método percibe la destrucción del modo de acción, entonces es el mejor lugar para limpiar todos los estados asociados a la existencia del menú que está desapareciendo.

override fun onDestroyActionMode(mode: ActionMode) {
    actionMode = null
}

3. Escuchar Evento De Inicio De ActionMode

Ahora bien, aunque ya tienes cubierto el manejo del ciclo de vida del modo de acción contextual, aún no estás percibiendo el estímulo que hará que se inicie.

Habíamos dicho que el evento de click prolongado es la forma estándar para iniciarlo.

Floating ActionMode con click prolongado

Por lo que si tuviéramos una pantalla mostrando una sola foto (ver módulo FloatingActionMode del proyecto), la solución sería invocar a setOnLongClickListener():

private var actionMode: ActionMode? = null

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    image = findViewById(R.id.image_container)
    image.setOnLongClickListener { view ->
        when (actionMode) {
            null -> {
                actionMode = view.startActionMode(floatingActionMode, ActionMode.TYPE_FLOATING)
                view.isActivated = true
                true
            }
            else -> false
        }
    }
}

Como ves, el código anterior inicia el modo de acción contextual con startActionMode() y recibe el contenido en actionMode.

Luego modifica la propiedad isActivated para cambiar el color del view por si tiene como drawable un recursos layer list. Aunque esto puede ser reemplazado por un cambio de background u otro efecto visual que indique selección.

Nota: El modo de acción flotante usa la abstracción ActionMode.Callback2 y su tipo debe ser indicado con ActionMode.TYPE_FLOAT en el inicio.


Selección Múltiple En ActionMode

No obstante, hacer lo anterior pero con múltiples ítems en un RecyclerView requiere más trabajo, ya que se deben conservar las selecciones.

¿Cómo lo logramos?

Aunque la selección múltiple puede diseñarse manualmente o con una librería de terceros. Disponemos de la librería recyclerview-selection de Android.

Esta será la opción que usaremos para controlar la representación visual de los elementos seleccionados y la actualización del título de la contextual action bar con el número de seleccionados.

Veamos los pasos para conseguirlo.

1. Añadir Dependencia Gradle

Abre tú archivo build.gradle del módulo donde usarás la librería de selección y agrega la siguiente dependencia en su última versión:

implementation 'androidx.recyclerview:recyclerview:-selection:ultima-version'

2. Implementar ItemKeyProvider

La abstracción ItemKeyProvider es usada por la librería para diferenciar a los ítems del RecyclerView en la selección.

Los tipos disponibles para las claves a proveer son Parcelable, String y Long. Para nuestro ejemplo usaremos String, ya que las fotos usan un id de este tipo.

data class Photo(
    val featuredImage: Int,
    val id: String = UUID.randomUUID().toString()
)

Con esto en mente, crearemos un archivo Kotlin llamado PhotoSelection.kt y añadiremos la siguiente implementación de clase PhotoKeyProvider:

class PhotoKeyProvider(private val adapter: PhotoAdapter) :
    ItemKeyProvider<String>(SCOPE_CACHED) {

    override fun getKey(position: Int): String {
        return adapter.currentList[position].id
    }

    override fun getPosition(key: String): Int {
        return adapter.currentList.indexOfFirst { photo -> photo.id == key }
    }
}

La definición anterior recibe como parámetro a nuestro adaptador de fotos de tipo ListAdapter y pasa como valor de alcance a SCOPE_CACHED al padre. Esta constante indica que usaremos un almacenamiento en caché simple.

Los métodos getKey() y getPosition() retornar en la clave y la posición de cada ítem basado en su correspondencia. Aquí el adaptador nos permite obtener ambos elementos a través de la propiedad currentList, la cual nos da acceso a las fotos.

3. Implementar ItemDetailsLookup

Este componente permite que la librería de selección encuentre detalles un ítem del RecyclerView a partir de su método getItemDetails().

Para implementarlo, solo añade una nueva clase llamada PhotoDetailsLookup al archivo PhotoSelection.kt y sobrescribe getItemDetails():

class PhotoDetailsLookup(private val recyclerView: RecyclerView) :
    ItemDetailsLookup<String>() {
    override fun getItemDetails(e: MotionEvent): ItemDetails<String>? {
        val view: View? = recyclerView.findChildViewUnder(e.x, e.y)
        if (view != null) {
            val holder = recyclerView.getChildViewHolder(view)
            if (holder is PhotoViewHolder) {
                return holder.details
            }
        }
        return null
    }
}

La idea es obtener el view asociado al área del parámetro MotionEvent y acceder al ViewHolder asociado. A la final se retorna el detalle como resultado.

Dicho detalle lo obtenemos creando una nueva propiedad en PhotoViewHolder con tipo retorno ItemDetailsLookup.ItemDetails:

inner class PhotoViewHolder(view: View) :
    RecyclerView.ViewHolder(view) {
    //...

    val details
        get() = object : ItemDetailsLookup.ItemDetails<String>() {
            override fun getPosition(): Int = bindingAdapterPosition

            override fun getSelectionKey(): String? = getItem(bindingAdapterPosition).id
        }
}

La clase ItemDetails conecta el adaptador hacia la librería para obtener el estado actual de cada ítem en él con sus métodos getPosition() y getSelectionKey().

Debido a que el ViewHolder tiene la propiedad bindingAdapterPosition para determinar la posición, estos métodos son sencillos de escribir.

4. Construir SelectionTracker

El punto de entrada principal de la librería es la clase SelectionTracker, el cual orquesta a los demás elementos para rastrear las selecciones en el RecyclerView.

Para construir una instancia de esta abstracción usa a SelectionTracker.Builder y pasa como parámetro a todas las implementaciones que has creado hasta el momento.

private fun setUpPhotoGrid() {
    photoGrid = findViewById(R.id.grid)
    val photoAdapter = PhotoAdapter()
    photoGrid.adapter = photoAdapter

    tracker = SelectionTracker.Builder(
        "photo-tracker",
        photoGrid,
        PhotoKeyProvider(photoAdapter),
        PhotoDetailsLookup(photoGrid),
        StorageStrategy.createStringStorage()
    ).build()

    photoAdapter.selectionTracker = tracker

    photoAdapter.submitList(repository.getAll())
}

Concretamente el builder recibe: a un ID para el tracker, al recycler view, el key provider, el item details y luego una estrategia de almacenamiento.

Para este caso usamos la estregia StorageStrategy.createStringStorage() que construye un objeto que representa los estados de las claves String que usará internamente el tracker.

Claro está que nuestro adaptador debe tener una propiedad mutable para el tracker con el fin de consultar las selecciones y decidir el aspecto de nuestros views:

class PhotoAdapter : ListAdapter<Photo, PhotoAdapter.PhotoViewHolder>(PhotoDiffCallback()) {

    var selectionTracker: SelectionTracker<String>? = null

5. Cambiar Background De Ítems En Selección

Lo siguiente sería cambiar el fondo de los ítems de la lista desde onBindViewHolder() del adaptador.

En particular lo que haremos será alternar la visibilidad de un view que sirva de overlay sobre cada foto.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="174dp"
    android:padding="4dp">

    <ImageView
        android:id="@+id/photo"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:contentDescription="@string/photo_desc"
        android:scaleType="centerCrop"
        tools:srcCompat="@tools:sample/backgrounds/scenic[6]" />

    <View
        android:id="@+id/selection_overlay"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/overlay"
        android:visibility="invisible" />
</FrameLayout>

Y es aquí donde entra el método SelectionTracker.isSelected() para determinar si el ítem actual está seleccionado a partir de su id.

override fun onBindViewHolder(holder: PhotoViewHolder, position: Int) {
    val photo = getItem(position)
    selectionTracker?.let {
        holder.bind(photo, it.isSelected(photo.id))
    }
}

inner class PhotoViewHolder(view: View) :
    RecyclerView.ViewHolder(view) {
    private val featuredImage: ImageView = view.findViewById(R.id.photo)
    private val overlay: View = view.findViewById(R.id.selection_overlay)


    fun bind(article: Photo, isSelected: Boolean) {
        overlay.visibility = if (isSelected) View.VISIBLE else View.INVISIBLE
        featuredImage.setImageResource(article.featuredImage)
    }

    val details
        get() = object : ItemDetailsLookup.ItemDetails<String>() {
            override fun getPosition(): Int = bindingAdapterPosition

            override fun getSelectionKey(): String? = getItem(bindingAdapterPosition).id
        }
}

Nuestra propiedad determinante para que el overlay sea revelado será visibility. Dependiendo de la respuesta del tracker, así mismo será el resultado de la expresión if (VISIBLE o INVISIBLE).

Siguiendo estos pasos verás que ya es posible seleccionar múltiples ítems sin tener que asignar una escucha de click prolongado:

Ejemplo librería recyclerview-selection
Ejemplo de librería recyclerview-selection

Pero esto solo es la mitad de nuestro objetivo, ahora veamos la coordinación con el ActionMode.

6. Observar Cambios De Selección

Usaremos el componente SelectionTracker.SelectionObserver para manejar los eventos de selección que la librería percibe.

Con relación a las acciones que deseamos realizar, tenemos:

  • Modificar título del modo contextual cuando se selecciona y deselecciona un ítem
  • Cerrar el modo contextual cuando no existan selecciones
  • Cerrar el modo contextual cuando se presione el botón Done

Por consiguiente, vayamos a la actividad principal y añadamos un observador al tracker para que inicie el ActionMode:

private fun setUpPhotoGrid() {
    //...

    tracker.addObserver(object : SelectionTracker.SelectionObserver<String>() {
        override fun onSelectionChanged() {

            if (tracker.hasSelection()) {
                if (actionMode == null) {
                    actionMode = startSupportActionMode(actionModeCallback)
                }
                updateContextualActionBarTitle()
            } else {
                actionMode?.finish()
            }
        }
    })
}

private fun updateContextualActionBarTitle() {
    actionMode?.title = "${tracker.selection.size()} seleccionados"
}

¿Cómo funciona el código anterior?

  1. Asignamos la escucha con addObserver() y pasamos una expresión de objeto anónima.
  2. Sobrescribimos al método onSelectionChanged() que es llamado por la librería cada que hay una selección
  3. Usamos el método SelectionTracker.hasSelection() para determinar si existe al menos un ítem seleccionado
    1. Si es así, instanciamos el modo de acción si aún no existe. Luego actualizamos el título del modo de acción
    2. De lo contrario finalizamos el action mode

Luego cubrimos el caso cuando el botón Done es presionado, es decir, limpiar las selecciones del tracker en onDestroyActionMode():

override fun onDestroyActionMode(mode: ActionMode) {
    tracker.clearSelection()
    actionMode = null
}

El método correspondiente para limpiar selecciones desde el rastreador selecciones sería clearSelection().

Así pues, marcar el inicio y final del ActionMode te permitirá mostrar la action bar contextual:

recyclerview-selection con ActionMode

7. Preservar Selección En Cambios De Configuración

Hasta el momento si corres el aplicativo, seleccionas elementos y luego rotas la pantalla, verás que se pierden las marcas establecidas:

SelectionTracker sin estado guardado

¿Cómo solucionar este inconveniente?

Bien sabemos que las actividades y fragmentos tienen un mecanismo para conservar sus estados internos a través de los métodos onSaveInstanceState() y onRestoreInstanceState().

Y afortunadamente, la clase SelectionTracker tiene dos métodos equivalentes para guardar y almacenar el su estado:

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    tracker.onSaveInstanceState(outState)
}

override fun onRestoreInstanceState(savedInstanceState: Bundle) {
    super.onRestoreInstanceState(savedInstanceState)
    tracker.onRestoreInstanceState(savedInstanceState)

    if (tracker.hasSelection()) {
        actionMode = startSupportActionMode(actionModeCallback)
        updateContextualActionBarTitle()
    }
}

Aunque el ActionMode no se iniciará si no llamamos al método de inicio en onRestoreInstanceState().

Si pruebas de inmediato esta sencilla implementación, verás que ahora sí, las selecciones se sostienen en cambios de configuración:

SelectionTracker con estado guardado

8. Manejar Eventos Del ActionMode

Ya para finalizar, añadiremos la lógica para eliminar las fotos seleccionadas cuando se presione el icono de la caneca en el ActionMode.

Eliminar múltiples ítems de un RecyclerView

Si has revisado el proyecto, verás que en el módulo PrimaryActionMode tenemos a la clase PhotosRepository. Este adaptador contiene una sencilla estrategia en memoria para almacenar nuestras fotos:

object PhotosRepository {
    private val inMemoryPhotos = mutableListOf(
        Photo(R.drawable.photo1),
        Photo(R.drawable.photo2),
        Photo(R.drawable.photo3),
        Photo(R.drawable.photo4),
        Photo(R.drawable.photo5),
        Photo(R.drawable.photo6),
        Photo(R.drawable.photo7),
        Photo(R.drawable.photo8),
        Photo(R.drawable.photo9),
        Photo(R.drawable.photo10)
    )

    fun getAll(): List<Photo> = inMemoryPhotos.toList()

    fun delete(photoIdsToRemove: List<String>) {
        inMemoryPhotos.removeAll { photo -> photo.id in photoIdsToRemove }
    }
}

Si observas su interfaz, tiene un método llamado delete() que recibe una lista de IDs de fotos para remover dichos elementos de la lista con removeAll().

Para invocarlo primero crearemos un método que maneje el click sobre las eliminaciones en onActionItemClicked() de la callback del ActionMode:

override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
    return when (item.itemId) {
        R.id.delete -> {
            deletePhotos()
            true
        }
        R.id.tag, R.id.move, R.id.size -> {
            showMessage(item)
            true
        }
        else -> false
    }
}

La concreción de deletePhotos() se basa en el uso de la propiedad SelectionTracker.selection, la cual contiene todas las selecciones actuales:

private fun deletePhotos() {
    val ids = tracker.selection.map { it }
    repository.delete(ids)
    (photoGrid.adapter as PhotoAdapter).submitList(repository.getAll())
    actionMode?.finish()
}

En consecuencia, mapeamos los objetos tipo Selection que selection provee con el objetivo de obtener los IDs de las fotos a eliminar.

Luego actualizamos al adaptador con submitList() y finalizamos el modo de acción contextual.


Cambiar Estilo De La Contextual Action Bar

Y como último agregado, observemos la modificación del tema de la App para que nuestro action bar contextual tome estilos en sus componentes:

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <!--...-->

        <item name="windowActionModeOverlay">true</item>
        <item name="actionModeStyle">@style/Widget.App.ActionMode</item>
        <item name="actionModeCloseDrawable">@drawable/ic_close</item>
        <item name="actionBarTheme">@style/ThemeOverlay.MaterialComponents.Dark.ActionBar</item>
    </style>
</resources>

Puedes cambiar el icono para el botón Done a través del atributo actionModeCloseDrawable. Normalmente será una flecha hacia la izquierda, pero en este ejemplo usamos un símbolo x.

También puedes asignar un tema específico con actionBarTheme. O Configurar completamente el estilo con actionModeStyle como hicimos en nuestro archivo styles.xml:

<resources>
    <style name="Widget.App.ActionMode" parent="Widget.AppCompat.ActionMode">
        <item name="titleTextStyle">?attr/textAppearanceHeadline6</item>
        <item name="subtitleTextStyle">?attr/textAppearanceSubtitle1</item>
        <item name="background">@color/material_grey_900</item>
    </style>
</resources>

Debes tomar como padre a Widget.AppCompat.ActionMode para poder cambiar atributos como: titleTextStyle (apariencia título), subtitleText (apariencia subtitulo) y background (fondo)


¿Qué Sigue?

En este tutorial aprendizte a iniciar el Contextual Action Mode en Android a través de la clase ActionMode. Viste como a partir de un recurso de menú se infla la Contextual Action Bar para proveer acciones asociadas a la selección de un ítem o múltiples elementos.

Ahora puedes culminar el apartado de menús yendo al tutorial sobre la clase PopupMenu para mostrar acciones asociadas al contenido de un view particular. También puedes explorar:

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!