Compose proporciona una variedad de API para ayudarte a detectar gestos que se generan a partir de las interacciones de los usuarios. Las API abarcan una gran variedad de casos de uso:
Algunos son de alto nivel y están diseñados para cubrir los gestos más utilizados. Por ejemplo, el modificador
clickable
permite la detección sencilla de un clic y, además, proporciona funciones de accesibilidad y muestra indicadores visuales cuando ante toques (como ondas).También hay detectores de gestos menos usados que ofrecen más flexibilidad en un nivel más bajo, como
PointerInputScope.detectTapGestures
oPointerInputScope.detectDragGestures
, pero no incluyas las funciones adicionales.
Toques
El modificador clickable
permite que las apps detecten clics en el elemento al que se aplica.
@Composable
fun ClickableSample() {
val count = remember { mutableStateOf(0) }
// content that you want to make clickable
Text(
text = count.value.toString(),
modifier = Modifier.clickable { count.value += 1 }
)
}
Cuando se necesita más flexibilidad, puedes proporcionar un detector de gestos de toque a través del modificador pointerInput
:
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = { /* Called when the gesture starts */ },
onDoubleTap = { /* Called on Double Tap */ },
onLongPress = { /* Called on Long Press */ },
onTap = { /* Called on Tap */ }
)
}
Desplazamiento
Modificadores de desplazamiento
Los selectores verticalScroll
y horizontalScroll
proporcionan la forma más sencilla de permitir que el usuario se desplace por un elemento cuando los límites del contenido sean más grandes que las restricciones de tamaño máximo. Con los modificadores verticalScroll
y horizontalScroll
, no necesitas traducir ni compensar el contenido.
@Composable
fun ScrollBoxes() {
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
.verticalScroll(rememberScrollState())
) {
repeat(10) {
Text("Item $it", modifier = Modifier.padding(2.dp))
}
}
}
ScrollState
te permite cambiar la posición del desplazamiento u obtener su estado actual. Para crearlo con los parámetros predeterminados, usa rememberScrollState()
.
@Composable
private fun ScrollBoxesSmooth() {
// Smoothly scroll 100px on first composition
val state = rememberScrollState()
LaunchedEffect(Unit) { state.animateScrollTo(100) }
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
.padding(horizontal = 8.dp)
.verticalScroll(state)
) {
repeat(10) {
Text("Item $it", modifier = Modifier.padding(2.dp))
}
}
}
Modificador desplazable
El modificador scrollable
difiere de los modificadores de desplazamiento porque scrollable
detecta los gestos de desplazamiento, pero no desplaza su contenido. Se necesita un ScrollableState
para que este modificador funcione correctamente.
Cuando construyas ScrollableState
, debes proporcionar una función consumeScrollDelta
que se invoque en cada paso de desplazamiento (con entrada de gestos, desplazamiento suave o arrastrar y soltar) con el delta en píxeles.
Esta función debe mostrar la distancia de desplazamiento consumida para garantizar que el evento se propague de forma adecuada en los casos en que haya elementos anidados que tengan el modificador scrollable
.
En el siguiente fragmento, se detectan los gestos y se muestra un valor numérico para un desplazamiento, pero no se desplaza ningún elemento:
@Composable
fun ScrollableSample() {
// actual composable state
var offset by remember { mutableStateOf(0f) }
Box(
Modifier
.size(150.dp)
.scrollable(
orientation = Orientation.Vertical,
// Scrollable state: describes how to consume
// scrolling delta and update offset
state = rememberScrollableState { delta ->
offset += delta
delta
}
)
.background(Color.LightGray),
contentAlignment = Alignment.Center
) {
Text(offset.toString())
}
}
Desplazamiento anidado
Compose admite desplazamiento anidado, en el que múltiples elementos reaccionan a un solo gesto de desplazamiento. Un ejemplo típico de desplazamiento anidado es una lista dentro de otra, y un caso más complejo es una barra de herramientas que se puede contraer.
Desplazamiento anidado automático
El desplazamiento anidado simple no requiere ninguna acción de tu parte. Los gestos que inician una acción de desplazamiento se propagan de elementos secundarios a superiores de forma automática, de modo que cuando el elemento secundario no puede desplazarse más, se controla el gesto con el elemento superior.
El desplazamiento automático anidado es compatible y proporcionado de forma predeterminada por algunos de los componentes y modificadores de Compose: verticalScroll
, horizontalScroll
, scrollable
, las API de Lazy
y TextField
. Esto significa que cuando el usuario se desplaza por un elemento secundario interno de componentes anidados, los modificadores anteriores propagan los deltas de desplazamiento a los elementos superiores que son compatibles con el desplazamiento anidado.
En el siguiente ejemplo, se muestran los elementos con un modificador verticalScroll
aplicado dentro de un contenedor que también tiene un modificador verticalScroll
aplicado.
val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
Box(
modifier = Modifier
.background(Color.LightGray)
.verticalScroll(rememberScrollState())
.padding(32.dp)
) {
Column {
repeat(6) {
Box(
modifier = Modifier
.height(128.dp)
.verticalScroll(rememberScrollState())
) {
Text(
"Scroll here",
modifier = Modifier
.border(12.dp, Color.DarkGray)
.background(brush = gradient)
.padding(24.dp)
.height(150.dp)
)
}
}
}
}
Cómo usar el modificador nestedScroll
Si necesitas crear un desplazamiento coordinado avanzado entre varios elementos, el modificador nestedScroll
te brinda más flexibilidad definiendo una jerarquía de desplazamiento anidada.
Como se mencionó en la sección anterior, algunos componentes tienen compatibilidad integrada con el desplazamiento anidado. Sin embargo, en el caso de los elementos componibles que no se pueden desplazar automáticamente, como Box
o Column
, los deltas de desplazamiento en esos componentes no se propagarán en el sistema de desplazamiento anidado y los deltas no llegarán al NestedScrollConnection
ni al componente superior. Para resolver este problema, puedes usar nestedScroll
a fin de conferir esa compatibilidad a otros componentes, incluidos los personalizados.
Interoperabilidad de desplazamiento anidado (a partir de Compose 1.2.0)
Cuando intentas anidar elementos View
desplazables en elementos componibles desplazables, o viceversa, es posible que encuentres problemas.
Los más notables suceden cuando te desplazas por el elemento secundario y alcanzas sus límites de inicio o finalización, y esperas que el elemento superior se desplace por él. Sin embargo, es posible que este comportamiento esperado no suceda o no funcione como se espera.
Este problema es el resultado de las expectativas que se compilan en elementos componibles desplazables.
Los elementos componibles desplazables tienen una regla de "desplazamiento anidado predeterminado", lo que significa que cualquier contenedor desplazable debe participar en la cadena de desplazamiento anidado, ambos como elemento superior mediante NestedScrollConnection
y como elemento secundario a través de NestedScrollDispatcher
.
Luego, el elemento secundario generaría un desplazamiento anidado para el elemento superior cuando el secundario esté en el límite. A modo de ejemplo, esta regla permite que Compose Pager
y Compose LazyRow
funcionen bien en conjunto. Sin embargo, cuando el desplazamiento de interoperabilidad se realiza con ViewPager2
o RecyclerView
, ya que no se implementa NestedScrollingParent3
, el desplazamiento continuo del elemento secundario al superior no es posible.
A fin de habilitar la API de interoperabilidad de desplazamiento anidada entre elementos View
desplazables y elementos componibles desplazables, anidados en ambas direcciones, puedes usar la API de interoperabilidad de desplazamiento anidada para mitigar estos problemas en las siguientes situaciones.
Una vista superior cooperativa que contiene un ComposeView secundario
Un elemento superior View
cooperativo es el que ya implementa NestedScrollingParent3
y, por lo tanto, puede recibir deltas de desplazamiento de un elemento secundario cooperativo, anidado y componible. En este caso, ComposeView
actuaría como elemento secundario y tendría que implementar NestedScrollingChild3
(de forma indirecta).
Un ejemplo de un elemento superior cooperativo es androidx.coordinatorlayout.widget.CoordinatorLayout
.
Si necesitas interoperabilidad de desplazamiento anidado entre contenedores superiores View
desplazables y elementos componibles desplazables secundarios, puedes usar rememberNestedScrollInteropConnection()
.
rememberNestedScrollInteropConnection()
permite y recuerda el NestedScrollConnection
que permite la interoperabilidad de desplazamiento anidada entre un elemento superior de View
que implementa NestedScrollingParent3
y un elemento secundario de Compose. Se debe usar junto con un modificador nestedScroll
. Dado que el desplazamiento anidado está habilitado de forma predeterminada en Compose, puedes usar esta conexión para habilitar el desplazamiento anidado en el lado View
y agregar la lógica de unión necesaria entre Views
y los elementos que admiten composición.
Un caso de uso frecuente consiste en implementar CoordinatorLayout
, CollapsingToolbarLayout
y un elemento secundario componible, como se muestra en este ejemplo:
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="100dp"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsing_toolbar_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<!--...-->
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
En tu actividad o fragmento, debes configurar el elemento secundario que admite composición y el elemento NestedScrollConnection
solicitado:
open class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<ComposeView>(R.id.compose_view).apply {
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
// Add the nested scroll connection to your top level @Composable element
// using the nestedScroll modifier.
LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) {
items(20) { item ->
Box(
modifier = Modifier
.padding(16.dp)
.height(56.dp)
.fillMaxWidth()
.background(Color.Gray),
contentAlignment = Alignment.Center
) {
Text(item.toString())
}
}
}
}
}
}
}
Un elemento superior componible que contiene un AndroidView secundario
En esta situación, se trata la implementación de la API de interoperabilidad de desplazamiento anidada en el lado de Compose, cuando tienes un elemento superior que admite composición y que contiene un AndroidView
secundario. El AndroidView
implementa NestedScrollDispatcher
, ya que actúa como un elemento secundario de un elemento superior de desplazamiento de Compose, así como NestedScrollingParent3
porque actúa como superior para unView
elemento secundario de desplazamiento. Luego, el elemento superior de Compose podrá recibir deltas de desplazamiento anidados desde un View
secundario de desplazamiento anidado.
En el siguiente ejemplo, se muestra cómo lograr una interoperabilidad de desplazamiento anidada, en este caso, junto con una barra de herramientas que se contrae de Compose:
@Composable
private fun NestedScrollInteropComposeParentWithAndroidChildExample() {
val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() }
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
// Sets up the nested scroll connection between the Box composable parent
// and the child AndroidView containing the RecyclerView
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Updates the toolbar offset based on the scroll to enable
// collapsible behaviour
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset.Zero
}
}
}
Box(
Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
TopAppBar(
modifier = Modifier
.height(ToolbarHeight)
.offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }
)
AndroidView(
{ context ->
LayoutInflater.from(context)
.inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply {
with(findViewById<RecyclerView>(R.id.main_list)) {
layoutManager = LinearLayoutManager(context, VERTICAL, false)
adapter = NestedScrollInteropAdapter()
}
}.also {
// Nested scrolling interop is enabled when
// nested scroll is enabled for the root View
ViewCompat.setNestedScrollingEnabled(it, true)
}
},
// ...
)
}
}
private class NestedScrollInteropAdapter :
Adapter<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() {
val items = (1..10).map { it.toString() }
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): NestedScrollInteropViewHolder {
return NestedScrollInteropViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.list_item, parent, false)
)
}
override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) {
// ...
}
class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) {
fun bind(item: String) {
// ...
}
}
// ...
}
En este ejemplo, se muestra cómo usar la API con un modificador scrollable
:
@Composable
fun ViewInComposeNestedScrollInteropExample() {
Box(
Modifier
.fillMaxSize()
.scrollable(rememberScrollableState {
// View component deltas should be reflected in Compose
// components that participate in nested scrolling
it
}, Orientation.Vertical)
) {
AndroidView(
{ context ->
LayoutInflater.from(context)
.inflate(android.R.layout.list_item, null)
.apply {
// Nested scrolling interop is enabled when
// nested scroll is enabled for the root View
ViewCompat.setNestedScrollingEnabled(this, true)
}
}
)
}
}
Por último, en este ejemplo, se muestra cómo se usa la API de interoperabilidad de desplazamiento anidada con BottomSheetDialogFragment
para lograr un comportamiento exitoso de arrastre y descarte:
class BottomSheetFragment : BottomSheetDialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false)
rootView.findViewById<ComposeView>(R.id.compose_view).apply {
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
LazyColumn(
Modifier
.nestedScroll(nestedScrollInterop)
.fillMaxSize()
) {
item {
Text(text = "Bottom sheet title")
}
items(10) {
Text(
text = "List item number $it",
modifier = Modifier.fillMaxWidth()
)
}
}
}
return rootView
}
}
}
Ten en cuenta que rememberNestedScrollInteropConnection()
instalará un NestedScrollConnection
en el elemento al que lo adjuntas. NestedScrollConnection
es responsable de transmitir los deltas del nivel de Compose al nivel de View
. Esto permite que el elemento participe en el desplazamiento anidado, pero no habilita el desplazamiento de elementos automáticamente. Para los elementos componibles que no se pueden desplazar automáticamente, como Box
o Column
, los deltas de desplazamiento en esos componentes no se propagarán en el sistema de desplazamiento anidado, y los deltas no llegarán al NestedScrollConnection
proporcionado por rememberNestedScrollInteropConnection()
, por lo tanto, esos deltas no llegarán al componente View
principal. Para solucionar este problema, asegúrate de establecer modificadores desplazables en estos tipos de elementos componibles anidados. Puedes consultar la sección anterior sobre el Desplazamiento anidado para obtener información más detallada.
Una vista superior no cooperativa que contiene un elemento ComposeView secundario
Una vista no cooperativa es la que no implementa las interfaces NestedScrolling
necesarias del lado de View
. Ten en cuenta que esto significa que la interoperabilidad de desplazamiento anidado con estos Views
no funciona de forma inmediata. Los Views
que no cooperativos son RecyclerView
y ViewPager2
.
Arrastres
El modificador draggable
es el punto de entrada de alto nivel para arrastrar gestos en una sola orientación e informa la distancia de arrastre en píxeles.
Es importante tener en cuenta que este modificador es similar a scrollable
, ya que solo detecta el gesto. Debes conservar el estado y representarlo en la pantalla, por ejemplo, para mover el elemento mediante el modificador offset
:
var offsetX by remember { mutableStateOf(0f) }
Text(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
offsetX += delta
}
),
text = "Drag me!"
)
Si necesitas controlar todo el gesto de arrastre, procura usar el detector de gestos de arrastre en su lugar, a través del modificador pointerInput
.
Box(modifier = Modifier.fillMaxSize()) {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Blue)
.size(50.dp)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
Deslices
El modificador swipeable
te permite arrastrar elementos que, cuando se lanzan, se suelen animar en dos o más puntos de anclaje definidos en una orientación. Un uso común es implementar un patrón de "deslizar para descartar".
Es importante tener en cuenta que este modificador no mueve el elemento; solo detecta el gesto. Debes conservar el estado y representarlo en la pantalla, por ejemplo, para mover el elemento mediante el modificador offset
.
El estado deslizable es obligatorio en el modificador swipeable
y se puede crear y recordar con rememberSwipeableState()
.
Este estado también proporciona un conjunto de métodos útiles para animar anclajes de manera programática (consulta snapTo
, animateTo
, performFling
y performDrag
), así como en las propiedades para observar el progreso de arrastre.
El gesto de deslizar se puede configurar para que tenga diferentes tipos de umbrales, como FixedThreshold(Dp)
y FractionalThreshold(Float)
, y pueden ser diferentes para cada punto de anclaje de combinación "from-to".
Para obtener más flexibilidad, puedes configurar resistance
cuando deslices los límites y también el velocityThreshold
que animará el deslizamiento al siguiente estado, incluso si no se alcanza el thresholds
posicional.
@Composable
fun SwipeableSample() {
val width = 96.dp
val squareSize = 48.dp
val swipeableState = rememberSwipeableState(0)
val sizePx = with(LocalDensity.current) { squareSize.toPx() }
val anchors = mapOf(0f to 0, sizePx to 1) // Maps anchor points (in px) to states
Box(
modifier = Modifier
.width(width)
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
.background(Color.LightGray)
) {
Box(
Modifier
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
.size(squareSize)
.background(Color.DarkGray)
)
}
}
Multitoque: desplazamiento lateral, zoom y rotación
Para detectar gestos multitáctiles utilizados para el desplazamiento lateral, el zoom y la rotación, puedes usar el modificador transformable
, que no transforma los elementos por sí solo; únicamente detecta los gestos.
@Composable
fun TransformableSample() {
// set up all transformation states
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
Box(
Modifier
// apply other transformations like rotation and zoom
// on the pizza slice emoji
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
// add transformable to listen to multitouch transformation events
// after offset
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}
Si necesitas combinar el zoom, el desplazamiento lateral y la rotación con otros gestos, puedes usar el detector PointerInputScope.detectTransformGestures
.