Composable tarik

Memungkinkan pengguna menarik komponen secara fisik di seluruh layar adalah hal mendasar untuk aplikasi Android modern. Baik Anda membuat penggeser kustom, komponen geser untuk menutup, maupun kanvas interaktif, Jetpack Compose menyediakan serangkaian API yang komprehensif untuk menangani peristiwa tarik dengan mudah.

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

Halaman ini menjelaskan cara dan waktu penggunaan setiap API penyeretan.

Memilih API penyeretan yang benar

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

Tarik di sepanjang satu sumbu dengan draggable

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

Penting untuk diperhatikan bahwa pengubah ini mirip dengan scrollable, karena pengubah hanya mendeteksi gestur. Anda perlu menyimpan status dan merepresentasikannya 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 menarik, pertimbangkan untuk menggunakan pendeteksi gestur tarik, 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 ditarik dengan jari

Tarik komponen di antara titik link dengan anchoredDraggable

Pengubah anchoredDraggable memungkinkan Anda menarik komponen di antara sekumpulan posisi yang telah ditetapkan, yang disebut titik link. Saat pengguna melepaskan gestur tarik, komponen akan berada di anchor terdekat. anchoredDraggable berguna untuk membuat komponen seperti panel bawah kustom, penggeser kompleks, atau panel panel samping.

Platform API

AnchoredDraggable API terdiri dari bagian utama berikut:

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

Contoh: Membangun komponen SwipeToDismiss kustom

Contoh ini menunjukkan cara membuat komponen SwipeToDismiss kustom (banner putih) yang dapat digeser ke kiri oleh pengguna untuk menutup, atau ditarik ke kanan untuk melihat efek peregangan overscroll.

  1. Tentukan nilai anchor. Tentukan enum yang mewakili posisi terpisah yang dapat ditempati oleh konten yang dapat ditarik:

    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. Agar komponen dapat ditarik dan memastikan titik angkurnya menyesuaikan dengan ukurannya, terapkan pengubah berikut ke konten yang akan dipindahkan 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, mengirimkan delta tarik dalam orientasi yang ditentukan, dan menangani gestur ayunkan jari untuk menganimasikan komponen ke anchor terdekat saat dilepaskan. Tambahkan pengubah ini ke komponen yang harus merespons gestur tarik dan teruskan state dan tarik orientation (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 penyeretan. Terapkan pengubah ini ke konten yang harus dipindahkan 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. overscrollEffect API menerapkan efek visual yang ditampilkan saat tepi penampung scroll telah dicapai dengan scroll atau fling. Anda dapat memberikan masukan visual saat pengguna menarik melewati batas anchor yang ditentukan dengan efek overscroll (biasanya berupa peregangan atau cahaya).

    1. Pengiriman: Teruskan instance overscrollEffect ke pengubah anchoredDraggable. Tindakan ini akan mengirimkan delta tarik 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 akan 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 "Tarik saya untuk menutup!", yang dapat digeser pengguna ke kiri untuk menutup (atau ditarik ke kanan untuk melihat efek peregangan overscroll):

Video 2. Implementasi komponen geser untuk menutup kustom.

Contoh: Membuat panel navigasi kustom berbasis gestur

Contoh ini menunjukkan cara membuat panel navigasi kustom yang digerakkan oleh gestur. Saat pengguna menarik secara horizontal, layar latar depan (ScreenContents) bergeser ke kanan, memperkecil skala dari 1.0f menjadi 0.8f, dan membulatkan sudutnya untuk menampilkan panel navigasi di bawahnya.

  1. Tentukan status dan posisi anchor. Dalam contoh ini, angkarnya adalah status panel samping yang terbuka dan tertutup. 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. Menganimasikan anchoredDraggableState saat item diklik. Tempatkan HomeScreenDrawer di bagian bawah Box dan ScreenContents di atas. Panel samping tetap diam, sementara lapisan atas diterjemahkan dan diskalakan di atasnya.

    Saat pengguna memilih item panel samping, perbarui status layar aktif dan tutup panel samping secara otomatis 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. Daripada hanya mengimbangi konten, Anda dapat mengubah beberapa properti dengan Modifier.graphicsLayer untuk menganimasikan terjemahan, skala, dan radius sudut secara bersamaan berdasarkan progres tarik:

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

    @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 samping secara terprogram (seperti saat mengetuk ikon menu atau memilih item panel samping) 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 (Sleep) ditarik ke kanan, menampilkan panel navigasi di bawahnya.

Tarik dalam dua dimensi dengan Modifier.draggable2D

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

Menarik elemen composable

Contoh ini menunjukkan kasus penggunaan paling umum untuk draggable2D — memungkinkan pengguna mengambil elemen UI dan memosisikannya 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 diposisikan ulang pada latar belakang abu-abu, yang menunjukkan penyeretan 2D langsung, dengan elemen berhenti bergerak begitu jari pengguna diangkat.

Cuplikan kode sebelumnya mencakup hal berikut:

  • Melacak posisi kotak menggunakan status offset.
  • Menggunakan pengubah offset untuk menggeser posisi komponen berdasarkan delta tarik.
  • Karena tidak ada dukungan fling, kotak akan berhenti bergerak begitu pengguna mengangkat jarinya.

Menarik composable turunan berdasarkan area tarik induk

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

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

Cuplikan sebelumnya mencakup hal berikut:

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