Composable tarik

Memungkinkan pengguna menyeret komponen secara fisik di seluruh layar sangat penting untuk aplikasi Android modern. Baik Anda membuat penggeser kustom, komponen geser untuk menutup, atau kanvas interaktif, Jetpack Compose menyediakan serangkaian API komprehensif untuk menangani peristiwa seret dengan mudah.

API seret Compose mengabstraksi kompleksitas peristiwa sentuh mentah. Daripada menghitung koordinat sentuh secara manual dan mengelola siklus proses gestur dengan pointerInput, Anda dapat menggunakan pengubah bawaan yang terikat langsung ke sistem pengelolaan status dan animasi Compose.

Halaman ini menjelaskan cara dan waktu penggunaan setiap API seret.

Memilih API seret yang benar

Ada beberapa API yang dapat Anda gunakan untuk menerapkan konten yang dapat diseret.

  • Pilih komponen Material standar (seperti SwipeToDismissBox atau panel navigasi) jika: Anda memerlukan perilaku geser atau seret Material 3 default (misalnya, menghapus email, mengarsipkan tugas, atau membuka menu samping / sheet bawah). Komponen ini menyediakan perilaku bawaan tanpa memerlukan pengelolaan status atau tata letak kustom.
  • Pilih Modifier.draggable jika: Anda hanya memerlukan delta seret mentah (misalnya, menyeret penggeser kustom). Untuk mengetahui informasi selengkapnya, lihat Menyeret sepanjang satu sumbu dengan draggable.
  • Pilih Modifier.anchoredDraggable jika: Anda membuat komponen interaktif kustom yang perlu di-snap di antara dua atau beberapa posisi terpisah (misalnya, sheet bawah, panel navigasi, atau tindakan geser untuk menampilkan). Untuk mengetahui informasi selengkapnya, lihat Menyeret komponen di antara titik anchor dengan anchoredDraggable.

Menyeret sepanjang satu sumbu dengan draggable

Pengubah draggable adalah titik entri tingkat tinggi untuk gestur seret dalam satu orientasi, dan melaporkan jarak seret dalam piksel.

Penting untuk diperhatikan bahwa pengubah ini mirip dengan scrollable, karena pengubah hanya mendeteksi gestur. Anda harus menyimpan status dan menampilkannya di layar misalnya, dengan memindahkan elemen melalui pengubah offset:

@Composable
private fun DraggableText() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            ),
        text = "Drag me!"
    )
}

Jika Anda perlu mengontrol seluruh gestur seret, pertimbangkan untuk menggunakan detektor gestur seret, melalui pengubah pointerInput.

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableFloatStateOf(0f) }
        var offsetY by remember { mutableFloatStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
        )
    }
}

MASUKKAN TEKS ALTERNATIF DI SINI
Gambar 1. Elemen UI yang diseret dengan jari

Menyeret komponen di antara titik anchor dengan anchoredDraggable

Pengubah anchoredDraggable memungkinkan Anda menyeret komponen di antara sekumpulan posisi yang telah ditentukan sebelumnya yang disebut titik anchor. Saat pengguna melepaskan gestur seret, komponen akan berada di anchor terdekat. anchoredDraggable berguna untuk membuat komponen seperti sheet bawah kustom, penggeser kompleks, atau panel laci.

Platform API

API AnchoredDraggable terdiri dari bagian utama berikut:

  • Anchor (DraggableAnchors): Builder yang menentukan pemetaan antara status kustom Anda (misalnya, class enum untuk Start, Center, End) dan nilai offset piksel yang sesuai.
  • Status (AnchoredDraggableState<T>): Penampung status yang mengelola posisi (offset) komponen saat ini, anchor tempat komponen berada, dan progres seretnya. Status ini harus dibuat menggunakan rememberSaveable dan AnchoredDraggableState.Saver<T> untuk mempertahankan statusnya di seluruh perubahan konfigurasi.
  • Pengubah gestur (Modifier.anchoredDraggable): Pengubah yang mendeteksi gestur seret di sepanjang Orientation, memperbarui status dengan delta seret, dan menangani gestur lempar untuk menganimasikan komponen ke anchor terdekat saat dilepaskan.

Contoh: Mem-build komponen SwipeToDismiss kustom

Contoh ini menunjukkan cara mem-build komponen SwipeToDismiss kustom (banner putih) yang dapat digeser ke kiri untuk menutup, atau diseret ke kanan untuk melihat efek peregangan overscroll.

  1. Tentukan nilai anchor. Tentukan enum yang mewakili posisi terpisah yang dapat digunakan untuk menempatkan konten yang dapat diseret:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. Buat instance dan simpan AnchoredDraggableState. Gunakan rememberSaveable dengan AnchoredDraggableState.Saver() untuk mempertahankan posisi komponen saat ini di seluruh pembuatan ulang aktivitas dan perubahan konfigurasi (misalnya, rotasi layar atau perubahan tema):

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. Terapkan pengubah layout, offset, dan draggable. Untuk membuat komponen dapat diseret dan memastikan titik anchor-nya beradaptasi dengan ukurannya, terapkan pengubah berikut ke konten yang harus bergerak secara visual:

    • Modifier.layout: Posisi anchor sering kali bergantung pada ukuran komponen, yang tidak diketahui selama komposisi awal. Gunakan pengubah layout untuk mengukur komponen, menghitung peta DraggableAnchors secara dinamis berdasarkan lebarnya, dan memperbarui status menggunakan state.updateAnchors().

    • Modifier.anchoredDraggable: Terhubung ke status, mengirim delta seret dalam orientasi yang ditentukan, dan menangani gestur lempar untuk menganimasikan komponen ke anchor terdekat saat dilepaskan. Tambahkan pengubah ini ke komponen yang harus merespons gestur seret dan teruskan state dan orientation seret (dan, secara opsional, overscrollEffect).
    • Modifier.offset: Membaca offset dari state.requireOffset() di dalam lambda offset. Membaca offset dalam lambda ini memastikan nilai hanya dibaca selama fase penempatan, sehingga menghindari rekomposisi yang tidak perlu selama seret. Terapkan pengubah ini ke konten yang harus bergerak secara visual.

    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }

  4. [Opsional] Mengintegrasikan overscroll. API overscrollEffect menerapkan efek visual yang ditampilkan saat tepi penampung scroll telah tercapai dengan scroll atau lempar. Anda dapat memberikan masukan visual saat pengguna menyeret melewati batas anchor yang ditentukan dengan efek overscroll (biasanya peregangan atau cahaya).

    1. Kirim: Teruskan instance overscrollEffect ke pengubah anchoredDraggable. Tindakan ini mengirim delta seret ke status AnchoredDraggable atau efek overscroll, bergantung pada apakah gestur berada di dalam atau di luar batas anchor.
    2. Render: Terapkan Modifier.overscroll dengan instance OverscrollEffect yang sama ke komponen yang harus merender efek secara visual.

Cuplikan berikut menunjukkan cara menambahkan efek peregangan standar:

val overscrollEffect = rememberOverscrollEffect()
Box(
    modifier.fillMaxWidth()
) {
    Box(Modifier.fillMaxWidth()) {
        background()
    }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }
}

Hasil

Cuplikan kode sebelumnya menghasilkan banner putih, dengan teks "Drag me to dismiss!", yang dapat digeser ke kiri untuk menutup (atau diseret ke kanan untuk melihat efek peregangan overscroll):

Video 2. Implementasi komponen geser untuk menutup kustom.

Contoh: Membuat panel navigasi kustom yang digerakkan oleh gestur

Contoh ini menunjukkan cara mem-build panel navigasi kustom yang digerakkan oleh gestur. Saat pengguna menyeret secara horizontal, layar latar depan (ScreenContents) akan bergeser ke kanan, diperkecil dari 1.0f menjadi 0.8f, dan sudutnya dibulatkan untuk menampilkan panel navigasi yang mendasarinya.

  1. Tentukan status dan posisi anchor. Dalam contoh ini, anchor adalah status panel yang dibuka dan ditutup. Tentukan enum untuk status Open dan Closed, lalu petakan ke offset piksel menggunakan DraggableAnchors:

    enum class DrawerState {
        Open,
        Closed
    }
    
        val anchors = DraggableAnchors {
            DrawerState.Open at drawerWidth
            DrawerState.Closed at 0f
        }
        val state = remember {
            AnchoredDraggableState(
                initialValue = DrawerState.Closed,
                anchors = anchors,
            )
        }

  2. Animasikan anchoredDraggableState saat item diklik. Tempatkan HomeScreenDrawer di bagian bawah Box dan ScreenContents di bagian atas. Panel tetap diam, sementara lapisan atas diterjemahkan dan diskalakan di atasnya.

    Saat pengguna memilih item panel, perbarui status layar aktif dan otomatis tutup panel menggunakan state.animateTo(DrawerState.Closed):

    @Composable
    private fun AnchoredDraggableDrawerOnClickSample(
        modifier: Modifier = Modifier,
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        var screenState by remember { mutableStateOf(ScreenState.Home) }
    
        Box(modifier = modifier.fillMaxSize()) {
            HomeScreenDrawer(
                selectedScreen = screenState,
                onScreenSelected = { screen ->
                    screenState = screen
                    // Dismiss drawer when a destination is selected
                    coroutineScope.launch { state.animateTo(DrawerState.Closed) }
                }
            )
    
            // ScreenContents(...)
        }
    }

  3. Gunakan state.requireOffset() untuk mengubah beberapa properti visual. Selain hanya mengimbangi konten, Anda dapat mengubah beberapa properti dengan Modifier.graphicsLayer untuk menganimasikan terjemahan, skala, dan radius sudut secara bersamaan berdasarkan progres seret:

    • Gunakan state.requireOffset() untuk mendapatkan offset seret saat ini.
    • Gunakan lerp() (interpolasi linear) untuk menghitung skala proporsional (1f hingga 0.8f) dan radius sudut (0.dp hingga 32.dp) relatif terhadap offset dan lebar panel.
    • Modifier.graphicsLayer menjalankan perubahan dalam fase gambar, sehingga memastikan animasi berperforma tinggi tanpa memicu rekomposisi selama seret.

    @Composable
    private fun AnchoredDraggableDrawerGraphicsLayerSample(
        state: AnchoredDraggableState<DrawerState>,
        drawerWidth: Float,
        selectedScreen: ScreenState,
        onDrawerClicked: () -> Unit
    ) {
        ScreenContents(
            selectedScreen = selectedScreen,
            onDrawerClicked = onDrawerClicked,
            modifier = Modifier
                .graphicsLayer {
                    this.translationX = state.requireOffset()
                    val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth)
                    this.scaleX = scale
                    this.scaleY = scale
                    val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth)
                    this.shape = RoundedCornerShape(cornerRadius.dp)
                    this.clip = true
                }
                .anchoredDraggable(state, Orientation.Horizontal)
        )
    }

  4. Memicu animasi terprogram dengan animateTo. Kontrol panel secara terprogram (seperti saat mengetuk ikon menu atau memilih item panel) dengan meluncurkan state.animateTo() dalam cakupan coroutine:

    fun toggleDrawerState(
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        coroutineScope.launch {
            if (state.currentValue == DrawerState.Open) {
                state.animateTo(DrawerState.Closed)
            } else {
                state.animateTo(DrawerState.Open)
            }
        }
    }

Hasil

Video 3. Layar latar depan (Tidur) diseret ke kanan, menampilkan panel navigasi yang mendasarinya.

Menyeret dalam dua dimensi dengan Modifier.draggable2D

Gunakan pengubah draggable2D untuk memindahkan elemen UI individual di seluruh sumbu X dan Y secara bersamaan.

Menyeret elemen composable

Contoh ini menunjukkan kasus penggunaan paling umum untuk draggable2D — memungkinkan pengguna mengambil elemen UI dan memposisikannya ulang 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)
        }
    }
}

Video 4. Kotak ungu kecil yang diposisikan ulang dengan latar belakang abu-abu, menunjukkan seret 2D langsung saat elemen berhenti bergerak saat jari pengguna diangkat.

Cuplikan kode sebelumnya mencakup hal berikut:

  • Melacak posisi kotak menggunakan status offset.
  • Menggunakan pengubah offset untuk menggeser posisi komponen berdasarkan delta seret.
  • Karena tidak ada dukungan lempar, kotak berhenti bergerak saat pengguna mengangkat jari.

Menyeret composable turunan berdasarkan area seret induk

Contoh ini menunjukkan cara menggunakan draggable2D untuk membuat area input 2D tempat tombol pemilih dibatasi dalam permukaan tertentu. Tidak seperti contoh elemen yang dapat diseret, yang memindahkan komponen itu sendiri, implementasi ini menggunakan delta 2D untuk memindahkan composable turunan "pemilih" 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)
                    }
                )
        )
    }
}

Video 5. Gradien warna dengan tombol pemilih melingkar putih yang dapat diseret ke segala arah, menunjukkan cara delta 2D dijepit ke batas penampung untuk memperbarui nilai warna yang dipilih.

Cuplikan sebelumnya mencakup hal berikut:

  • Cuplikan ini menggunakan pengubah onSizeChanged untuk mengambil dimensi sebenarnya dari penampung gradien. Pemilih mengetahui dengan tepat lokasi tepi.
  • Di dalam graphicsLayer, cuplikan ini menyesuaikan translationX dan translationY untuk memastikan pemilih tetap berada di tengah saat diseret.