Modificatori di scorrimento nidificati

Lo scorrimento nidificato è un sistema in cui più componenti di scorrimento contenuti l'uno nell'altro collaborano reagendo a un singolo gesto di scorrimento e comunicando i relativi delta di scorrimento (modifiche).

Il sistema di scorrimento nidificato consente il coordinamento tra i componenti scorrevoli e collegati gerarchicamente (nella maggior parte dei casi condividendo lo stesso elemento principale). Questo sistema collega i container di scorrimento e consente l'interazione con i delta di scorrimento che vengono propagati e condivisi tra loro.

Compose offre diversi modi per gestire lo scorrimento nidificato tra i componibili. Un esempio tipico di scorrimento nidificato è un elenco all'interno di un altro elenco, mentre un caso più complesso è una barra degli strumenti comprimibile.

Scorrimento nidificato automatico

Lo scorrimento nidificato semplice non richiede alcun intervento da parte tua. I gesti che avviano un'azione di scorrimento vengono propagati automaticamente dagli elementi secondari a quelli principali, in modo che quando l'elemento secondario non può più scorrere, il gesto viene gestito dall'elemento principale.

Lo scorrimento nidificato automatico è supportato e fornito immediatamente da alcuni componenti e modificatori di Compose: verticalScroll, horizontalScroll, scrollable, API Lazy e TextField. Ciò significa che quando l'utente scorre un elemento secondario interno di componenti nidificati, i modificatori precedenti propagano i delta di scorrimento agli elementi principali che supportano lo scorrimento nidificato.

L'esempio seguente mostra gli elementi con un modificatore verticalScroll applicato all'interno di un container a cui è applicato anche un modificatore verticalScroll.

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

Due elementi UI di scorrimento verticale nidificati, che rispondono ai gesti all'interno e all'esterno dell'elemento interno
Figura 1. Due elementi UI di scorrimento verticale nidificati, che rispondono ai gesti all'interno e all'esterno dell'elemento interno.

Utilizzo del modificatore nestedScroll

Se devi creare uno scorrimento coordinato avanzato tra più elementi, il modificatore nestedScroll offre una maggiore flessibilità definendo una gerarchia di scorrimento nidificata. Come menzionato nella sezione precedente, alcuni componenti hanno il supporto integrato per lo scorrimento nidificato. Tuttavia, per i composabili che non sono scorrevoli automaticamente, come Box o Column, i delta di scorrimento su questi componenti non verranno propagati nel sistema di scorrimento nidificato e i delta non raggiungeranno NestedScrollConnection né il componente principale. Per risolvere il problema, puoi utilizzare nestedScroll per conferire questo supporto ad altri componenti, inclusi quelli personalizzati.

Ciclo di scorrimento nidificato

Il ciclo di scorrimento nidificato è il flusso di delta di scorrimento che vengono inviati su e giù per l'albero della gerarchia attraverso tutti i componenti (o nodi) che fanno parte del sistema di scorrimento nidificato, ad esempio utilizzando componenti e modificatori scorrevoli o nestedScroll.

Fasi del ciclo di scorrimento nidificato

Quando un evento di attivazione (ad esempio un gesto) viene rilevato da un componente scorrevole, prima ancora che venga attivata l'azione di scorrimento effettiva, i delta generati vengono inviati al sistema di scorrimento nidificato e passano attraverso tre fasi: pre-scorrimento, consumo del nodo e post-scorrimento.

Fasi del ciclo di scorrimento nidificato
Figura 2. Fasi del ciclo di scorrimento nidificato.

Nella prima fase, prima dello scorrimento, il componente che ha ricevuto i delta degli eventi di attivazione invia questi eventi verso l'alto, attraverso l'albero della gerarchia, fino al componente principale più in alto. Gli eventi delta verranno quindi propagati verso il basso, il che significa che i delta verranno propagati dal livello principale più alto verso il livello secondario che ha avviato il ciclo di scorrimento nidificato.

Fase di pre-scorrimento: invio verso l'alto
Figura 3. Fase di pre-scorrimento: invio verso l'alto.

In questo modo, i componenti principali di scorrimento nidificati (componibili che utilizzano nestedScroll o modificatori scorrevoli) hanno la possibilità di eseguire un'azione con il delta prima che il nodo stesso possa utilizzarlo.

Fase di pre-scorrimento: bubbling verso il basso
Figura 4. Fase di pre-scorrimento: bubbling verso il basso.

Nella fase di consumo del nodo, il nodo stesso utilizzerà il delta non utilizzato dai relativi elementi principali. Questo è il momento in cui il movimento di scorrimento viene effettivamente eseguito ed è visibile.

Fase di consumo dei nodi
Figura 5. Fase di consumo del nodo.

Durante questa fase, il bambino può scegliere di consumare tutto o parte del rotolo rimanente. Tutto ciò che rimane verrà inviato nuovamente per passare alla fase di post-scorrimento.

Infine, nella fase post-scorrimento, tutto ciò che non è stato consumato dal nodo stesso verrà inviato nuovamente ai suoi predecessori per il consumo.

Fase post-scorrimento: invio verso l'alto
Figura 6. Fase post-scorrimento: invio verso l'alto.

La fase post-scorrimento funziona in modo simile alla fase pre-scorrimento, in cui uno qualsiasi dei genitori può scegliere se consumare o meno.

Fase post-scorrimento: bubbling verso il basso
Figura 7. Fase post-scorrimento: bubbling verso il basso.

Analogamente allo scorrimento, quando un gesto di trascinamento termina, l'intenzione dell'utente può essere tradotta in una velocità utilizzata per scorrere (scorrimento utilizzando un'animazione) il contenitore scorrevole. Anche lo scorrimento rapido fa parte del ciclo di scorrimento nidificato e le velocità generate dall'evento di trascinamento passano attraverso fasi simili: pre-scorrimento rapido, consumo del nodo e post-scorrimento rapido. Tieni presente che l'animazione di scorrimento rapido è associata solo al gesto di tocco e non verrà attivata da altri eventi, come l'accessibilità o lo scorrimento hardware.

Partecipa al ciclo di scorrimento nidificato

La partecipazione al ciclo significa intercettare, consumare e segnalare il consumo di delta lungo la gerarchia. Compose fornisce un insieme di strumenti per influenzare il funzionamento del sistema di scorrimento nidificato e come interagire direttamente con questo, ad esempio quando devi eseguire un'operazione con i delta di scorrimento prima che un componente scorrevole inizi a scorrere.

Se il ciclo di scorrimento nidificato è un sistema che agisce su una catena di nodi, il modificatore nestedScroll è un modo per intercettare e inserire queste modifiche e influenzare i dati (delta di scorrimento) propagati nella catena. Questo modificatore può essere posizionato in qualsiasi punto della gerarchia e comunica con le istanze del modificatore di scorrimento nidificate nell'albero in modo da poter condividere informazioni tramite questo canale. I componenti di base di questo modificatore sono NestedScrollConnection e NestedScrollDispatcher.

NestedScrollConnection offre un modo per rispondere alle fasi del ciclo di scorrimento nidificato e influenzare il sistema di scorrimento nidificato. È composto da quattro metodi di callback, ognuno dei quali rappresenta una delle fasi di consumo: pre/post-scorrimento e pre/post-scorrimento veloce:

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

Ogni callback fornisce anche informazioni sul delta propagato: delta available per quella particolare fase e delta consumed consumato nelle fasi precedenti. Se in qualsiasi momento vuoi interrompere la propagazione dei delta verso l'alto nella gerarchia, puoi utilizzare la connessione di scorrimento nidificata:

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

Tutti i callback forniscono informazioni sul tipo NestedScrollSource.

NestedScrollDispatcher inizializza il ciclo di scorrimento nidificato. L'utilizzo di un dispatcher e la chiamata dei relativi metodi attiva il ciclo. I container scorrevoli hanno un dispatcher integrato che invia al sistema i delta acquisiti durante i gesti. Per questo motivo, la maggior parte dei casi d'uso della personalizzazione dello scorrimento nidificato prevede l'utilizzo di NestedScrollConnection anziché di un dispatcher, per reagire ai delta già esistenti anziché inviarne di nuovi. Per altri utilizzi, consulta NestedScrollDispatcherSample.

Ridimensiona un'immagine durante lo scorrimento

Man mano che l'utente scorre, puoi creare un effetto visivo dinamico in cui l'immagine cambia dimensioni in base alla posizione di scorrimento.

Ridimensiona un'immagine in base alla posizione di scorrimento

Questo snippet mostra come ridimensionare un'immagine all'interno di un LazyColumn in base alla posizione di scorrimento verticale. L'immagine si rimpicciolisce quando l'utente scorre verso il basso e si ingrandisce quando scorre verso l'alto, rimanendo entro i limiti di dimensioni minime e massime definiti:

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

Punti chiave sul codice

  • Questo codice utilizza un NestedScrollConnection per intercettare gli eventi di scorrimento.
  • onPreScroll calcola la variazione delle dimensioni dell'immagine in base al delta di scorrimento.
  • La variabile di stato currentImageSize memorizza le dimensioni correnti dell'immagine, vincolate tra minImageSize e maxImageSize. imageScale, derivate da currentImageSize.
  • LazyColumn compensa in base a currentImageSize.
  • Image utilizza un modificatore graphicsLayer per applicare la scala calcolata.
  • La classe translationY all'interno di graphicsLayer garantisce che l'immagine rimanga centrata verticalmente durante il ridimensionamento.

Risultato

Lo snippet precedente genera un effetto di ridimensionamento dell'immagine durante lo scorrimento:

Figura 8. Un effetto di ridimensionamento dell'immagine durante lo scorrimento.

Interoperabilità dello scorrimento nidificato

Quando provi a nidificare elementi View scorrevoli in composabili scorrevoli o viceversa, potresti riscontrare problemi. I più evidenti si verificano quando scorri l'elemento secondario e raggiungi i limiti di inizio o fine e ti aspetti che l'elemento principale prenda il controllo dello scorrimento. Tuttavia, questo comportamento previsto potrebbe non verificarsi o non funzionare come previsto.

Questo problema è dovuto alle aspettative integrate nei componibili scorrevoli. I componibili scorrevoli hanno una regola "scorrimento nidificato per impostazione predefinita", il che significa che qualsiasi contenitore scorrevole deve partecipare alla catena di scorrimento nidificato, sia come elemento principale tramite NestedScrollConnection sia come elemento secondario tramite NestedScrollDispatcher. Il componente secondario attiverà quindi uno scorrimento nidificato per il componente principale quando il componente secondario si trova al limite. Ad esempio, questa regola consente a Compose Pager e Compose LazyRow di funzionare bene insieme. Tuttavia, quando lo scorrimento di interoperabilità viene eseguito con ViewPager2 o RecyclerView, poiché questi non implementano NestedScrollingParent3, lo scorrimento continuo dall'elemento secondario a quello principale non è possibile.

Per abilitare l'API di interoperabilità dello scorrimento nidificato tra elementi View scorrevoli e componibili scorrevoli, nidificati in entrambe le direzioni, puoi utilizzare l'API di interoperabilità dello scorrimento nidificato per mitigare questi problemi nei seguenti scenari.

Un genitore cooperante View contenente un figlio ComposeView

Un elemento principale cooperante View è un elemento che implementa già NestedScrollingParent3 e pertanto è in grado di ricevere delta di scorrimento da un elemento secondario nidificato componibile cooperante. In questo caso, ComposeView agirebbe come elemento secondario e dovrebbe implementare (indirettamente) NestedScrollingChild3. Un esempio di genitore cooperante è androidx.coordinatorlayout.widget.CoordinatorLayout.

Se hai bisogno dell'interoperabilità dello scorrimento nidificato tra i container principali View scorrevoli e i componibili secondari scorrevoli nidificati, puoi utilizzare rememberNestedScrollInteropConnection().

rememberNestedScrollInteropConnection() consente e memorizza la NestedScrollConnection che consente l'interoperabilità dello scorrimento nidificato tra un elemento principale View che implementa NestedScrollingParent3 e un elemento secondario di Compose. Deve essere utilizzato insieme a un modificatore nestedScroll. Poiché lo scorrimento nidificato è abilitato per impostazione predefinita sul lato Compose, puoi utilizzare questa connessione per abilitare lo scorrimento nidificato sul lato View e aggiungere la logica di collegamento necessaria tra Views e i componibili.

Un caso d'uso frequente è l'utilizzo di CoordinatorLayout, CollapsingToolbarLayout e un composable secondario, mostrato in questo esempio:

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

Nella tua Activity o nel tuo Fragment, devi configurare il composable secondario e l'oggetto NestedScrollConnection richiesto:

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 composable principale contenente un elemento secondario AndroidView

Questo scenario riguarda l'implementazione dell'API di interoperabilità dello scorrimento nidificato sul lato Compose, quando hai un composable principale contenente un AndroidView secondario. AndroidView implementa NestedScrollDispatcher, poiché agisce come elemento secondario di un elemento principale di scorrimento di Compose, nonché NestedScrollingParent3 , poiché agisce come elemento principale di un elemento secondario di scorrimento View. Il componente principale di Compose potrà quindi ricevere delta di scorrimento nidificati da un componente secondario scorrevole nidificato View.

L'esempio seguente mostra come ottenere l'interoperabilità dello scorrimento nidificato in questo scenario, insieme a una barra degli strumenti comprimibile di 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(
            title = {},
            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) {
            // ...
        }
    }
    // ...
}

Questo esempio mostra come utilizzare l'API con un modificatore 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(R.layout.list_item, null)
                    .apply {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(this, true)
                    }
            }
        )
    }
}

Infine, questo esempio mostra come viene utilizzata l'API di interoperabilità dello scorrimento nidificato con BottomSheetDialogFragment per ottenere un comportamento di trascinamento e chiusura efficace:

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

Tieni presente che rememberNestedScrollInteropConnection() installerà un NestedScrollConnection nell'elemento a cui lo colleghi. NestedScrollConnection è responsabile della trasmissione dei delta dal livello Compose al livello View. In questo modo, l'elemento può partecipare allo scorrimento nidificato, ma lo scorrimento degli elementi non viene attivato automaticamente. Per i composabili che non sono scorrevoli automaticamente, come Box o Column, i delta di scorrimento su questi componenti non verranno propagati nel sistema di scorrimento nidificato e non raggiungeranno il NestedScrollConnection fornito da rememberNestedScrollInteropConnection(), pertanto non raggiungeranno il componente View principale. Per risolvere il problema, assicurati di impostare anche i modificatori di scorrimento su questi tipi di elementi componibili nidificati. Per informazioni più dettagliate, puoi consultare la sezione precedente sullo scorrimento nidificato.

Un elemento principale non cooperante View contenente un elemento secondario ComposeView

Una View non cooperante è una View che non implementa le interfacce NestedScrolling necessarie sul lato View. Tieni presente che ciò significa che l'interoperabilità dello scorrimento nidificato con questi Views non funziona immediatamente. I Views non cooperanti sono RecyclerView e ViewPager2.

Risorse aggiuntive