Menangani interaksi pengguna

Komponen antarmuka pengguna memberikan masukan kepada pengguna perangkat melalui cara komponen merespons interaksi pengguna. Setiap komponen memiliki cara sendiri untuk merespons interaksi, yang membantu pengguna mengetahui apa yang akan terjadi dengan interaksi yang mereka lakukan. Misalnya, jika pengguna menyentuh tombol pada layar sentuh perangkat, tombol tersebut kemungkinan akan berubah sedemikian rupa, mungkin dengan menambahkan warna sorotan. Perubahan ini memberi tahu pengguna bahwa mereka telah menyentuh tombol. Jika pengguna tidak ingin melakukan mereka akan menyeret jari mereka menjauh dari tombol sebelum melepaskan--jika tidak, tombol akan aktif.

Gambar 1. Tombol yang selalu muncul aktif, tanpa ripple tekan.
Gambar 2. Tombol dengan ripple tekan yang mencerminkan status pengaktifannya secara tepat.

Dokumentasi Gestur Compose mencakup cara komponen Compose menangani peristiwa pointer level rendah, seperti gerakan pointer dan klik. Secara langsung, Compose memisahkan peristiwa level rendah tersebut menjadi interaksi level yang lebih tinggi–misalnya, serangkaian peristiwa pointer dapat ditambahkan ke penekanan dan pelepasan tombol. Memahami abstraksi level tinggi tersebut dapat membantu Anda menyesuaikan respons UI terhadap pengguna. Misalnya, Anda mungkin ingin menyesuaikan perubahan tampilan komponen saat pengguna berinteraksi dengan komponen, atau mungkin Anda hanya ingin mempertahankan log tindakan pengguna tersebut. Dokumen ini memberikan informasi yang Anda perlukan untuk mengubah elemen UI standar, atau mendesain elemen UI Anda sendiri.

Interaksi

Dalam banyak kasus, Anda tidak perlu mengetahui cara komponen Compose menafsirkan interaksi pengguna. Misalnya, Button bergantung pada Modifier.clickable untuk mencari tahu apakah pengguna mengklik tombol atau tidak. Jika menambahkan tombol khusus ke aplikasi, Anda dapat menentukan kode onClick tombol, dan Modifier.clickable akan menjalankan kode tersebut jika sesuai. Itu berarti Anda tidak perlu mengetahui apakah pengguna mengetuk layar atau memilih tombol dengan keyboard; Modifier.clickable mengetahui bahwa pengguna melakukan klik, dan merespons dengan menjalankan kode onClick.

Namun, jika ingin menyesuaikan respons komponen UI terhadap perilaku pengguna, Anda mungkin perlu mengetahui apa yang terjadi lebih lanjut. Bagian ini memberikan beberapa informasi tersebut.

Saat pengguna berinteraksi dengan komponen UI, sistem merepresentasikan perilakunya dengan menghasilkan sejumlah peristiwa Interaction. Misalnya, jika pengguna menyentuh tombol, tombol tersebut akan menghasilkan PressInteraction.Press. Jika pengguna mengangkat jari di dalam tombol, tindakan ini akan menghasilkan PressInteraction.Release, yang memberi tahu tombol bahwa klik telah selesai. Di sisi lain, jika pengguna menarik jari ke luar tombol, lalu mengangkat jari, tombol akan menghasilkan PressInteraction.Cancel, untuk menunjukkan bahwa penekanan pada tombol dibatalkan, bukan diselesaikan.

Interaksi ini tidak terkonfigurasi. Yaitu, peristiwa interaksi level rendah ini tidak bermaksud menafsirkan makna tindakan pengguna, atau urutannya. Peristiwa ini juga tidak menafsirkan tindakan pengguna mana yang mungkin diprioritaskan dari tindakan lainnya.

Interaksi ini biasanya berpasangan, dengan awal dan akhir. Interaksi kedua berisi referensi ke interaksi pertama. Misalnya, jika pengguna menyentuh tombol, lalu mengangkat jarinya, sentuhan tersebut akan menghasilkan interaksi PressInteraction.Press, dan pelepasan akan menghasilkan PressInteraction.Release; Release memiliki properti press yang mengidentifikasi PressInteraction.Press awal.

Anda dapat melihat interaksi untuk komponen tertentu dengan mengamati InteractionSource-nya. InteractionSource di-build di atas alur Kotlin, sehingga Anda dapat mengumpulkan interaksi dari alur tersebut dengan cara yang sama seperti Anda mengerjakan alur lainnya. Untuk informasi selengkapnya tentang keputusan desain ini, lihat postingan blog Illuminating Interactions.

Status interaksi

Anda mungkin ingin memperluas fungsi bawaan komponen dengan melacak interaksi sendiri. Misalnya, mungkin Anda ingin tombol berubah warna saat ditekan. Cara termudah untuk melacak interaksi adalah dengan mengamati status interaksi yang sesuai. InteractionSource menawarkan sejumlah metode yang mengungkap berbagai status interaksi sebagai status. Misalnya, jika ingin melihat apakah tombol tertentu ditekan, Anda dapat memanggil metode InteractionSource.collectIsPressedAsState():

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Selain collectIsPressedAsState(), Compose juga menyediakan collectIsFocusedAsState(), collectIsDraggedAsState(), dan collectIsHoveredAsState(). Metode ini sebenarnya adalah metode praktis yang dibuat di atas InteractionSource API dengan level yang lebih rendah. Dalam beberapa kasus, Anda mungkin ingin menggunakan fungsi level rendah tersebut secara langsung.

Misalnya, anggaplah Anda perlu mengetahui apakah tombol sedang ditekan, dan apakah tombol sedang ditarik. Jika Anda menggunakan collectIsPressedAsState() dan collectIsDraggedAsState(), Compose akan melakukan banyak tugas duplikat, dan tidak ada jaminan Anda akan mendapatkan semua interaksi dalam urutan yang tepat. Untuk situasi seperti ini, Anda mungkin ingin langsung menggunakan InteractionSource. Untuk informasi selengkapnya tentang pelacakan interaksi diri Anda sendiri dengan InteractionSource, lihat Bekerja dengan InteractionSource.

Bagian berikut menjelaskan cara memakai dan memunculkan interaksi dengan InteractionSource dan MutableInteractionSource.

Menggunakan dan mengeluarkan Interaction

InteractionSource mewakili aliran hanya baca Interactions — bukan mungkin memunculkan Interaction ke InteractionSource. Untuk memancarkan Interaction, Anda perlu menggunakan MutableInteractionSource, yang diperluas dari InteractionSource.

Pengubah dan komponen dapat menggunakan, memunculkan, atau memakai dan memunculkan Interactions. Bagian berikut menjelaskan cara memakai dan memunculkan interaksi dari keduanya pengubah dan komponen.

Memakai contoh pengubah

Untuk pengubah yang menggambar batas untuk status fokus, Anda hanya perlu mengamati Interactions, sehingga Anda dapat menyetujui InteractionSource:

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

Dari tanda tangan fungsi, terlihat jelas bahwa pengubah ini adalah konsumen, yaitu pengubah dapat memakai Interaction, tetapi tidak dapat memunculkannya.

Membuat contoh pengubah

Untuk pengubah yang menangani peristiwa pengarahan kursor seperti Modifier.hoverable, Anda perlu memunculkan Interactions, dan menerima MutableInteractionSource sebagai parameter sebagai gantinya:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

Pengubah ini adalah produser — Pengubah ini dapat menggunakan MutableInteractionSource untuk memunculkan HoverInteractions saat kursor diarahkan ke atasnya atau tidak diarahkan kursor.

Membangun komponen yang menggunakan dan menghasilkan

Komponen tingkat tinggi seperti Button Material berfungsi sebagai produser dan konsumen. Mereka menangani input dan memfokuskan peristiwa, serta mengubah penampilan mereka sebagai respons terhadap peristiwa ini, seperti menampilkan ripple atau menganimasikan elevasi. Akibatnya, fungsi tersebut secara langsung mengekspos MutableInteractionSource sebagai , sehingga Anda dapat memberikan instance yang Anda ingat sendiri:

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,

    // exposes MutableInteractionSource as a parameter
    interactionSource: MutableInteractionSource? = null,

    elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { /* content() */ }

Hal ini memungkinkan pengangkatan MutableInteractionSource dari komponen dan mengamati semua Interaction yang dihasilkan oleh komponen. Anda dapat menggunakannya untuk mengontrol tampilan komponen tersebut, atau komponen lain di UI Anda.

Jika Anda membuat komponen interaktif tingkat tinggi, sebaiknya Anda mengekspos MutableInteractionSource sebagai parameter dengan cara ini. Selain mengikuti praktik terbaik pengangkatan status, hal ini juga membuatnya mudah dibaca dan mengontrol keadaan visual komponen dengan cara yang sama seperti (seperti status aktif) dapat dibaca dan dikontrol.

Compose mengikuti pendekatan arsitektur berlapis, sehingga komponen Material tingkat tinggi dibangun di atas bangunan dasar blok yang menghasilkan Interaction yang mereka butuhkan untuk mengontrol ripple dan efek visual. Library dasar menyediakan pengubah interaksi tingkat tinggi seperti Modifier.hoverable, Modifier.focusable, dan Modifier.draggable.

Untuk membangun komponen yang merespons peristiwa pengarahan kursor, Anda cukup menggunakan Modifier.hoverable dan teruskan MutableInteractionSource sebagai parameter. Setiap kali kursor diarahkan ke atasnya, komponen akan memunculkan HoverInteraction, dan Anda dapat menggunakan ini untuk mengubah tampilan komponen.

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Agar komponen ini juga dapat difokuskan, Anda dapat menambahkan Modifier.focusable dan meneruskan MutableInteractionSource yang sama dengan parameter. Sekarang, keduanya HoverInteraction.Enter/Exit dan FocusInteraction.Focus/Unfocus dimunculkan melalui MutableInteractionSource yang sama, dan Anda dapat menyesuaikan tampilan untuk kedua jenis interaksi di tempat yang sama:

// This InteractionSource will emit hover and focus interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource)
        .focusable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Modifier.clickable jauh lebih tinggi abstraksi tingkat daripada hoverable dan focusable — agar komponen dapat diklik, secara implisit dapat diarahkan, dan komponen yang dapat diklik harus juga dapat difokuskan. Anda dapat menggunakan Modifier.clickable untuk membuat komponen yang menangani pengarahan kursor, fokus, dan interaksi tekan, tanpa perlu menggabungkan elemen level API. Jika Anda juga ingin membuat komponen yang dapat diklik, Anda dapat ganti hoverable dan focusable dengan clickable:

// This InteractionSource will emit hover, focus, and press interactions
val interactionSource = remember { MutableInteractionSource() }
Box(
    Modifier
        .size(100.dp)
        .clickable(
            onClick = {},
            interactionSource = interactionSource,

            // Also show a ripple effect
            indication = ripple()
        ),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Bekerja dengan InteractionSource

Jika memerlukan informasi level rendah terkait interaksi dengan komponen, Anda dapat menggunakan flow API standar untuk InteractionSource komponen tersebut. Misalnya, Anda ingin mempertahankan daftar interaksi tekan dan tarik untuk InteractionSource. Kode ini melakukan separuh pekerjaan, yang menambahkan penekanan baru ke daftar saat masuk:

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

Namun, selain menambahkan interaksi baru, Anda juga harus menghapus interaksi saat interaksi tersebut berakhir (misalnya, saat pengguna mengangkat jarinya kembali dari komponen). Hal ini mudah dilakukan karena interaksi akhir selalu membawa referensi ke interaksi awal yang terkait. Kode ini menunjukkan cara menghapus interaksi yang telah berakhir:

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

Sekarang, jika ingin mengetahui apakah komponen saat ini ditekan atau ditarik, yang harus Anda lakukan adalah memeriksa apakah interactions kosong:

val isPressedOrDragged = interactions.isNotEmpty()

Jika Anda ingin tahu apa interaksi terbaru, lihat saja interaksi item baris dalam daftar. Misalnya, ini adalah cara implementasi ripple Compose mencari tahu overlay status yang sesuai untuk digunakan bagi interaksi terbaru:

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

Karena semua Interaction mengikuti struktur yang sama, tidak banyak perbedaan kode saat bekerja dengan berbagai jenis interaksi pengguna — pola keseluruhannya sama.

Perhatikan bahwa contoh sebelumnya di bagian ini mewakili Flow interaksi menggunakan State — hal ini memudahkan kita untuk mengamati nilai yang telah diperbarui, karena membaca nilai status akan otomatis menyebabkan rekomposisi. Namun, komposisi adalah batch pre-frame. Ini berarti bahwa jika status berubah, dan kemudian berubah kembali dalam {i>frame<i} yang sama, komponen yang mengamati status tidak melihat perubahannya.

Hal ini penting untuk interaksi, karena interaksi dapat dimulai dan diakhiri secara rutin dalam {i>frame<i} yang sama. Misalnya, menggunakan contoh sebelumnya dengan Button:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Jika penekanan dimulai dan berakhir dalam {i>frame<i} yang sama, teks tidak akan pernah ditampilkan sebagai "Ditekan!". Di kebanyakan kasus, ini bukan masalah — menunjukkan efek visual untuk dalam waktu singkat akan menyebabkan kedipan, dan tidak terlalu terlihat jelas oleh pengguna. Untuk beberapa kasus, seperti menampilkan efek riak atau animasi serupa, sebaiknya tampilkan efek setidaknya dengan jumlah minimum waktu, alih-alih langsung berhenti jika tombol tidak lagi ditekan. Kepada melakukannya, Anda dapat langsung memulai dan menghentikan animasi dari dalam lambda, alih-alih menulis ke status. Ada contoh dari pola ini bagian Membangun Indication lanjutan dengan batas animasi.

Contoh: Komponen build dengan penanganan interaksi kustom

Untuk mengetahui cara mem-build komponen dengan respons kustom terhadap input, berikut contoh tombol yang dimodifikasi. Dalam hal ini, misalnya Anda menginginkan tombol yang merespons penekanan dengan mengubah tampilannya:

Animasi tombol yang secara dinamis menambahkan ikon keranjang bahan makanan saat diklik
Gambar 3. Tombol yang secara dinamis menambahkan ikon saat diklik.

Untuk melakukannya, build composable kustom berdasarkan Button, dan minta parameter icon tambahan untuk menggambar ikon (dalam hal ini, keranjang belanja). Anda memanggil collectIsPressedAsState() untuk melacak apakah pengguna mengarahkan kursor ke tombol; saat itu terjadi, Anda menambahkan ikon. Kode akan terlihat seperti berikut ini:

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource? = null
) {
    val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false

    Button(
        onClick = onClick,
        modifier = modifier,
        interactionSource = interactionSource
    ) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

Dan berikut tampilan penggunaan composable baru tersebut:

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

Karena PressIconButton baru ini di-build di atas Button Material yang ada, kode ini bereaksi terhadap interaksi pengguna dengan cara yang biasa. Saat pengguna menekannya, tombol akan sedikit mengubah opasitasnya, seperti Button Material biasa.

Membuat dan menerapkan efek kustom yang dapat digunakan kembali dengan Indication

Di bagian sebelumnya, Anda telah mempelajari cara mengubah sebagian komponen sebagai respons ke Interaction yang berbeda, seperti menampilkan ikon saat ditekan. Hal yang sama dapat digunakan untuk mengubah nilai parameter yang Anda berikan ke komponen, atau mengubah konten yang ditampilkan di dalam komponen, namun ini hanya berlaku per komponen. Sering kali, aplikasi atau sistem desain akan memiliki sistem generik untuk efek visual stateful — sebuah efek yang seharusnya diterapkan ke semua komponen dengan cara yang konsisten.

Jika Anda membangun sistem desain semacam ini, menyesuaikan satu komponen dan menggunakan kembali penyesuaian ini untuk komponen lain bisa sulit bagi alasan berikut:

  • Setiap komponen dalam sistem desain membutuhkan boilerplate yang sama
  • Sangat mudah untuk lupa menerapkan efek ini ke komponen yang baru dibuat dan komponen yang dapat diklik
  • Mungkin sulit untuk menggabungkan efek kustom dengan efek lainnya

Untuk menghindari masalah ini dan menskalakan komponen kustom dengan mudah di seluruh sistem Anda, Anda dapat menggunakan Indication. Indication mewakili efek visual yang dapat digunakan kembali yang dapat diterapkan komponen dalam suatu aplikasi atau sistem desain. Indication dibagi menjadi dua suku cadang:

  • IndicationNodeFactory: Factory yang membuat instance Modifier.Node yang merender efek visual untuk sebuah komponen. Untuk implementasi yang lebih sederhana yang tidak berubah di seluruh komponen, ini bisa berupa singleton (objek) dan digunakan kembali di seluruh aplikasi.

    Instance ini dapat bersifat stateful atau stateless. Karena mereka dibuat berdasarkan , mereka dapat mengambil nilai dari CompositionLocal untuk mengubah cara elemen itu muncul atau berperilaku di dalam komponen tertentu, seperti yang lainnya Modifier.Node.

  • Modifier.indication: Pengubah yang menggambar Indication untuk komponen. Modifier.clickable dan pengubah interaksi tingkat tinggi lainnya menerima parameter indikasi secara langsung, sehingga mereka tidak hanya Interaction, tetapi juga dapat menggambar efek visual untuk Interaction yang yang dihasilkan. Jadi, untuk kasus sederhana, Anda cukup menggunakan Modifier.clickable tanpa membutuhkan Modifier.indication.

Ganti efek dengan Indication

Bagian ini menjelaskan cara mengganti efek skala manual yang diterapkan ke efek tombol tertentu dengan setara tanda yang dapat digunakan kembali di berbagai komponen.

Kode berikut membuat tombol yang diskalakan ke bawah saat ditekan:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale")

Button(
    modifier = Modifier.scale(scale),
    onClick = { },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Untuk mengonversi efek skala dalam cuplikan di atas menjadi Indication, ikuti langkah-langkah berikut:

  1. Buat Modifier.Node yang bertanggung jawab untuk menerapkan efek skala. Saat terpasang, node mengamati sumber interaksi, mirip dengan yang sebelumnya contoh. Satu-satunya perbedaan di sini adalah ia langsung meluncurkan animasi alih-alih mengonversi Interaksi yang masuk menjadi status.

    Node perlu mengimplementasikan DrawModifierNode agar dapat mengganti ContentDrawScope#draw(), dan render efek skala menggunakan gambar yang sama seperti halnya API grafis lainnya di Compose.

    Memanggil drawContent() yang tersedia dari penerima ContentDrawScope akan diambil komponen aktual tempat Indication harus diterapkan, sehingga Anda memanggil fungsi ini di dalam transformasi skala. Pastikan atribut Implementasi Indication selalu memanggil drawContent() pada waktu tertentu; jika tidak, komponen tempat Anda menerapkan Indication tidak akan digambar.

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

  2. Buat IndicationNodeFactory. Satu-satunya tanggung jawabnya adalah untuk membuat instance node baru untuk sumber interaksi yang disediakan. Karena tidak ada parameter untuk mengonfigurasi indikasi, factory bisa berupa objek:

    object ScaleIndication : IndicationNodeFactory {
        override fun create(interactionSource: InteractionSource): DelegatableNode {
            return ScaleNode(interactionSource)
        }
    
        override fun equals(other: Any?): Boolean = other === ScaleIndication
        override fun hashCode() = 100
    }

  3. Modifier.clickable menggunakan Modifier.indication secara internal, jadi untuk membuat komponen yang dapat diklik dengan ScaleIndication, yang perlu Anda lakukan adalah menyediakan Indication sebagai parameter untuk clickable:

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    Hal ini juga memudahkan pembuatan komponen tingkat tinggi yang dapat digunakan kembali menggunakan Indication — tombol dapat terlihat seperti:

    @Composable
    fun ScaleButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        shape: Shape = CircleShape,
        content: @Composable RowScope.() -> Unit
    ) {
        Row(
            modifier = modifier
                .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp)
                .clickable(
                    enabled = enabled,
                    indication = ScaleIndication,
                    interactionSource = interactionSource,
                    onClick = onClick
                )
                .border(width = 2.dp, color = Color.Blue, shape = shape)
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }

Kemudian, Anda dapat menggunakan tombol tersebut dengan cara berikut:

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

Animasi tombol dengan ikon keranjang belanja yang menjadi lebih kecil saat ditekan
Gambar 4. Tombol yang dibuat dengan Indication kustom.

Membangun Indication lanjutan dengan batas animasi

Indication tidak hanya terbatas pada efek transformasi, seperti menskalakan komponen. Karena IndicationNodeFactory menampilkan Modifier.Node, Anda dapat menggambar segala jenis efek di atas atau di bawah konten seperti pada API gambar lainnya. Sebagai misalnya, Anda dapat menggambar {i>border<i} animasi di sekitar komponen dan {i>overlay<i} di bagian atas komponen ketika ditekan:

Tombol dengan efek pelangi indah saat ditekan
Gambar 5. Efek batas animasi yang digambar dengan Indication.

Implementasi Indication di sini sangat mirip dengan contoh sebelumnya — alat itu hanya membuat {i> node<i} dengan beberapa parameter. Karena {i>border<i} animasi tergantung pada bentuk dan batas komponen yang digunakan Indication, Penerapan Indication juga memerlukan penyediaan bentuk dan lebar batas sebagai parameter:

data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory {

    override fun create(interactionSource: InteractionSource): DelegatableNode {
        return NeonNode(
            shape,
            // Double the border size for a stronger press effect
            borderWidth * 2,
            interactionSource
        )
    }
}

Implementasi Modifier.Node juga secara konseptual sama, meskipun menggambar kode menjadi lebih rumit. Seperti sebelumnya, modul ini mengamati InteractionSource saat dilampirkan, meluncurkan animasi, dan mengimplementasikan DrawModifierNode untuk menggambar efek di atas konten:

private class NeonNode(
    private val shape: Shape,
    private val borderWidth: Dp,
    private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
    var currentPressPosition: Offset = Offset.Zero
    val animatedProgress = Animatable(0f)
    val animatedPressAlpha = Animatable(1f)

    var pressedAnimation: Job? = null
    var restingAnimation: Job? = null

    private suspend fun animateToPressed(pressPosition: Offset) {
        // Finish any existing animations, in case of a new press while we are still showing
        // an animation for a previous one
        restingAnimation?.cancel()
        pressedAnimation?.cancel()
        pressedAnimation = coroutineScope.launch {
            currentPressPosition = pressPosition
            animatedPressAlpha.snapTo(1f)
            animatedProgress.snapTo(0f)
            animatedProgress.animateTo(1f, tween(450))
        }
    }

    private fun animateToResting() {
        restingAnimation = coroutineScope.launch {
            // Wait for the existing press animation to finish if it is still ongoing
            pressedAnimation?.join()
            animatedPressAlpha.animateTo(0f, tween(250))
            animatedProgress.snapTo(0f)
        }
    }

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                    is PressInteraction.Release -> animateToResting()
                    is PressInteraction.Cancel -> animateToResting()
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition(
            currentPressPosition, size
        )
        val brush = animateBrush(
            startPosition = startPosition,
            endPosition = endPosition,
            progress = animatedProgress.value
        )
        val alpha = animatedPressAlpha.value

        drawContent()

        val outline = shape.createOutline(size, layoutDirection, this)
        // Draw overlay on top of content
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha * 0.1f
        )
        // Draw border on top of overlay
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha,
            style = Stroke(width = borderWidth.toPx())
        )
    }

    /**
     * Calculates a gradient start / end where start is the point on the bounding rectangle of
     * size [size] that intercepts with the line drawn from the center to [pressPosition],
     * and end is the intercept on the opposite end of that line.
     */
    private fun calculateGradientStartAndEndFromPressPosition(
        pressPosition: Offset,
        size: Size
    ): Pair<Offset, Offset> {
        // Convert to offset from the center
        val offset = pressPosition - size.center
        // y = mx + c, c is 0, so just test for x and y to see where the intercept is
        val gradient = offset.y / offset.x
        // We are starting from the center, so halve the width and height - convert the sign
        // to match the offset
        val width = (size.width / 2f) * sign(offset.x)
        val height = (size.height / 2f) * sign(offset.y)
        val x = height / gradient
        val y = gradient * width

        // Figure out which intercept lies within bounds
        val intercept = if (abs(y) <= abs(height)) {
            Offset(width, y)
        } else {
            Offset(x, height)
        }

        // Convert back to offsets from 0,0
        val start = intercept + size.center
        val end = Offset(size.width - start.x, size.height - start.y)
        return start to end
    }

    private fun animateBrush(
        startPosition: Offset,
        endPosition: Offset,
        progress: Float
    ): Brush {
        if (progress == 0f) return TransparentBrush

        // This is *expensive* - we are doing a lot of allocations on each animation frame. To
        // recreate a similar effect in a performant way, it would be better to create one large
        // gradient and translate it on each frame, instead of creating a whole new gradient
        // and shader. The current approach will be janky!
        val colorStops = buildList {
            when {
                progress < 1 / 6f -> {
                    val adjustedProgress = progress * 6f
                    add(0f to Blue)
                    add(adjustedProgress to Color.Transparent)
                }
                progress < 2 / 6f -> {
                    val adjustedProgress = (progress - 1 / 6f) * 6f
                    add(0f to Purple)
                    add(adjustedProgress * MaxBlueStop to Blue)
                    add(adjustedProgress to Blue)
                    add(1f to Color.Transparent)
                }
                progress < 3 / 6f -> {
                    val adjustedProgress = (progress - 2 / 6f) * 6f
                    add(0f to Pink)
                    add(adjustedProgress * MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 4 / 6f -> {
                    val adjustedProgress = (progress - 3 / 6f) * 6f
                    add(0f to Orange)
                    add(adjustedProgress * MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 5 / 6f -> {
                    val adjustedProgress = (progress - 4 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                else -> {
                    val adjustedProgress = (progress - 5 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxYellowStop to Yellow)
                    add(MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
            }
        }

        return linearGradient(
            colorStops = colorStops.toTypedArray(),
            start = startPosition,
            end = endPosition
        )
    }

    companion object {
        val TransparentBrush = SolidColor(Color.Transparent)
        val Blue = Color(0xFF30C0D8)
        val Purple = Color(0xFF7848A8)
        val Pink = Color(0xFFF03078)
        val Orange = Color(0xFFF07800)
        val Yellow = Color(0xFFF0D800)
        const val MaxYellowStop = 0.16f
        const val MaxOrangeStop = 0.33f
        const val MaxPinkStop = 0.5f
        const val MaxPurpleStop = 0.67f
        const val MaxBlueStop = 0.83f
    }
}

Perbedaan utama di sini adalah bahwa sekarang ada durasi minimum untuk animasi dengan fungsi animateToResting(), sehingga meskipun penekanannya segera dirilis, animasi pers akan dilanjutkan. Terdapat juga penanganan untuk beberapa penekanan cepat di awal animateToPressed — jika penekanan terjadi selama penekanan atau animasi istirahat yang ada, animasi sebelumnya akan dibatalkan, dan animasi pers dimulai dari awal. Untuk mendukung banyak efek serentak (seperti dengan ripple, tempat animasi ripple baru akan menggambar di atas ripple lainnya), Anda dapat melacak animasi dalam daftar, alih-alih membatalkan animasi yang sudah ada dan memulai animasi baru.