Scrolling bertingkat

Scroll bertingkat adalah sistem tempat beberapa komponen scroll yang terdapat di dalamnya bekerja sama dengan bereaksi terhadap satu gestur scroll dan mengomunikasikan delta (perubahan) scroll-nya.

Sistem scrolling bertingkat memungkinkan koordinasi antara komponen yang dapat di-scroll dan ditautkan secara hierarkis (paling sering dengan berbagi induk yang sama). Sistem ini menautkan penampung scroll dan memungkinkan interaksi dengan delta scroll yang disebarkan dan dibagikan di antara keduanya.

Compose menyediakan beberapa cara untuk menangani scroll bertingkat antar-composable. Contoh umum scroll bertingkat adalah daftar di dalam daftar lain, dan kasus yang lebih kompleks adalah toolbar yang dapat diciutkan.

Scrolling bertingkat otomatis

Scroll bertingkat sederhana tidak memerlukan tindakan dari Anda. Gestur yang memulai tindakan scroll diterapkan dari turunan ke induk secara otomatis, sehingga saat turunan tidak dapat men-scroll lagi, gestur tersebut akan ditangani oleh elemen induknya.

Scroll bertingkat otomatis didukung dan disediakan secara langsung oleh beberapa komponen dan pengubah Compose: verticalScroll, horizontalScroll, scrollable, Lazy API, dan TextField. Artinya, ketika pengguna men-scroll turunan internal komponen bertingkat, pengubah sebelumnya akan menyebarkan delta scroll ke induk yang memiliki dukungan scroll bertingkat.

Contoh berikut menunjukkan elemen dengan pengubah verticalScroll yang diterapkan di dalamnya dalam penampung yang juga memiliki pengubah verticalScroll yang diterapkan ke elemen tersebut.

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

Dua elemen UI scroll vertikal bertingkat, merespons gestur di dalam dan di luar elemen dalam
Gambar 1. Dua elemen UI scroll vertikal bertingkat, merespons gestur di dalam dan di luar elemen dalam.

Menggunakan pengubah nestedScroll

Jika Anda perlu membuat scroll terkoordinasi lanjutan di antara beberapa elemen, pengubah nestedScroll memberi Anda lebih banyak fleksibilitas dengan menentukan hierarki scroll bertingkat. Seperti yang disebutkan di bagian sebelumnya, beberapa komponen memiliki dukungan scroll bertingkat bawaan. Namun, untuk composable yang tidak dapat di-scroll secara otomatis, seperti Box atau Column, delta scroll pada komponen tersebut tidak akan menyebar di sistem scroll bertingkat dan delta tidak akan menjangkau NestedScrollConnection atau komponen induk. Untuk mengatasi hal ini, Anda dapat menggunakan nestedScroll untuk memberikan dukungan tersebut kepada komponen lain, termasuk komponen kustom.

Siklus scrolling bertingkat

Siklus scroll bertingkat adalah alur delta scroll yang dikirim ke atas dan ke bawah pohon hierarki melalui semua komponen (atau node) yang merupakan bagian dari sistem scrolling bertingkat, misalnya dengan menggunakan komponen dan pengubah yang dapat di-scroll, atau nestedScroll.

Fase siklus scrolling bertingkat

Saat peristiwa pemicu (misalnya, gestur) terdeteksi oleh komponen yang dapat di-scroll, sebelum tindakan scrolling yang sebenarnya dipicu, delta yang dihasilkan dikirim ke sistem scroll bertingkat dan melalui tiga fase: pra-scroll, penggunaan node, dan pasca-scroll.

Fase siklus scrolling bertingkat
Gambar 2. Fase siklus scroll bertingkat.

Pada fase pra-scroll pertama, komponen yang menerima pemicu peristiwa delta akan mengirimkan peristiwa tersebut ke atas, melalui hierarki pohon, ke parent teratas. Peristiwa delta kemudian akan muncul, yang berarti delta akan disebarkan dari induk paling atas ke turunan yang memulai siklus scroll bertingkat.

Fase pra-scroll - mengirim
ke atas
Gambar 3. Fase pra-scroll: mengirimkan ke atas.

Hal ini memberi induk scroll bertingkat (composable yang menggunakan nestedScroll atau pengubah yang dapat di-scroll) kesempatan untuk melakukan sesuatu dengan delta sebelum node itu sendiri dapat menggunakannya.

Fase sebelum scroll - bubbling
down
Gambar 4. Fase sebelum scroll - mengecil.

Pada fase penggunaan node, node itu sendiri akan menggunakan delta apa pun yang tidak digunakan oleh induknya. Ini adalah saat gerakan men-scroll benar-benar dilakukan dan terlihat.

Fase konsumsi node
Gambar 5. Fase konsumsi node.

Selama fase ini, anak dapat memilih untuk menggunakan semua atau sebagian sisa scroll. Semua yang tersisa akan dikirim kembali ke atas untuk melalui fase setelah scroll.

Terakhir, pada fase setelah scroll, apa pun yang tidak digunakan oleh node itu sendiri akan dikirim lagi ke ancestor-nya untuk digunakan.

Fase pasca-scroll - mengirimkan
ke atas
Gambar 6. Fase pasca-scroll - mengirimkan ke atas.

Fase setelah scroll berfungsi dengan cara yang sama seperti fase sebelum scroll, di mana setiap induk dapat memilih untuk menggunakan atau tidak.

Fase pasca-scroll - memperkecil
Gambar 7. Fase pasca-scroll - mengalir ke bawah.

Mirip dengan scroll, saat gestur menarik selesai, maksud pengguna dapat diterjemahkan menjadi kecepatan yang digunakan untuk menggeser (men-scroll menggunakan animasi) penampung yang dapat di-scroll. Gerakan cepat juga merupakan bagian dari siklus scroll bertingkat, dan kecepatan yang dihasilkan oleh peristiwa penarikan melalui fase serupa: pra-gerakan cepat, penggunaan node, dan pasca-gerakan cepat. Perhatikan bahwa animasi ayun hanya terkait dengan gestur sentuh dan tidak akan dipicu oleh peristiwa lain, seperti scroll a11y atau hardware.

Berpartisipasi dalam siklus scrolling bertingkat

Partisipasi dalam siklus berarti mencegat, menggunakan, dan melaporkan penggunaan delta di sepanjang hierarki. Compose menyediakan serangkaian alat untuk memengaruhi cara kerja sistem scroll bertingkat dan cara berinteraksi langsung dengannya, misalnya saat Anda perlu melakukan sesuatu dengan delta scroll sebelum komponen yang dapat di-scroll mulai di-scroll.

Jika siklus scroll bertingkat adalah sistem yang bekerja pada rangkaian node, pengubah nestedScroll adalah cara untuk mencegat dan menyisipkan perubahan ini, serta memengaruhi data (delta scroll) yang dipropagasi dalam rangkaian. Pengubah ini dapat ditempatkan di mana saja dalam hierarki, dan berkomunikasi dengan instance pengubah scroll bertingkat di atas hierarki sehingga dapat membagikan informasi melalui saluran ini. Blok penyusun pengubah ini adalah NestedScrollConnection dan NestedScrollDispatcher.

NestedScrollConnection memberikan cara untuk merespons fase siklus scroll bertingkat dan memengaruhi sistem scroll bertingkat. Hal ini terdiri dari empat metode callback, yang masing-masing mewakili salah satu fase konsumsi: sebelum/sesudah scroll dan sebelum/sesudah geser:

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

Setiap callback juga memberikan informasi tentang delta yang disebarkan: delta available untuk fase tertentu, dan delta consumed yang digunakan dalam fase sebelumnya. Jika suatu saat Anda ingin berhenti menyebarkan delta ke atas hierarki, Anda dapat menggunakan koneksi scroll bertingkat untuk melakukannya:

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

Semua callback memberikan informasi tentang jenis NestedScrollSource.

NestedScrollDispatcher menginisialisasi siklus scroll bertingkat. Menggunakan dispatcher dan memanggil metodenya akan memicu siklus. Penampung yang dapat di-scroll memiliki pengirim bawaan yang mengirim delta yang diambil selama gestur ke dalam sistem. Oleh karena itu, sebagian besar kasus penggunaan penyesuaian scrolling bertingkat melibatkan penggunaan NestedScrollConnection, bukan pengirim, untuk bereaksi terhadap delta yang sudah ada, bukan mengirim yang baru. Lihat NestedScrollDispatcherSample untuk mengetahui penggunaan lainnya.

Mengubah ukuran gambar saat men-scroll

Saat pengguna men-scroll, Anda dapat membuat efek visual dinamis di mana ukuran gambar berubah berdasarkan posisi scroll.

Mengubah ukuran gambar berdasarkan posisi scroll

Cuplikan ini menunjukkan cara mengubah ukuran gambar dalam LazyColumn berdasarkan posisi scroll vertikal. Gambar menyusut saat pengguna men-scroll ke bawah, dan membesar saat mereka men-scroll ke atas, tetap berada dalam batas ukuran minimum dan maksimum yang ditentukan:

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

Poin penting tentang kode

  • Kode ini menggunakan NestedScrollConnection untuk mencegat peristiwa scroll.
  • onPreScroll menghitung perubahan ukuran gambar berdasarkan delta scroll.
  • Variabel status currentImageSize menyimpan ukuran gambar saat ini, dibatasi antara minImageSize dan maxImageSize. imageScale yang berasal dari currentImageSize.
  • Offset LazyColumn berdasarkan currentImageSize.
  • Image menggunakan pengubah graphicsLayer untuk menerapkan skala yang dihitung.
  • translationY dalam graphicsLayer memastikan gambar tetap berada di tengah secara vertikal saat diskalakan.

Hasil

Cuplikan sebelumnya menghasilkan efek gambar yang diskalakan saat men-scroll:

Gambar 8. Efek penskalaan gambar saat men-scroll.

Interop scrolling bertingkat

Saat mencoba menyusun bertingkat elemen View yang dapat di-scroll dalam composable yang dapat di-scroll, atau sebaliknya, Anda mungkin mengalami masalah. Masalah yang paling terlihat akan terjadi saat Anda men-scroll turunan dan mencapai batas awal atau akhir, dan induk diharapkan mengambil alih scroll. Namun, perilaku yang diharapkan ini mungkin tidak terjadi atau mungkin tidak berfungsi seperti yang diharapkan.

Masalah ini adalah hasil dari ekspektasi yang dibuat di composable yang dapat di-scroll. Composable yang dapat di-scroll memiliki aturan "nested-scroll-by-default", yang berarti setiap penampung yang dapat di-scroll harus berpartisipasi dalam rantai scroll bertingkat, baik sebagai induk melalui NestedScrollConnection maupun sebagai turunan melalui NestedScrollDispatcher. Turunan kemudian akan mendorong scroll bertingkat untuk induk saat turunan berada di batas. Sebagai contoh, aturan ini memungkinkan Compose Pager dan Compose LazyRow untuk bekerja sama dengan baik. Namun, jika scroll interoperabilitas dilakukan dengan ViewPager2 atau RecyclerView, karena scroll ini tidak mengimplementasikan NestedScrollingParent3, scrolling berkelanjutan dari turunan ke induk tidak dapat dilakukan.

Untuk mengaktifkan API interop scroll bertingkat antara elemen View yang dapat di-scroll dan composable yang dapat di-scroll, yang ditumpuk di kedua arah, Anda dapat menggunakan API interop scroll bertingkat untuk mengurangi masalah ini, dalam skenario berikut.

View induk yang bekerja sama berisi ComposeView turunan

View induk yang bekerja sama adalah yang sudah mengimplementasikan NestedScrollingParent3 sehingga dapat menerima delta scroll dari composable turunan bertingkat yang bekerja sama. Dalam hal ini, ComposeView akan bertindak sebagai turunan dan harus (secara tidak langsung) mengimplementasikan NestedScrollingChild3. Salah satu contoh induk yang bekerja sama adalah androidx.coordinatorlayout.widget.CoordinatorLayout.

Jika memerlukan interoperabilitas scroll bertingkat antara penampung induk View yang dapat di-scroll dan composable turunan yang dapat di-scroll, Anda dapat menggunakan rememberNestedScrollInteropConnection().

rememberNestedScrollInteropConnection() mengizinkan dan mengingat NestedScrollConnection yang memungkinkan interoperabilitas scroll bertingkat antara induk View yang mengimplementasikan NestedScrollingParent3 dan turunan Compose. Ini harus digunakan bersama pengubah nestedScroll. Karena scroll bertingkat diaktifkan secara default di sisi Compose, Anda dapat menggunakan koneksi ini untuk mengaktifkan scroll bertingkat di sisi View dan menambahkan logika glue yang diperlukan antara Views dan composable.

Kasus penggunaan sering menggunakan CoordinatorLayout, CollapsingToolbarLayout, dan composable turunan, seperti ditunjukkan dalam contoh ini:

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

Dalam Activity atau Fragment, Anda perlu menyiapkan composable turunan dan NestedScrollConnection yang diperlukan:

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

Composable induk yang berisi AndroidView turunan

Skenario ini mencakup implementasi API interop scroll bertingkat di sisi Compose - jika Anda memiliki composable induk yang berisi AndroidView turunan. AndroidView akan mengimplementasikan NestedScrollDispatcher, karena berfungsi sebagai turunan dari induk scroll Compose, serta NestedScrollingParent3 , karena berfungsi sebagai induk untuk turunan scrolling View. Induk Compose akan dapat menerima delta scroll bertingkat dari View turunan bertingkat yang dapat di-scroll.

Contoh berikut menunjukkan cara melakukan interop scroll bertingkat dalam skenario ini, beserta toolbar Compose yang diciutkan:

@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) {
            // ...
        }
    }
    // ...
}

Contoh ini menunjukkan cara menggunakan API dengan pengubah 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)
                    }
            }
        )
    }
}

Terakhir, contoh ini menunjukkan cara API interop scroll bertingkat digunakan dengan BottomSheetDialogFragment untuk mencapai perilaku tarik lalu tutup yang sukses:

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

Perhatikan bahwa rememberNestedScrollInteropConnection() akan menginstal NestedScrollConnection di elemen yang Anda pasangkan. NestedScrollConnection bertanggung jawab untuk mengirim delta dari level Compose ke level View. Hal ini memungkinkan elemen berpartisipasi dalam scroll bertingkat, tetapi tidak mengaktifkan scrolling elemen secara otomatis. Untuk composable yang tidak dapat di-scroll secara otomatis, seperti Box atau Column, delta scroll pada komponen tersebut tidak akan menyebar di sistem scroll bertingkat dan delta tidak akan menjangkau NestedScrollConnection yang disediakan oleh rememberNestedScrollInteropConnection(). Oleh karena itu, delta tersebut tidak akan menjangkau komponen View induk. Untuk mengatasinya, pastikan Anda juga menetapkan pengubah yang dapat di-scroll ke jenis composable bertingkat ini. Anda dapat melihat bagian sebelumnya tentang scrolling bertingkat untuk informasi yang lebih mendetail.

View induk yang tidak bekerja sama berisi ComposeView turunan

View yang tidak bekerja sama adalah View yang tidak mengimplementasikan antarmuka NestedScrolling yang diperlukan di sisi View. Perlu diperhatikan bahwa ini berarti interoperabilitas scroll bertingkat dengan Views ini tidak langsung berfungsi dari awal. Views yang tidak bekerja sama adalah RecyclerView dan ViewPager2.

Referensi lainnya