Gestos

O Compose fornece diversas APIs para ajudar a detectar gestos originados de interações do usuário. As APIs abrangem uma grande variedade de casos de uso:

  • Algumas delas são de alto nível e foram projetadas para abranger os gestos mais usados. Por exemplo, o modificador clickable facilita a detecção de cliques e também fornece recursos de acessibilidade e exibe indicadores visuais quando tocado (como ondulações).

  • Também existem detectores de gestos menos usados, que oferecem mais flexibilidade em um nível inferior, como PointerInputScope.detectTapGestures ou PointerInputScope.detectDragGestures, mas não incluem os recursos complementares.

Tocar e pressionar

O modificador clickable permite que os apps detectem cliques no elemento em que ele é aplicado.

@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 }
    )
}

Exemplo de um elemento de IU respondendo a toques

Quando for necessário ter mais flexibilidade, forneça um detector de gestos de toque usando o 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 */ }
    )
}

Rolagem

Modificadores de rolagem

Os modificadores verticalScroll e horizontalScroll oferecem a forma mais simples de permitir que o usuário role um elemento quando os limites do conteúdo são maiores que as restrições de tamanho máximo. Com os modificadores verticalScroll e horizontalScroll, não é necessário transladar nem deslocar o conteúdo.

@Composable
fun ScrollBoxes() {
    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .verticalScroll(rememberScrollState())
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

Lista vertical simples respondendo a gestos de rolagem

O ScrollState permite mudar a posição de rolagem ou descobrir o estado atual. Para criá-lo com parâmetros padrão, use 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 scrollable

O modificador scrollable é diferente dos modificadores de rolagem, porque scrollable detecta os gestos de rolagem, mas não desloca o conteúdo. Um ScrollableState é necessário para que esse modificador funcione corretamente. Ao criar ScrollableState, é necessário fornecer uma função consumeScrollDelta, que vai ser invocada em cada etapa de rolagem (por entrada de gestos, rolagem suave ou deslizamento rápido) com o delta em pixels. Essa função precisa retornar a quantidade de distância de rolagem consumida. Isso é para garantir que o evento seja propagado corretamente nos casos em que há elementos aninhados com o modificador scrollable.

O snippet a seguir detecta os gestos e exibe um valor numérico para o deslocamento, mas não desloca os elementos:

@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())
    }
}

Elemento da IU que detecta o pressionamento da tela e exibe o valor numérico referente ao local de pressionamento

Rolagem aninhada

O Compose é compatível com a rolagem aninhada, em que vários elementos reagem a um único gesto de rolagem. Um exemplo típico de rolagem aninhada é uma lista dentro de outra, e um caso mais complexo é uma barra de ferramentas recolhível (em inglês).

Rolagem aninhada automática

Nenhuma ação é necessária para a rolagem aninhada simples. Os gestos que iniciam uma ação de rolagem são propagados automaticamente para os pais. Assim, quando o elemento filho não consegue rolar mais, o gesto é processado pelo pai.

Há suporte para a rolagem aninhada automática e ela é fornecida de imediato por alguns componentes e modificadores do Compose: verticalScroll, horizontalScroll, scrollable, APIs Lazy e TextField. Isso significa que, quando o usuário rola um filho interno de componentes aninhados, os modificadores anteriores propagam os deltas de rolagem para os pais que têm suporte à rolagem aninhada.

O exemplo a seguir mostra elementos com um modificador verticalScroll aplicado em um contêiner que também tem um modificador verticalScroll aplicado a ele.

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)
                )
            }
        }
    }
}

Dois elementos da IU de rolagem vertical aninhados, respondendo a gestos dentro e fora do elemento interno

Como usar o modificador nestedScroll

Caso você precise criar uma rolagem coordenada avançada entre vários elementos, o modificador nestedScroll oferece mais flexibilidade, definindo uma hierarquia de rolagem aninhada. Como mencionado na seção anterior, alguns componentes têm suporte integrado à rolagem aninhada. No entanto, no caso de elementos de composição que não podem ser rolados automaticamente, como Box ou Column, os deltas de rolagem não serão propagados no sistema de rolagem aninhado e não vão alcançar a NestedScrollConnection nem o componente pai. Se quiser resolver isso, use nestedScroll para conferir esse suporte a outros componentes, inclusive aos componentes personalizados.

Interoperabilidade de rolagem aninhada (a partir do Compose 1.2.0)

Ao tentar aninhar elementos View roláveis em elementos de composição roláveis, ou vice-versa, talvez você encontre problemas. Os mais perceptíveis aconteceriam ao rolar o filho e atingir o limite inicial ou final, esperando que o pai assuma a rolagem. Esse comportamento pode não acontecer ou não funcionar como previsto.

Esse problema é resultado das expectativas criadas em elementos de composição roláveis. Esses elementos têm uma regra "nested-scroll-by-default", que significa que qualquer contêiner rolável precisa participar da cadeia de rolagem aninhada, ambos como um pai pela NestedScrollConnection e como um filho pelo NestedScrollDispatcher. Quando o filho estivesse no limite, ele geraria uma rolagem aninhada para o pai. Por exemplo, essa regra permite que Pager e LazyRow do Compose funcionem bem juntos. No entanto, quando a rolagem de interoperabilidade ocorre com a ViewPager2 ou a RecyclerView, como elas não implementam a NestedScrollingParent3, a rolagem contínua de filho para pai não pode ser feita.

Para ativar a API de interoperabilidade de rolagem aninhada entre elementos View roláveis e elementos de composição roláveis, aninhados em ambas as direções, você pode usar a API para mitigar esses problemas nos cenários a seguir.

Uma visualização mãe colaborativa que contém uma ComposeView filha

Uma View mãe colaborativa é aquela que já implementa a NestedScrollingParent3 e, por isso, pode receber deltas de rolagem de um elemento de composição filho que é colaborativo e aninhado. A ComposeView atuaria como uma filha nesse caso e precisaria implementar (indiretamente) a NestedScrollingChild3. Um exemplo de um pai colaborativo é o androidx.coordinatorlayout.widget.CoordinatorLayout.

Caso você precise de interoperabilidade de rolagem aninhada entre contêineres pai roláveis de View e elementos de composição filhos roláveis e aninhados, use rememberNestedScrollInteropConnection().

A função rememberNestedScrollInteropConnection() permite e se lembra da NestedScrollConnection, que ativa a interoperabilidade de rolagem aninhada entre uma View mãe que implementa a NestedScrollingParent3 e um filho de composição. Ela precisa ser usada em conjunto com um modificador nestedScroll. Como a rolagem aninhada é ativada por padrão no lado do Compose, você pode usar essa conexão para ativar a rolagem aninhada no lado da View e adicionar a conexão necessária entre Views e elementos de composição.

Um caso de uso frequente é a utilização de CoordinatorLayout, CollapsingToolbarLayout e um elemento de composição, como mostrado neste exemplo:

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

Na atividade ou no fragmento, você precisa configurar o elemento de composição filho e a NestedScrollConnection necessária:

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())
                        }
                    }
                }
            }
        }
    }
}

Um elemento de composição pai que contém uma AndroidView filha

Esse cenário aborda a implementação da API de interoperabilidade de rolagem aninhada no lado do Compose, quando há um elemento de composição pai contendo uma AndroidView filha. A AndroidView implementa o NestedScrollDispatcher, que atua como filha para um pai de rolagem do Compose, e a NestedScrollingParent3, que atua como uma View de rolagem filha. O pai de composição vai poder receber deltas de rolagem aninhados em uma View filha de rolagem aninhada.

O exemplo a seguir mostra como alcançar a interoperabilidade de rolagem aninhada nesse cenário, junto com uma barra de ferramentas recolhível do 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) {
            // ...
        }
    }
    // ...
}

O exemplo a seguir mostra como usar a API com um 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 fim, este exemplo mostra como a API de interoperabilidade de rolagem aninhada é usada com a classe BottomSheetDialogFragment para possibilitar um comportamento de arrastar e dispensar:

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
        }
    }
}

Observe que a função rememberNestedScrollInteropConnection() vai instalar uma NestedScrollConnection no elemento ao qual ela será anexada. A NestedScrollConnection é responsável por transmitir os deltas do nível de composição para o nível da View. Isso permite que o elemento participe da rolagem aninhada, mas não ativa a rolagem automática de elementos. No caso de elementos de composição que não podem ser rolados automaticamente, como Box ou Column, os deltas de rolagem não serão propagados no sistema de rolagem aninhado. Além disso, os deltas não vão conseguir alcançar a NestedScrollConnection fornecida pela rememberNestedScrollInteropConnection(). Portanto, esses deltas não vão alcançar o componente View mãe. Para resolver isso, defina também modificadores roláveis para esses tipos de elementos de composição aninhados. Consulte a seção anterior sobre Rolagem aninhada para ver mais informações.

Uma visualização mãe não colaborativa que contém uma ComposeView filha

Uma visualização não colaborativa é aquela que não implementa as interfaces NestedScrolling necessárias no lado da View. Isso significa que a interoperabilidade de rolagem aninhada nessas Views não funciona imediatamente. As Views não colaborativas são a RecyclerView e a ViewPager2.

Arrastar

O modificador draggable é o ponto de entrada de alto nível para gestos de arrastar em uma única orientação e informa a distância da ação de arrastar em pixels.

É importante observar que esse modificador é semelhante a scrollable, porque ele detecta apenas o gesto. É necessário manter o estado e representá-lo na tela, por exemplo, movendo o elemento com o 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!"
)

Caso você precise controlar todo o gesto de arrastar, considere usar o detector de gestos de arrastar, com o 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
                }
            }
    )
}

Um elemento da IU sendo arrastado por pressionamento da tela

Deslizar

O modificador swipeable permite arrastar elementos que, quando soltos, são animados em direção a dois ou mais pontos de fixação definidos na orientação. Um uso comum para isso é a implementação do padrão "deslizar para dispensar".

É importante ressaltar que esse modificador não move o elemento, apenas detecta o gesto. É necessário manter o estado e representá-lo na tela, por exemplo, movendo o elemento com o modificador offset.

O estado deslizante é obrigatório no modificador swipeable e pode ser criado e lembrado com rememberSwipeableState(). Esse estado também fornece um conjunto de métodos úteis para inserir animações de forma programática nos pontos fixos (consulte snapTo, animateTo, performFling e performDrag) e as propriedades para observar o progresso da ação de arrastar.

O gesto de deslizar pode ser configurado para ter diferentes tipos de limite, como FixedThreshold(Dp) e FractionalThreshold(Float). Esses limites podem ser diferentes para cada combinação de ponto de partida e chegada dos pontos fixos.

Para ter mais flexibilidade, você pode configurar resistance ao deslizar para além dos limites. Também é possível configurar velocityThreshold, que fará a animação do gesto de deslizar para o próximo estado, mesmo que os thresholds não tenham sido alcançados.

@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)
        )
    }
}

Um elemento da IU respondendo a um gesto de deslizar

Multitoque: panorâmica, zoom, rotação

Para detectar gestos multitoque usados para colocar na panorâmica, aplicar zoom e girar, use o modificador transformable. Ele não transforma os elementos por si só, apenas detecta os 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()
    )
}

Um elemento da IU respondendo a gestos multitoque: panorâmica, zoom e rotação

Caso você precise combinar zoom, panorâmica e rotação com outros gestos, use o detector PointerInputScope.detectTransformGestures.