Memahami gestur

Ada beberapa istilah dan konsep yang penting untuk dipahami saat menangani gestur dalam aplikasi. Halaman ini menjelaskan istilah pointer, peristiwa pointer, dan gestur, serta memperkenalkan berbagai tingkat abstraksi untuk gestur. Kursus ini juga membahas lebih dalam tentang konsumsi dan penyebaran peristiwa.

Definisi

Untuk memahami berbagai konsep di halaman ini, Anda perlu memahami beberapa terminologi yang digunakan:

  • Penunjuk: Objek fisik yang dapat Anda gunakan untuk berinteraksi dengan aplikasi. Untuk perangkat seluler, pointer yang paling umum adalah jari Anda yang berinteraksi dengan layar sentuh. Atau, Anda dapat menggunakan stilus untuk menggantikan jari Anda. Untuk layar besar, Anda dapat menggunakan mouse atau trackpad untuk berinteraksi secara tidak langsung dengan layar. Perangkat input harus dapat "menunjuk" ke koordinat agar dianggap sebagai pointer, sehingga keyboard, misalnya, tidak dapat dianggap sebagai pointer. Di Compose, jenis pointer disertakan dalam perubahan pointer menggunakan PointerType.
  • Peristiwa pointer: Menjelaskan interaksi level rendah dari satu atau beberapa pointer dengan aplikasi pada waktu tertentu. Setiap interaksi pointer, seperti meletakkan jari di layar atau menarik mouse, akan memicu peristiwa. Di Compose, semua informasi yang relevan untuk peristiwa tersebut terdapat dalam class PointerEvent.
  • Gestur: Urutan peristiwa pointer yang dapat ditafsirkan sebagai satu tindakan. Misalnya, gestur ketuk dapat dianggap sebagai urutan peristiwa tekan yang diikuti dengan peristiwa lepas. Ada gestur umum yang digunakan oleh banyak aplikasi, seperti ketuk, tarik, atau transformasi, tetapi Anda juga dapat membuat gestur kustom sendiri jika diperlukan.

Berbagai tingkat abstraksi

Jetpack Compose menyediakan berbagai tingkat abstraksi untuk menangani gestur. Di tingkat teratas adalah dukungan komponen. Composable seperti Button otomatis menyertakan dukungan gestur. Untuk menambahkan dukungan gestur ke komponen kustom, Anda dapat menambahkan pengubah gestur seperti clickable ke composable arbitrer. Terakhir, jika memerlukan gestur kustom, Anda dapat menggunakan pengubah pointerInput.

Sebagai aturan, bangun pada tingkat abstraksi tertinggi yang menawarkan fungsionalitas yang Anda butuhkan. Dengan cara ini, Anda akan mendapatkan manfaat dari praktik terbaik yang disertakan dalam lapisan. Misalnya, Button berisi lebih banyak informasi semantik, yang digunakan untuk aksesibilitas, daripada clickable, yang berisi lebih banyak informasi daripada implementasi pointerInput mentah.

Dukungan komponen

Banyak komponen siap pakai di Compose menyertakan semacam penanganan gestur internal. Misalnya, LazyColumn merespons gestur tarik dengan men-scroll kontennya, Button menampilkan ripple saat Anda menekannya, dan komponen SwipeToDismiss berisi logika geser untuk menutup elemen. Jenis penanganan gestur ini berfungsi secara otomatis.

Selain penanganan gestur internal, banyak komponen juga mengharuskan pemanggil untuk menangani gestur. Misalnya, Button otomatis mendeteksi ketukan dan memicu peristiwa klik. Anda meneruskan lambda onClick ke Button untuk bereaksi terhadap gestur. Demikian pula, Anda menambahkan lambda onValueChange ke Slider untuk bereaksi terhadap pengguna yang menarik tuas penggeser.

Jika sesuai dengan kasus penggunaan Anda, pilih gestur yang disertakan dalam komponen, karena gestur tersebut menyertakan dukungan siap pakai untuk fokus dan aksesibilitas, serta telah diuji dengan baik. Misalnya, Button ditandai dengan cara khusus sehingga layanan aksesibilitas mendeskripsikannya dengan benar sebagai tombol, bukan hanya elemen yang dapat diklik:

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

Untuk mempelajari lebih lanjut aksesibilitas di Compose, lihat Aksesibilitas di Compose.

Menambahkan gestur tertentu ke composable arbitrer dengan pengubah

Anda dapat menerapkan pengubah gestur ke composable arbitrer mana pun agar composable memproses gestur. Misalnya, Anda dapat mengizinkan Box generik menangani gestur ketuk dengan menjadikannya clickable, atau mengizinkan Column menangani scroll vertikal dengan menerapkan verticalScroll.

Ada banyak pengubah untuk menangani berbagai jenis gestur:

Sebagai aturan, pilih pengubah gestur siap pakai daripada penanganan gestur kustom. Pengubah menambahkan lebih banyak fungsi selain penanganan peristiwa pointer murni. Misalnya, pengubah clickable tidak hanya menambahkan deteksi penekanan dan ketukan, tetapi juga menambahkan informasi semantik, indikasi visual pada interaksi, pengarahan kursor, fokus, dan dukungan keyboard. Anda dapat memeriksa kode sumber clickable untuk melihat cara fungsi ditambahkan.

Menambahkan gestur kustom ke composable arbitrer dengan pengubah pointerInput

Tidak semua gestur diimplementasikan dengan pengubah gestur siap pakai. Misalnya, Anda tidak dapat menggunakan pengubah untuk bereaksi terhadap tarik setelah tekan lama, klik kontrol, atau ketukan tiga jari. Sebagai gantinya, Anda dapat menulis pengendali gestur sendiri untuk mengidentifikasi gestur kustom ini. Anda dapat membuat pengendali gestur dengan pengubah pointerInput, yang memberi Anda akses ke peristiwa pointer mentah.

Kode berikut memproses peristiwa pointer mentah:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

Jika Anda menguraikan cuplikan ini, komponen intinya adalah:

  • Pengubah pointerInput. Anda meneruskan satu atau beberapa kunci ke fungsi tersebut. Saat nilai salah satu kunci tersebut berubah, lambda konten pengubah akan dieksekusi ulang. Contoh ini meneruskan filter opsional ke composable. Jika nilai filter tersebut berubah, pengendali peristiwa pointer harus dijalankan ulang untuk memastikan peristiwa yang tepat dicatat ke dalam log.
  • awaitPointerEventScope membuat cakupan coroutine yang dapat digunakan untuk menunggu peristiwa pointer.
  • awaitPointerEvent menangguhkan coroutine hingga peristiwa pointer berikutnya terjadi.

Meskipun memproses peristiwa input mentah sangat efektif, menulis gestur kustom berdasarkan data mentah ini juga rumit. Untuk menyederhanakan pembuatan gestur kustom, tersedia banyak metode utilitas.

Mendeteksi gestur penuh

Alih-alih menangani peristiwa pointer mentah, Anda dapat memproses gestur tertentu yang terjadi dan meresponsnya dengan tepat. AwaitPointerEventScope menyediakan metode untuk memproses:

Ini adalah pendeteksi tingkat teratas, jadi Anda tidak dapat menambahkan beberapa pendeteksi dalam satu pengubah pointerInput. Cuplikan berikut hanya mendeteksi ketukan, bukan tarikan:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Secara internal, metode detectTapGestures memblokir coroutine, dan detektor kedua tidak pernah tercapai. Jika Anda perlu menambahkan lebih dari satu pemroses gestur ke composable, gunakan instance pengubah pointerInput terpisah:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Menangani peristiwa per gestur

Menurut definisinya, gestur dimulai dengan peristiwa pointer ke bawah. Anda dapat menggunakan metode bantuan awaitEachGesture, bukan loop while(true) yang melewati setiap peristiwa mentah. Metode awaitEachGesture memulai ulang blok yang memuatnya saat semua pointer telah diangkat, yang menunjukkan bahwa gestur telah selesai:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

Dalam praktiknya, Anda hampir selalu ingin menggunakan awaitEachGesture kecuali jika Anda merespons peristiwa pointer tanpa mengidentifikasi gestur. Contohnya adalah hoverable, yang tidak merespons peristiwa pointer ke bawah atau ke atas— hanya perlu mengetahui kapan pointer masuk atau keluar dari batasnya.

Menunggu peristiwa atau sub-gestur tertentu

Ada serangkaian metode yang membantu mengidentifikasi bagian umum gestur:

Menerapkan penghitungan untuk peristiwa multi-sentuh

Saat pengguna melakukan gestur multi-kontrol menggunakan lebih dari satu pointer, akan sulit untuk memahami transformasi yang diperlukan berdasarkan nilai mentah. Jika pengubah transformable atau metode detectTransformGestures tidak memberikan kontrol yang cukup mendetail untuk kasus penggunaan Anda, Anda dapat memproses peristiwa mentah dan menerapkan penghitungan pada peristiwa tersebut. Metode bantuan ini adalah calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation, dan calculateZoom.

Pengiriman peristiwa dan hit-testing

Tidak semua peristiwa pointer dikirim ke setiap pengubah pointerInput. Pengiriman peristiwa berfungsi sebagai berikut:

  • Peristiwa pointer dikirim ke hierarki composable. Saat pointer baru memicu peristiwa pointer pertamanya, sistem akan mulai melakukan hit-test pada composable yang "memenuhi syarat". Composable dianggap memenuhi syarat jika memiliki kemampuan penanganan input pointer. Hit-testing mengalir dari bagian atas hierarki UI ke bagian bawah. Composable "terkena" saat peristiwa pointer terjadi dalam batas composable tersebut. Proses ini menghasilkan rantai composable yang lulus hit-test.
  • Secara default, jika ada beberapa composable yang memenuhi syarat pada tingkat hierarki yang sama, hanya composable dengan indeks z tertinggi yang akan "terkena". Misalnya, saat Anda menambahkan dua composable Button yang tumpang-tindih ke Box, hanya composable yang digambar di atas yang menerima peristiwa pointer. Secara teoritis, Anda dapat mengganti perilaku ini dengan membuat implementasi PointerInputModifierNode Anda sendiri dan menetapkan sharePointerInputWithSiblings ke true.
  • Peristiwa lebih lanjut untuk pointer yang sama dikirim ke rantai composable yang sama, dan mengalir sesuai dengan logika penyebaran peristiwa. Sistem tidak melakukan hit-testing lagi untuk pointer ini. Artinya, setiap composable dalam rantai menerima semua peristiwa untuk pointer tersebut, meskipun peristiwa tersebut terjadi di luar batas composable tersebut. Composable yang tidak ada dalam rantai tidak akan pernah menerima peristiwa pointer, bahkan saat pointer berada di dalam batasnya.

Peristiwa pengarahan kursor, yang dipicu oleh pengarahan kursor mouse atau stilus, merupakan pengecualian untuk aturan yang ditentukan di sini. Peristiwa pengarahan kursor dikirim ke composable mana pun yang terkena. Jadi, saat pengguna mengarahkan kursor dari batas satu composable ke composable berikutnya, peristiwa akan dikirim ke composable baru, bukan ke composable pertama.

Konsumsi peristiwa

Jika lebih dari satu composable memiliki pengendali gestur yang ditetapkan, pengendali tersebut tidak boleh bertentangan. Misalnya, lihat UI ini:

Item daftar dengan Gambar, Kolom dengan dua teks, dan Tombol.

Saat pengguna mengetuk tombol bookmark, lambda onClick tombol akan menangani gestur tersebut. Saat pengguna mengetuk bagian lain dari item daftar, ListItem akan menangani gestur tersebut dan membuka artikel. Dalam hal input pointer, Tombol harus menggunakan peristiwa ini, sehingga induknya tahu untuk tidak bereaksi lagi terhadapnya. Gestur yang disertakan dalam komponen siap pakai dan pengubah gestur umum mencakup perilaku penggunaan ini, tetapi jika Anda menulis gestur kustom sendiri, Anda harus menggunakan peristiwa secara manual. Anda dapat melakukannya dengan metode PointerInputChange.consume:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

Menggunakan peristiwa tidak menghentikan penyebaran peristiwa ke composable lain. Sebagai gantinya, composable harus secara eksplisit mengabaikan peristiwa yang digunakan. Saat menulis gestur kustom, Anda harus memeriksa apakah peristiwa sudah digunakan oleh elemen lain:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

Penyebaran peristiwa

Seperti yang disebutkan sebelumnya, perubahan pointer diteruskan ke setiap composable yang dikenainya. Namun, jika ada lebih dari satu composable, dalam urutan apa peristiwa disebarkan? Jika Anda mengambil contoh dari bagian terakhir, UI ini diterjemahkan ke hierarki UI berikut, dengan hanya ListItem dan Button yang merespons peristiwa pointer:

Struktur hierarki. Lapisan atas adalah ListItem, lapisan kedua memiliki Image, Column, dan Button, dan Column dibagi menjadi dua Text. ListItem dan Button ditandai.

Peristiwa pointer mengalir melalui setiap composable ini sebanyak tiga kali, selama tiga "pass":

  • Dalam Pass awal, peristiwa mengalir dari bagian atas hierarki UI ke bawah. Alur ini memungkinkan induk menangkap peristiwa sebelum turunan dapat menggunakannya. Misalnya, tooltip perlu mencegat tekan lama, bukan meneruskannya ke turunannya. Dalam contoh kita, ListItem menerima peristiwa sebelum Button.
  • Dalam Main pass, peristiwa mengalir dari node daun hierarki UI hingga ke root hierarki UI. Fase ini adalah tempat Anda biasanya menggunakan gestur, dan merupakan pass default saat memproses peristiwa. Menangani gestur dalam penerusan ini berarti bahwa node daun lebih diprioritaskan daripada induknya, yang merupakan perilaku paling logis untuk sebagian besar gestur. Dalam contoh kita, Button menerima peristiwa sebelum ListItem.
  • Dalam Final pass, peristiwa mengalir sekali lagi dari bagian atas hierarki UI ke node daun. Alur ini memungkinkan elemen yang lebih tinggi dalam stack untuk merespons penggunaan peristiwa oleh induknya. Misalnya, tombol akan menghapus indikasi riaknya saat penekanan berubah menjadi penyeretan induk yang dapat di-scroll.

Secara visual, alur peristiwa dapat direpresentasikan sebagai berikut:

Setelah perubahan input digunakan, informasi ini akan diteruskan dari titik tersebut dalam alur dan seterusnya:

Dalam kode, Anda dapat menentukan penerusan yang Anda minati:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

Dalam cuplikan kode ini, peristiwa yang sama persis ditampilkan oleh setiap panggilan metode await ini, meskipun data tentang konsumsi mungkin telah berubah.

Menguji gestur

Dalam metode pengujian, Anda dapat mengirim peristiwa pointer secara manual menggunakan metode performTouchInput. Hal ini memungkinkan Anda melakukan gestur penuh tingkat lebih tinggi (seperti mencubit atau mengklik lama) atau gestur tingkat rendah (seperti memindahkan kursor dengan jumlah piksel tertentu):

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

Lihat dokumentasi performTouchInput untuk contoh lainnya.

Pelajari lebih lanjut

Anda dapat mempelajari gestur di Jetpack Compose lebih lanjut dari referensi berikut: