Modificadores De Gestos En Compose

En este tutorial veremos los modificadores de gestos que existen en Jetpack Compose con el fin de procesar los eventos generados por el usuario a través de la pantalla del dispositivo. Eventos como clics, scroll, drag, swipe y multitouch.


Ejemplo De Modificadores De Gestos

Para mostrar la implementación del manejo de los gestos hemos creado un nuevo módulo llamado p5_gestos dentro del proyecto Android Studio de guía de Compose:

Ejemplo de modificadores de gestos en Compose

Cada sección tiene asociado un archivo Kotlin del paquete examples. Puedes probar cada uno, reemplazando la invocación de la función componible desde GesturesActivity en setContent():

class GesturesActivity : ComponentActivity() {
    @ExperimentalMaterialApi
    @ExperimentalFoundationApi
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            RotationExample()
        }
    }
}

Descargar el proyecto desde el siguiente enlace:

Teniendo esto en cuenta, comencemos con el procesamiento del primer gesto.


Manejar Gestos De Tapping

Usa el modificador clickable() para permitir que un elemento de UI reciba clics desde la pantalla (toque breve con la punta del dedo) o el evento de clic del servicio de accesibilidad.

fun Modifier.clickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onClick: () -> Unit
): Modifier

Sus parámetros permiten:

  • enabled: Activa y desactiva este modificador
  • onClickLabel: Etiqueta de accesibilidad para la acción de clic
  • role: Describe el tipo de elemento en la interfaz de usuario para los servicios de accesibilidad
  • onClick: Manejador ejecutado cuando se hace clic en el elemento

A diferencia del sistema de views, clickable incluye los efectos visuales cuando se presiona el elemento en pantalla.

Por ejemplo:

Modifiquemos el color de fondo un elemento Box cuando el usuario tapea su contenido:

@Composable
@Preview
fun ClickableExample() {
    var background by remember {
        mutableStateOf(Color.Blue)
    }
    Box(
        Modifier
            .background(background)
            .size(150.dp)
            .clickable {
                background = randomColor()
            }
    )
}

fun randomColor() = Color(Random.nextLong(0xFFFFFFFF))

En el código anterior hemos aplicado clickable() con una lambda al final para onClick, donde modificamos el estado background a partir de la función randomColor().

Si ejecutas la App y cliqueas la caja, verás el cambio de background aleatorio:

Ejemplo clickable() en Compose

Lectura: Aprende más sobre el componente Box y estados en Compose.

Doble Clic Y Clic Prolongado

En el caso que desees manejar eventos de doble clic y clic prolongado, invoca al modificador combinedClickable().

@ExperimentalFoundationApi
fun Modifier.combinedClickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onLongClickLabel: String? = null,
    onLongClick: () -> Unit = null,
    onDoubleClick: () -> Unit = null,
    onClick: () -> Unit
): @ExperimentalFoundationApi Modifier

Este provee el procesamiento de ambos eventos con los parámetros adicionales onDoubleClick y onLongClick.

Por ejemplo:

Manejemos los eventos de tap, double tap y long press desde un Text. Adicional, en cada interacción cambiamos su estado por un String que represente al gesto detectado.

@ExperimentalFoundationApi
@Composable
fun CombinedClickableExample() {
    var text by remember {
        mutableStateOf("Ninguno")
    }

    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(
            text = "Evento: $text",
            Modifier
                .combinedClickable(
                    onDoubleClick = {
                        text = "Double tap"
                    },
                    onLongClick = {
                        text = "Long press"
                    },
                    onClick = {
                        text = "Tap"
                    }),
            fontSize = 24.sp
        )
    }
}

El ejemplo anterior retiene el tipo de evento manejado como estado del elemento Text. Luego asignamos 3 lambdas a onDoubleClick, onLongClick y onClick para mutar este valor con el texto correspondiente.

Al ejecutar el ejemplo y aplicar los eventos, el resultado se vería así:

Ejemplo combinedClickable() en Compose

Manejar Gestos De Scrolling

Scroll Horizontal

Aplica el modificador horizontalScroll() sobre un elemento de UI, para habilitar su desplazamiento horizontal cuando su contenido es mas grande que sus restricciones máximas de ancho.

fun Modifier.horizontalScroll(
    state: ScrollState,
    enabled: Boolean = true,
    flingBehavior: FlingBehavior? = null,
    reverseScrolling: Boolean = false
): Modifier

Donde:

  • state: Es el estado del scroll
  • enabled: Determina si el scroll está activo o no
  • flingBehavior: Especifica la lógica del comportamiento cuando finaliza el desplazamiento
  • reverseScrolling: Invierte la dirección del scrolling. Si pasas true, la posición inicial será la derecha, si pasas false será la izquierda

Como ves, es posible invocar el modificador sin los últimos 3 parámetros, ya que tienen valores por defecto comunes a la mayoría de situaciones.

En el caso de state, crearemos y recordaremos el estado con la función rememberScrollState(), la cual facilita la configuración de una instancia de tipo ScrollState.

Por ejemplo:

Creemos una fila que llene el ancho total de la pantalla con scroll horizontal. Luego añadimos 10 cajas como hijas con colores de fondo aleatorios.

@Composable
fun HorizontalScrollExample() {
    val scrollState = rememberScrollState()
    Row(
        Modifier
            .fillMaxWidth()
            .horizontalScroll(scrollState)
    ) {
        repeat(10) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(randomColor())
            )

        }
    }
}

El código anterior añade a la cadena de modificadores a horizontalScroll() junto a su estado. Luego usamos la función repeat() de Kotlin para generar las 10 cajas con una sola línea. La salida será:

Ejemplo horizontalScroll() en Compose

Scroll Vertical

Añade el modificador verticalScroll() para permitir el desplazamiento vertical en el caso que el alto del contenido exceda las restricciones del eje Y. Sus parámetros son exactamente iguales que horizontaScroll().

Por ejemplo:

Creemos una columna cuya altura sea de 300dp y posea scroll vertical. Luego añadamos 10 cajas con diferentes colores de fondo para visualizar el desplazamiento.

@Composable
fun VerticalScrollExample() {
    val scrollState = rememberScrollState()

    Column(
        Modifier
            .height(300.dp)
            .fillMaxWidth()
            .verticalScroll(scrollState),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        repeat(10) {
            Box(
                modifier = Modifier
                    .size(100.dp)
                    .background(randomColor())
            )
        }
    }
}

Al desplazar el contenido verticalmente:

Ejemplo de modificador verticalScroll() en Compose

Scroll Sin Movimiento

Otra forma para detectar el scroll de un contenido es el modificador scrollable(). A diferencia del scroll vertical y horizontal, este no desplaza el contenido sobre las restricciones, si no que hace un seguimiento de la distancia del scroll a partir de su parámetro state.

fun Modifier.scrollable(
    state: ScrollableState,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null,
    interactionSource: MutableInteractionSource? = null
): Modifier

Esta función requiere de la orientación del scroll y la creación del estado con rememberScrollableState(). La cual recibe una lambda del tipo (Float) -> Float, donde los parámetro son los píxeles recorridos del scroll y el resultado la cantidad de scroll consumida en el evento.

Por ejemplo:

Aumentemos o disminuyamos el canal alfa de un rectángulo cuando se realiza un gesto de scroll vertical en su contenido:

@Composable
fun ScrollableExample() {
    val boxSize = 300f
    var scrollDeltaSum by remember {
        mutableStateOf(boxSize)
    }
    
    Column(
        Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Alpha = ${scrollDeltaSum / boxSize}")
        Box(
            modifier = Modifier
                .size(boxSize.dp)
                .scrollable(
                    orientation = Orientation.Vertical,
                    state = rememberScrollableState { delta ->
                        scrollDeltaSum = (scrollDeltaSum - delta / 2).coerceIn(0f, boxSize)
                        delta
                    }
                )
                .alpha(scrollDeltaSum / boxSize)
                .background(Color.Magenta)
        )
    }
}

Como ves, creamos un estado llamado scrollDeltaSum con un valor inicial igual a las dimensiones de la caja (100%).

Esta variable llevará la suma de la cantidad de scroll en píxeles detectada en rememberScrollableState(). No obstante, forzamos a que su valor se encuentre entre 0 y 300 con coerceIn().

Esto nos permite calcular el valor del modificador alpha() a partir de la división entre scrollDeltaSum y boxSize.

El resultado será:

Scroll Anidado

Jetpack Compose te permite anidar modificadores de scrolling en una jerarquía si su comportamiento es simple.

Por ejemplo:

Creemos una fila con 5 columnas, donde cada columna tiene 5 textos hacia.

@Preview
@Composable
fun NestedScrollExample() {
    Row(
        Modifier
            .fillMaxWidth()
            .height(100.dp)
            .horizontalScroll(rememberScrollState())
    ) {

        repeat(5) {
            Column(
                Modifier
                    .size(100.dp)
                    .background(randomColor())
                    .verticalScroll(rememberScrollState())
            ) {
                repeat(5) {
                    Text(
                        "Vertical",
                        Modifier.padding(16.dp)
                    )
                }

            }
        }
    }
}

Como ves, es solo usar horizontalScroll() en la fila para desplazar las columnas y verticalScroll() sobre cada fila para desplazar los elementos Text. Este scroll anidado se proyecta así:

Ejemplo de scroll anidado en Compose

Nota: En el caso en el que requieras coordinación avanzada entre los desplazamientos, entonces usa el modificador nestedScroll() para aplicar conexiones y despachos de eventos del scroll anidado con el scroll padre.


Manejar Gestos De Arrastre

El modificador dragglable() le permite a un elemento recibir gestos de arrastre, donde el usuario toca el contenido y arrastra su dedo sin perder contacto con la superficie.

Este no realiza el desplazamiento automáticamente, por lo que debes actualizar el estado del modificador offset para lograr el movimiento.

fun Modifier.draggable(
    state: DraggableState,
    orientation: Orientation,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    startDragImmediately: Boolean = false,
    onDragStarted: suspend CoroutineScope.(Offset) -> Unit = {},
    onDragStopped: suspend CoroutineScope.(Float) -> Unit = {},
    reverseDirection: Boolean = false
): Modifier

Usa su parámetro state del tipo DragglabeState, para definir como los eventos de arrastre serán interpretados cuando el elemento aterrice sobre la superficie.

La función rememberDraggableState() será la encargada de crear y recordar el estado, proporcionandote la cantidad de píxeles en su parámetro onDelta de tipo (Float)->Unit.

Por ejemplo:

Permitamos que un círculo sea arrastrado verticalmente con el siguiente componible:

@Composable
fun DraggableExample() {
    var offsetY by remember {
        mutableStateOf(0f)
    }
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .offset { IntOffset(0, offsetY.toInt()) }
                .background(Color.Magenta, CircleShape)
                .size(50.dp)
                .draggable(
                    orientation = Orientation.Vertical,
                    state = rememberDraggableState { delta ->
                        offsetY += delta
                    })
        )
    }
}

La función componible anterior establece como estado el desplazamiento vertical del arrastre en offsetY. Su modificación se realiza en rememberDraggableState() y su actualización en el modificador offset().

Al ejecutar podrás ver lo siguiente:

Ejemplo draggable() en Compose

Manejar Gestos De Swipe

Usa el modificador swipeable() para habilitar gestos de swipe entre un conjunto de estados predefinidos. Es decir, el movimiento del contenido de un elemento a lo largo o ancho de sus límites.

<T : Any?> Modifier.swipeable(
    state: SwipeableState<T>,
    anchors: Map<Float, T>,
    orientation: Orientation,
    enabled: Boolean,
    reverseDirection: Boolean,
    interactionSource: MutableInteractionSource?,
    thresholds: (from, to) -> ThresholdConfig,
    resistance: ResistanceConfig?,
    velocityThreshold: Dp
)

Ten en cuenta el propósito de los siguientes parámetros a la hora de su invocación:

  • state: Estado del modificador de swipe. Contiene el valor actual actual del ancla que ha sido alcanzada por el gesto de swipe. Crearemos y recordaremos el estado con rememberSwipeableState().
  • anchors: Mapeado de anclas y estados. Las anclas (en píxeles) actúan como restricciones de desplazamiento con respecto a un estado del tipo T
  • thresholds: (from, to) -> ThresholdConfig: Representa a los umbrales entre diferentes estados

Veamos un ejemplo:

Procesemos el gesto de swipe horizontal de un rectángulo con el fin de que se mueva a 4 posiciones fijas a lo largo de su carril horizontal:

@ExperimentalMaterialApi
@Composable
fun SwipeableExample() {
    val width = 200.dp
    val baseAnchor = 50.dp
    val swipeableState = rememberSwipeableState(initialValue = "Fácil")

    val sizePx = with(LocalDensity.current) { baseAnchor.toPx() }

    val anchors =
        mapOf(
            0f to "Fácil",
            sizePx to "Normal",
            sizePx * 2 to "Difícil",
            sizePx * 3 to "Demente"
        )

    Box(
        modifier = Modifier
            .width(width)
            .swipeable(
                swipeableState,
                anchors = anchors,
                orientation = Orientation.Horizontal,
                thresholds = { _, _ -> FractionalThreshold(0.5f) }
            )
            .background(Color.LightGray)
    ) {
        Box(
            modifier = Modifier
                .offset { IntOffset(swipeableState.offset.value.toInt(), 0) }
                .size(baseAnchor)
                .background(Color.Cyan))
    }
}

En el código anterior:

  1. Declaramos el ancho de la pista que recorrerá la caja
  2. Definiremos cada límite de aterrizaje en 50dp
  3. Establecemos el estado inicial en el primer indicador "Fácil"
  4. Convertimos el espacio de cada límite a píxeles en sizePx
  5. Creamos las anclas usando múltiplos de sizePx
  6. Habilitamos en la caja externa la detección de gestos con swipeable() y los parámetros creados previamente. Usamos la clase FractionalThreshold a fin de especificar la fracción la distancia mínima entre anclas, para permitir el aterrizaje
  7. Aplicamos el modificador offset() en la caja interna para desplazarla a partir del valor que proporciona swipeableState.offset

El resultado del gesto de swipe creará el siguiente carril con 4 anclas:


Manejar Gestos De Transformación

Por otro lado, Compose nos permite detectar gestos multitouch que permiten transformar el tamaño, posición y rotación de un elemento en pantalla.

Para ello hacemos uso del modificador transformable(), el cual permite actualizar el estado de UI a partir de un parámetro TransformableState.

Modifier.transformable(
    state: TransformableState,
    lockRotationOnZoomPan: Boolean,
    enabled: Boolean
)

Manejamos el estado con la función rememberTransformableState(). Esta recibe una lambda (onTransformation) con tres parámetros que indican el zoom, offset y la rotación percibidos por el gesto.

@Composable
fun rememberTransformableState(
    onTransformation: (zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
)

Por ejemplo:

Permitamos al usuario rotar un rectángulo 360 grados en la dirección que desee con el uso de dos dedos en la superficie de la pantalla:

@Composable
fun RotationExample() {
    var rotation by remember {
        mutableStateOf(0f)
    }

    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .rotate(rotation)
                .transformable(
                    state = rememberTransformableState { _, _, degrees ->
                        rotation += degrees
                    })
                .size(150.dp, 300.dp)
                .background(Color.Red)
        )
    }
}

Debido a que no usamos los dos primeros parámetros de onTransformation, los ignoramos con _. El valor de la rotación es recordado en la variable rotation, por lo que incrementamos el valor para asignarlo en el modificador rotate().

Al ejecutar la App, sostén la tecla Ctrl en el emulador de Android Studio para desplegar el indicador de multitouch. Y luego presiona el clic para rotar el contenido:

Ejemplo transformable() en Compose

Ú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!