Modificadores de rolagem aninhada

A rolagem aninhada é um sistema em que vários componentes de rolagem contidos uns nos outros funcionam juntos reagindo a um único gesto de rolagem e comunicando os deltas (mudanças) de rolagem.

O sistema de rolagem aninhada permite a coordenação entre componentes roláveis e vinculados hierarquicamente (na maioria das vezes, compartilhando o mesmo pai). Esse sistema vincula contêineres de rolagem e permite a interação com os deltas de rolagem que estão sendo propagados e compartilhados entre eles.

O Compose oferece várias maneiras de processar a rolagem aninhada entre elementos combináveis. Um exemplo típico de rolagem aninhada é uma lista dentro de outra, e um caso mais complexo é uma barra de ferramentas recolhível.

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, Lazy APIs 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 verticalScroll modificador aplicado a eles dentro de um contêiner que também tem um verticalScroll modificador aplicado.

@Composable
private fun AutomaticNestedScroll() {
    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 de IU de rolagem vertical aninhados, respondendo a gestos dentro e fora do elemento interno
Figura 1. Dois elementos de rolagem vertical aninhados da interface, 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 nestedScroll modificador 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 combináveis 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. Para resolver isso, use nestedScroll para conferir esse suporte a outros componentes, incluindo os personalizados.

Ciclo de rolagem aninhada

O ciclo de rolagem aninhada é o fluxo de deltas de rolagem enviados para cima e para baixo na árvore de hierarquia por todos os componentes (ou nós) que fazem parte do sistema de rolagem aninhada, por exemplo, usando componentes e modificadores roláveis ou nestedScroll.

Fases do ciclo de rolagem aninhada

Quando um evento de acionamento (por exemplo, um gesto) é detectado por um componente rolável, antes que a ação de rolagem real seja acionada, os deltas gerados são enviados para o sistema de rolagem aninhada e passam por três fases: pré-rolagem, consumo de nós e pós-rolagem.

Fases de um ciclo de rolagem
aninhada
Figura 2. Fases do ciclo de rolagem aninhada.

Na primeira fase, de pré-rolagem, o componente que recebeu os deltas do evento de acionamento vai enviar esses eventos para cima, pela árvore de hierarquia, até o pai mais alto. Os eventos delta vão então subir, o que significa que os deltas serão propagados do pai mais alto para o filho que iniciou o ciclo de rolagem aninhada.

Fase de pré-rolagem: envio
para cima
Figura 3. Fase de pré-rolagem: envio para cima.

Isso oferece aos pais de rolagem aninhada (elementos combináveis que usam nestedScroll ou modificadores roláveis) a oportunidade de fazer algo com o delta antes que o nó possa consumi-lo.

Fase de pré-rolagem: propagação
para baixo
Figura 4. Fase de pré-rolagem: envio para baixo.

Na fase de consumo de nós, o nó vai usar qualquer delta que não tenha sido usado pelos pais. É quando o movimento de rolagem é feito e fica visível.

Fase de consumo de nós
Figura 5. Fase de consumo de nós.

Durante essa fase, o filho pode consumir todo ou parte da rolagem restante. Qualquer coisa que sobrar será enviada de volta para passar pela fase de pós-rolagem.

Por fim, na fase de pós-rolagem, tudo o que o nó não consumiu será enviado novamente aos ancestrais para consumo.

Fase pós-rolagem: envio
para cima
Figura 6. Fase de pós-rolagem: envio para cima.

A fase de pós-rolagem funciona de maneira semelhante à fase de pré-rolagem, em que qualquer um dos pais pode escolher consumir ou não.

Fase pós-rolagem: propagação
para baixo
Figura 7. Fase de pós-rolagem: envio para baixo.

Semelhante à rolagem, quando um gesto de arrastar termina, a intenção do usuário pode ser traduzida em uma velocidade usada para deslizar rapidamente (rolar usando uma animação) o contêiner rolável. O movimento de deslizar também faz parte do ciclo de rolagem aninhada, e as velocidades geradas pelo evento de arrastar passam por fases semelhantes: pré-deslize, consumo de nós e pós-deslize. A animação de deslize só está associada ao gesto de toque e não será acionada por outros eventos, como rolagem de acessibilidade ou de hardware.

Participar do ciclo de rolagem aninhada

A participação no ciclo significa interceptar, consumir e informar o consumo de deltas ao longo da hierarquia. O Compose oferece um conjunto de ferramentas para influenciar o funcionamento do sistema de rolagem aninhada e como interagir diretamente com ele, por exemplo, quando você precisa fazer algo com os deltas de rolagem antes que um componente rolável comece a rolar.

Se o ciclo de rolagem aninhada for um sistema que atua em uma cadeia de nós, o nestedScroll modificador será uma maneira de interceptar e inserir essas mudanças e influenciar os dados (deltas de rolagem) que são propagados na cadeia. Esse modificador pode ser colocado em qualquer lugar da hierarquia e se comunica com instâncias de modificadores de rolagem aninhada na árvore para que possa compartilhar informações por esse canal. Os blocos de construção desse modificador são NestedScrollConnection e NestedScrollDispatcher.

NestedScrollConnection oferece uma maneira de responder às fases do ciclo de rolagem aninhada e influenciar o sistema de rolagem aninhada. Ela é composta por quatro métodos de callback, cada um representando uma das fases de consumo: pré/pós-rolagem e pré/pós-deslize:

val nestedScrollConnection = object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        println("Received onPreScroll callback.")
        return Offset.Zero
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        println("Received onPostScroll callback.")
        return Offset.Zero
    }
}

Cada callback também fornece informações sobre o delta que está sendo propagado: delta available para essa fase específica e delta consumed consumido nas fases anteriores. Se você quiser parar de propagar deltas na hierarquia, use a conexão de rolagem aninhada para fazer isso:

val disabledNestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return if (source == NestedScrollSource.SideEffect) {
                available
            } else {
                Offset.Zero
            }
        }
    }
}

Todos os callbacks fornecem informações sobre o NestedScrollSource tipo.

NestedScrollDispatcher inicializa o ciclo de rolagem aninhada. O uso de um dispatcher e a chamada dos métodos dele acionam o ciclo. Os contêineres roláveis têm um dispatcher integrado que envia deltas capturados durante gestos para o sistema. Por esse motivo, a maioria dos casos de uso de personalização de rolagem aninhada envolve o uso de NestedScrollConnection em vez de um dispatcher, para reagir a deltas já existentes em vez de enviar novos. Consulte NestedScrollDispatcherSample para mais usos.

Redimensionar uma imagem na rolagem

À medida que o usuário rola, você pode criar um efeito visual dinâmico em que a imagem muda de tamanho com base na posição de rolagem.

Redimensionar uma imagem com base na posição de rolagem

Este snippet demonstra o redimensionamento de uma imagem em uma LazyColumn com base em posição de rolagem vertical. A imagem diminui à medida que o usuário rola para baixo e aumenta à medida que rola para cima, permanecendo dentro dos limites de tamanho mínimo e máximo definidos:

@Composable
fun ImageResizeOnScrollExample(
    modifier: Modifier = Modifier,
    maxImageSize: Dp = 300.dp,
    minImageSize: Dp = 100.dp
) {
    var currentImageSize by remember { mutableStateOf(maxImageSize) }
    var imageScale by remember { mutableFloatStateOf(1f) }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // Calculate the change in image size based on scroll delta
                val delta = available.y
                val newImageSize = currentImageSize + delta.dp
                val previousImageSize = currentImageSize

                // Constrain the image size within the allowed bounds
                currentImageSize = newImageSize.coerceIn(minImageSize, maxImageSize)
                val consumed = currentImageSize - previousImageSize

                // Calculate the scale for the image
                imageScale = currentImageSize / maxImageSize

                // Return the consumed scroll amount
                return Offset(0f, consumed.value)
            }
        }
    }

    Box(Modifier.nestedScroll(nestedScrollConnection)) {
        LazyColumn(
            Modifier
                .fillMaxWidth()
                .padding(15.dp)
                .offset {
                    IntOffset(0, currentImageSize.roundToPx())
                }
        ) {
            // Placeholder list items
            items(100, key = { it }) {
                Text(
                    text = "Item: $it",
                    style = MaterialTheme.typography.bodyLarge
                )
            }
        }

        Image(
            painter = ColorPainter(Color.Red),
            contentDescription = "Red color image",
            Modifier
                .size(maxImageSize)
                .align(Alignment.TopCenter)
                .graphicsLayer {
                    scaleX = imageScale
                    scaleY = imageScale
                    // Center the image vertically as it scales
                    translationY = -(maxImageSize.toPx() - currentImageSize.toPx()) / 2f
                }
        )
    }
}

Principais pontos sobre o código

  • Esse código usa um NestedScrollConnection para interceptar eventos de rolagem.
  • onPreScroll calcula a mudança no tamanho da imagem com base no delta de rolagem.
  • A variável de estado currentImageSize armazena o tamanho atual da imagem, restrita entre minImageSize e maxImageSize. imageScale deriva de currentImageSize.
  • Os LazyColumn deslocamentos com base no currentImageSize.
  • O Image usa um modificador graphicsLayer para aplicar a escala calculada.
  • O translationY dentro da graphicsLayer garante que a imagem permaneça centralizada verticalmente à medida que é dimensionada.

Resultado

O snippet anterior resulta em um efeito de imagem de escalonamento na rolagem:

Figura 8. Um efeito de imagem de escalonamento na rolagem.

Interoperabilidade de rolagem aninhada

Ao tentar aninhar elementos View roláveis em elementos combináveis 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. No entanto, esse comportamento esperado pode não acontecer ou não funcionar como esperado.

Esse problema é resultado das expectativas criadas em elementos combináveis 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 combináveis roláveis, aninhados em ambas as direções, você pode usar a API para mitigar esses problemas nos cenários a seguir.

Uma mãe colaborativa View que contém uma filha ComposeView

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

Caso você precise de interoperabilidade de rolagem aninhada entre contêineres pai roláveis de View e elementos combináveis 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 nestedScroll modificador. 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 do View e adicionar a conexão necessária entre Views e elementos combináveis.

Um caso de uso frequente é usar CoordinatorLayout, CollapsingToolbarLayout e um elemento combinável filho, 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 combinável filho e a necessária NestedScrollConnection:

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 combinável 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 combinável pai contendo uma filha AndroidView. O AndroidView implementa NestedScrollDispatcher, já que atua como filho de um pai de rolagem do Compose, e também NestedScrollingParent3 , já que atua como pai de um filho de rolagem View. O pai de composição vai poder receber deltas de rolagem aninhados em uma filha View 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 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 rememberNestedScrollInteropConnection() instala um NestedScrollConnection no elemento a que você o anexa. 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 combináveis 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 combináveis aninhados. Consulte a seção anterior sobre rolagem aninhada para mais detalhes.

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

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.

Outros recursos