Memahami gestur

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

Definisi

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

  • Pointer: Objek fisik yang dapat digunakan untuk berinteraksi dengan aplikasi. Untuk perangkat seluler, pointer yang paling umum adalah jari berinteraksi dengan layar sentuh. Atau, Anda dapat menggunakan stilus untuk mengganti jari Anda. Untuk perangkat layar besar, Anda dapat menggunakan mouse atau trackpad untuk berinteraksi secara tidak langsung dengan layar. Perangkat input harus dapat "menunjuk" sebuah 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 tingkat rendah dari satu atau beberapa pointer dengan aplikasi pada waktu tertentu. Setiap interaksi pointer, seperti meletakkan jari pada layar atau menarik mouse, akan memicu suatu peristiwa. Di Compose, semua informasi yang relevan untuk peristiwa tersebut dimuat dalam class PointerEvent.
  • Gestur: Urutan peristiwa pointer yang dapat ditafsirkan sebagai satu tindakan. Misalnya, gestur ketuk dapat dianggap sebagai urutan peristiwa turun yang diikuti dengan peristiwa ke atas. 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.

Biasanya, buat di tingkat abstraksi tertinggi yang menawarkan fungsi yang Anda butuhkan. Dengan cara ini, Anda akan mendapatkan manfaat dari praktik terbaik yang disertakan dalam lapisan tersebut. 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 bekerja secara otomatis.

Selain penanganan gestur internal, banyak komponen juga memerlukan pemanggil untuk menangani gestur. Misalnya, Button otomatis mendeteksi ketukan dan memicu peristiwa klik. Anda meneruskan lambda onClick ke Button untuk merespons 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 menyertakan dukungan unik untuk fokus dan aksesibilitas, serta teruji 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 aksesibilitas di Compose lebih lanjut, lihat Aksesibilitas di Compose.

Menambahkan gestur tertentu ke composable arbitrer dengan pengubah

Anda dapat menerapkan pengubah gestur ke composable arbitrer mana pun untuk membuat composable tersebut 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, lebih memilih pengubah gestur unik 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 setiap gestur diimplementasikan dengan pengubah gestur unik. Misalnya, Anda tidak dapat menggunakan pengubah untuk bereaksi terhadap tarik setelah menekan lama, klik kontrol, atau mengetuk dengan tiga jari. Sebagai gantinya, Anda dapat menulis pengendali gestur Anda sendiri untuk mengidentifikasi gestur kustom ini. Anda dapat membuat pengendali gestur dengan pengubah pointerInput, yang memberi Anda akses ke peristiwa pointer mentah.

Kode berikut memantau 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 cuplikan ini dipisahkan, komponen intinya adalah:

  • Pengubah pointerInput. Anda meneruskan satu atau beberapa kunci. Saat nilai salah satu kunci tersebut berubah, lambda konten pengubah akan dijalankan kembali. Sampel meneruskan filter opsional ke composable. Jika nilai filter tersebut berubah, pengendali peristiwa pointer harus dijalankan kembali 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 cukup rumit. Untuk menyederhanakan pembuatan gestur kustom, banyak metode utilitas tersedia.

Mendeteksi gestur penuh

Daripada menangani peristiwa pointer mentah, Anda dapat memproses gestur tertentu agar terjadi dan merespons dengan tepat. AwaitPointerEventScope menyediakan metode untuk memproses:

Ini adalah pendeteksi tingkat atas, sehingga Anda tidak dapat menambahkan beberapa pendeteksi dalam satu pengubah pointerInput. Cuplikan berikut hanya mendeteksi ketukan, bukan tarik:

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 pendeteksi kedua tidak pernah dijangkau. 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 definisi, gestur dimulai dengan peristiwa pointer down. Anda dapat menggunakan metode helper awaitEachGesture, bukan loop while(true) yang meneruskan setiap peristiwa mentah. Metode awaitEachGesture akan memulai ulang blok penampung saat semua pointer telah dicabut, yang menunjukkan gestur 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—hal ini hanya perlu mengetahui kapan pointer memasuki atau keluar dari batasnya.

Menunggu peristiwa atau sub-gestur tertentu

Ada serangkaian metode yang membantu mengidentifikasi bagian-bagian umum dari {i>gesture <i}:

Menerapkan penghitungan untuk peristiwa multi-sentuh

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

Pengiriman peristiwa dan hit-test

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 memulai hit-testing composable yang "memenuhi syarat". Composable dianggap memenuhi syarat jika memiliki kemampuan penanganan input pointer. Hit-test mengalir dari bagian atas hierarki UI ke bawah. Composable adalah "hit" jika peristiwa pointer terjadi dalam batas composable tersebut. Proses ini menghasilkan rangkaian composable yang mencapai hit-test positif.
  • Secara default, jika ada beberapa composable yang memenuhi syarat pada tingkat hierarki yang sama, hanya composable dengan indeks z tertinggi yang akan menjadi "hit". Misalnya, saat Anda menambahkan dua composable Button yang tumpang-tindih ke Box, hanya yang digambar di bagian atas yang akan menerima peristiwa pointer. Secara teoritis, Anda dapat mengganti perilaku ini dengan membuat penerapan PointerInputModifierNode Anda sendiri dan menetapkan sharePointerInputWithSiblings ke benar (true).
  • Peristiwa lebih lanjut untuk pointer yang sama dikirim ke rantai composable yang sama, dan mengalir sesuai dengan logika propagasi peristiwa. Sistem tidak melakukan lagi hit-testing 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, meskipun pointer berada di dalam batasnya.

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

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, agar induknya tahu untuk tidak bereaksi lagi. Gestur yang disertakan dalam komponen siap pakai dan pengubah gestur umum menyertakan perilaku konsumsi ini, tetapi jika Anda menulis gestur kustom sendiri, Anda harus menggunakan peristiwa secara manual. Anda melakukannya dengan metode PointerInputChange.consume:

Modifier.pointerInput(Unit) {

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

Memakai peristiwa tidak akan menghentikan penerapan peristiwa ke composable lain. Sebagai gantinya, composable harus secara eksplisit mengabaikan peristiwa yang digunakan. Saat menulis gestur kustom, Anda harus memeriksa apakah suatu 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
            }
        }
    }
}

Penerapan peristiwa

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

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

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

  • Pada Tahap awal, peristiwa mengalir dari bagian atas hierarki UI ke bawah. Alur ini memungkinkan induk untuk menangkap peristiwa sebelum anak dapat menggunakannya. Misalnya, tooltip perlu menangkap tekan lama, bukan meneruskannya ke turunannya. Dalam contoh kita, ListItem menerima peristiwa sebelum Button.
  • Di Jalur utama, peristiwa mengalir dari node daun hierarki UI hingga root hierarki UI. Fase ini adalah saat Anda biasanya menggunakan gestur, dan merupakan proses default saat memproses peristiwa. Menangani gestur dalam penerusan ini berarti bahwa node daun lebih diutamakan daripada induknya, yang merupakan perilaku paling logis untuk sebagian besar gestur. Dalam contoh kita, Button menerima peristiwa sebelum ListItem.
  • Di 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 pemakaian peristiwa oleh induknya. Misalnya, tombol menghapus indikasi ripple saat penekanan berubah menjadi tarik dari induknya yang dapat di-scroll.

Secara visual, alur peristiwa dapat direpresentasikan sebagai berikut:

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

Dalam kode, Anda dapat menentukan kartu 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 identik yang sama ditampilkan oleh setiap panggilan metode tunggu 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 cubit atau klik lama) atau gestur tingkat rendah (seperti menggerakkan 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: