Scrolling dua dimensi: scrollable2D, draggable2D

Di Jetpack Compose, scrollable2D dan draggable2D adalah pengubah tingkat rendah yang dirancang untuk menangani input penunjuk dalam dua dimensi. Meskipun pengubah 1D standar scrollable dan draggable dibatasi pada satu orientasi, varian 2D melacak gerakan di seluruh sumbu X dan Y secara bersamaan.

Misalnya, pengubah scrollable yang ada digunakan untuk men-scroll dan menggeser satu orientasi, sedangkan scrollable2d digunakan untuk men-scroll dan menggeser dalam 2D. Hal ini memungkinkan Anda membuat tata letak yang lebih kompleks yang bergerak ke semua arah, seperti spreadsheet atau penampil gambar. Pengubah scrollable2d juga mendukung scrolling bertingkat dalam skenario 2D.

Gambar 1. Penggeseran dua arah di peta.

Pilih scrollable2D atau draggable2D

Memilih API yang tepat bergantung pada elemen UI yang ingin Anda pindahkan dan perilaku fisik yang diinginkan untuk elemen ini.

Modifier.scrollable2D: Gunakan pengubah ini pada penampung untuk memindahkan konten di dalamnya. Misalnya, gunakan dengan peta, spreadsheet, atau penampil foto, yang konten penampungnya perlu di-scroll dalam arah horizontal dan vertikal. ScrollView mencakup dukungan lemparan bawaan sehingga konten terus bergerak setelah gesekan, dan berkoordinasi dengan komponen scrolling lainnya di halaman.

Modifier.draggable2D: Gunakan pengubah ini untuk memindahkan komponen itu sendiri. Ini adalah pengubah ringan, sehingga gerakan berhenti tepat saat jari pengguna berhenti. Tidak menyertakan dukungan pelepasan.

Jika Anda ingin membuat komponen dapat ditarik, tetapi tidak memerlukan dukungan penggeseran atau scroll bertingkat, gunakan draggable2D.

Menerapkan pengubah 2D

Bagian berikut memberikan contoh untuk menunjukkan cara menggunakan pengubah 2D.

Mengimplementasikan Modifier.scrollable2D

Gunakan pengubah ini untuk penampung tempat pengguna perlu memindahkan konten ke semua arah.

Merekam data gerakan 2D

Contoh ini menunjukkan cara merekam data gerakan 2D mentah dan menampilkan offset X,Y:

@Composable
private fun Scrollable2DSample() {
    // 1. Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            // ...
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 2. Attach the 2D scroll logic to capture XY movement deltas
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        // 3. Update the cumulative offset state with the new movement delta
                        offset += delta

                        // Return the delta to indicate the entire movement was handled by this box
                        delta
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // 4. Display the current X and Y values from the offset state in real-time
                Text(
                    text = "X: ${offset.x.roundToInt()}",
                    // ...
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    // ...
                )
            }
        }
    }
}

Gambar 2. Kotak ungu yang melacak dan menampilkan offset koordinat X dan Y saat ini saat pengguna menarik pointer di permukaannya.

Cuplikan sebelumnya melakukan hal berikut:

  • Menggunakan offset sebagai status yang menyimpan total jarak yang telah di-scroll pengguna.
  • Di dalam rememberScrollable2DState, fungsi lambda ditentukan untuk menangani setiap delta, yang dihasilkan oleh jari pengguna. Kode offset.value += delta memperbarui status manual dengan posisi baru.
  • Komponen Text menampilkan nilai X dan Y saat ini dari status offset tersebut, yang diperbarui secara real-time saat pengguna menarik.

Menggeser area pandang yang besar

Contoh ini menunjukkan cara menggunakan data yang dapat di-scroll 2D yang direkam dan menerapkan translationX dan translationY ke konten yang lebih besar daripada penampung induknya:

@Composable
private fun Panning2DImage() {

    // Manually track the total distance the user has moved in both X and Y directions
    val offset = remember { mutableStateOf(Offset.Zero) }

    // Define how gestures are captured. The lambda is called for every finger movement
    val scrollState = rememberScrollable2DState { delta ->
        offset.value += delta
        delta
    }

    // The Viewport (Container): A fixed-size box that acts as a window into the larger content
    Box(
        modifier = Modifier
            .size(600.dp, 400.dp) // The visible area dimensions
            // ...
            // Hide any parts of the large content that sit outside this container's boundaries
            .clipToBounds()
            // Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
            .scrollable2D(state = scrollState),
        contentAlignment = Alignment.Center,
    ) {
        // The Content: An image given a much larger size than the container viewport
        Image(
            painter = painterResource(R.drawable.cheese_5),
            contentDescription = null,
            modifier = Modifier
                .requiredSize(1200.dp, 800.dp)
                // Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
                // we use graphicsLayer to shift the drawing position based on the tracked offset.
                .graphicsLayer {
                    translationX = offset.value.x
                    translationY = offset.value.y
                },
            contentScale = ContentScale.FillBounds
        )
    }
}

Gambar 3. Area pandang gambar panning dua arah, dibuat dengan Modifier.scrollable2D.
Gambar 4. Viewport teks dua arah yang dapat di-panning, dibuat dengan Modifier.scrollable2D.

Cuplikan sebelumnya mencakup hal berikut:

  • Penampung ditetapkan ke ukuran tetap (600x400dp), sementara konten diberi ukuran yang jauh lebih besar (1200x800dp) untuk menghindari perubahan ukuran ke ukuran induknya.
  • Pengubah clipToBounds() pada penampung memastikan bahwa bagian konten besar yang berada di luar kotak 600x400 disembunyikan dari tampilan.
  • Tidak seperti komponen tingkat tinggi seperti LazyColumn, scrollable2D tidak memindahkan konten untuk Anda secara otomatis. Sebagai gantinya, Anda harus menerapkan offset yang dilacak ke konten, baik menggunakan transformasi graphicsLayer atau offset tata letak.
  • Di dalam blok graphicsLayer, translationX = offset.value.x dan translationY = offset.value.y menggeser posisi gambar atau teks berdasarkan gerakan jari Anda, sehingga menciptakan efek visual scrolling.

Menerapkan scrolling bertingkat dengan scrollable2D

Contoh ini menunjukkan cara mengintegrasikan komponen dua arah ke dalam induk satu dimensi standar, seperti feed berita vertikal.

Perhatikan poin-poin berikut saat menerapkan scrolling bertingkat:

  • Lambda untuk rememberScrollable2DState hanya akan menampilkan delta yang digunakan , sehingga daftar induk dapat mengambil alih secara alami saat turunan mencapai batasnya.
  • Saat pengguna melakukan gerakan mengayun diagonal, kecepatan 2D akan dibagikan. Jika turunan mencapai batas selama animasi, momentum yang tersisa akan diteruskan ke induk untuk melanjutkan scroll secara alami.

@Composable
private fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val maxScrollDp = 250.dp
    val maxScrollPx = with(LocalDensity.current) { maxScrollDp.toPx() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(Color(0xFFF5F5F5)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            "Scroll down to find the 2D Box",
            modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
            style = TextStyle(fontSize = 18.sp, color = Color.Gray)
        )

        // The Child: A 2D scrollable box with nested scroll coordination
        Box(
            modifier = Modifier
                .size(250.dp)
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        val oldOffset = offset

                        // Calculate new potential offset and clamp it to our boundaries
                        val newX = (oldOffset.x + delta.x).coerceIn(-maxScrollPx, maxScrollPx)
                        val newY = (oldOffset.y + delta.y).coerceIn(-maxScrollPx, maxScrollPx)

                        val newOffset = Offset(newX, newY)

                        // Calculate exactly how much was consumed by the child
                        val consumed = newOffset - oldOffset

                        offset = newOffset

                        // IMPORTANT: Return ONLY the consumed delta.
                        // The remaining (unconsumed) delta propagates to the parent Column.
                        consumed
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                val density = LocalDensity.current
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${with(density) { offset.x.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${with(density) { offset.y.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: 250 or -250,\nthis parent list will take over the vertical scroll.",
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
            style = TextStyle(fontSize = 14.sp, color = Color.Gray)
        )
    }
}

Gambar 5. Kotak ungu dalam daftar scroll vertikal yang memungkinkan gerakan 2D internal, tetapi meneruskan kontrol scroll vertikal ke daftar induk setelah offset Y internal kotak mencapai batas 300 piksel.

Dalam cuplikan sebelumnya:

  • Komponen 2D dapat menggunakan gerakan sumbu X untuk menggeser secara internal sekaligus mengirimkan gerakan sumbu Y ke daftar induk setelah batas vertikal turunan tercapai.
  • Daripada menjebak pengguna dalam permukaan 2D, sistem menghitung delta yang digunakan dan meneruskan sisanya ke hierarki. Hal ini memastikan pengguna dapat terus men-scroll halaman lainnya tanpa mengangkat jari.

Mengimplementasikan Modifier.draggable2D

Gunakan pengubah draggable2D untuk memindahkan setiap elemen UI.

Menarik elemen composable

Contoh ini menunjukkan kasus penggunaan paling umum untuk draggable2D — memungkinkan pengguna mengambil elemen UI dan memosisikannya kembali di mana saja dalam penampung induk.

@Composable
private fun DraggableComposableElement() {
    // 1. Track the position of the floating window
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Box(
            modifier = Modifier
                // 2. Apply the offset to the box's position
                .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
                // ...
                // 3. Attach the 2D drag logic
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Update the position based on the movement delta
                        offset += delta
                    }
                ),
            contentAlignment = Alignment.Center
        ) {
            Text("Video Preview", color = Color.White, fontSize = 12.sp)
        }
    }
}

Gambar 6. Kotak ungu kecil yang diposisikan ulang di latar belakang abu-abu, menunjukkan penarikan 2D langsung di mana elemen berhenti bergerak saat jari pengguna diangkat.

Cuplikan kode sebelumnya mencakup hal berikut:

  • Melacak posisi kotak menggunakan status offset.
  • Menggunakan pengubah offset untuk mengubah posisi komponen berdasarkan delta penarikan.
  • Karena tidak ada dukungan gerakan cepat, kotak berhenti bergerak saat pengguna mengangkat jarinya.

Menarik komponen turunan berdasarkan area penarikan induk

Contoh ini menunjukkan cara menggunakan draggable2D untuk membuat area input 2D dengan kenop pemilih yang dibatasi dalam permukaan tertentu. Tidak seperti contoh elemen yang dapat ditarik, yang memindahkan komponen itu sendiri, penerapan ini menggunakan delta 2D untuk memindahkan "pemilih" composable turunan di seluruh pemilih warna:

@Composable
private fun ExampleColorSelector(
    // ...
)  {
    // 1. Maintain the 2D position of the selector in state.
    var selectorOffset by remember { mutableStateOf(Offset.Zero) }

    // 2. Track the size of the background container.
    var containerSize by remember { mutableStateOf(IntSize.Zero) }

    Box(
        modifier = Modifier
            .size(300.dp, 200.dp)
            // Capture the actual pixel dimensions of the container when it's laid out.
            .onSizeChanged { containerSize = it }
            .clip(RoundedCornerShape(12.dp))
            .background(
                brush = remember(hue) {
                    // Create a simple gradient representing Saturation and Value for the given Hue.
                    Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))
                }
            )
    ) {
        Box(
            modifier = Modifier
                .size(24.dp)
                .graphicsLayer {
                    // Center the selector on the finger by subtracting half its size.
                    translationX = selectorOffset.x - (24.dp.toPx() / 2)
                    translationY = selectorOffset.y - (24.dp.toPx() / 2)
                }
                // ...
                // 3. Configure 2D touch dragging.
                .draggable2D(
                    state = rememberDraggable2DState { delta ->
                        // 4. Calculate the new position and clamp it to the container bounds
                        val newX = (selectorOffset.x + delta.x)
                            .coerceIn(0f, containerSize.width.toFloat())
                        val newY = (selectorOffset.y + delta.y)
                            .coerceIn(0f, containerSize.height.toFloat())

                        selectorOffset = Offset(newX, newY)
                    }
                )
        )
    }
}

Gambar 7. Gradien warna dengan kenop pemilih melingkar putih yang dapat ditarik ke segala arah, yang menunjukkan cara delta 2D di-clamping ke batas penampung untuk memperbarui nilai warna yang dipilih.

Cuplikan sebelumnya mencakup hal berikut:

  • Komponen ini menggunakan pengubah onSizeChanged untuk mengambil dimensi sebenarnya dari penampung gradien. Pemilih tahu persis di mana tepinya.
  • Di dalam graphicsLayer, translationX dan translationY disesuaikan untuk memastikan pemilih tetap berada di tengah saat ditarik.