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 tidak ingin melakukannya, pengguna dapat menarik jarinya menjauh dari tombol sebelum melepaskannya. Jika tidak, tombol akan aktif.

Gambar 1. Tombol yang selalu muncul aktif, tanpa ripple tekan.
Gambar 2. Tombol dengan ripple pers yang mencerminkan status aktifnya sebagaimana mestinya.

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 melacak sendiri interaksi dengan InteractionSource, lihat Menggunakan InteractionSource.

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

Memakai dan memunculkan Interaction

InteractionSource mewakili aliran hanya baca Interactions — tidak dapat memunculkan Interaction ke InteractionSource. Untuk memunculkan Interaction, Anda harus menggunakan MutableInteractionSource, yang diperluas dari InteractionSource.

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

Contoh pengubah pemakaian

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

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

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

Contoh pengubah pembuatan

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

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

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

Membangun komponen yang mengkonsumsi dan menghasilkan

Komponen tingkat tinggi seperti Button Material berfungsi sebagai produsen dan konsumen. Fungsi ini menangani input dan peristiwa fokus, serta mengubah tampilannya sebagai respons terhadap peristiwa ini, seperti menampilkan ripple atau menganimasikan elevasinya. Akibatnya, fungsi ini secara langsung mengekspos MutableInteractionSource sebagai parameter, sehingga Anda dapat menyediakan instance yang diingat 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.

Jika Anda membangun komponen tingkat tinggi interaktif, sebaiknya tampilkan MutableInteractionSource sebagai parameter dengan cara ini. Selain mengikuti praktik terbaik pengangkatan status, hal ini juga memudahkan untuk membaca dan mengontrol status visual komponen dengan cara yang sama seperti status lainnya (seperti status diaktifkan) dapat dibaca dan dikontrol.

Compose mengikuti pendekatan arsitektur berlapis, sehingga komponen Material tingkat tinggi dibuat di atas elemen penyusun dasar yang menghasilkan Interaction yang diperlukan untuk mengontrol ripple dan efek visual lainnya. Library dasar menyediakan pengubah interaksi tingkat tinggi seperti Modifier.hoverable, Modifier.focusable, dan Modifier.draggable.

Untuk membuat komponen yang merespons peristiwa pengarahan kursor, Anda cukup menggunakan Modifier.hoverable dan meneruskan MutableInteractionSource sebagai parameter. Setiap kali diarahkan, komponen akan memunculkan HoverInteraction, dan Anda dapat menggunakannya 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 sebagai parameter. Sekarang, HoverInteraction.Enter/Exit dan FocusInteraction.Focus/Unfocus ditampilkan melalui MutableInteractionSource yang sama, dan Anda dapat menyesuaikan penampilan 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 adalah abstraksi level yang lebih tinggi daripada hoverable dan focusable — agar komponen dapat diklik, secara implisit dapat diarahkan, dan komponen yang dapat diklik juga harus dapat difokuskan. Anda dapat menggunakan Modifier.clickable untuk membuat komponen yang menangani interaksi arahkan kursor, fokus, dan tekan, tanpa perlu menggabungkan API dengan tingkat lebih rendah. Jika ingin membuat komponen juga dapat diklik, Anda dapat mengganti 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 interaksi terbaru, cukup lihat item terakhir dalam daftar. Misalnya, ini adalah cara implementasi ripple Compose menentukan overlay status yang sesuai untuk digunakan untuk 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 ada banyak perbedaan dalam kode saat menggunakan berbagai jenis interaksi pengguna — pola keseluruhannya sama.

Perhatikan bahwa contoh sebelumnya di bagian ini mewakili Flow interaksi menggunakan State — hal ini memudahkan untuk mengamati nilai yang diperbarui, karena membaca nilai status akan otomatis menyebabkan rekomposisi. Namun, komposisi adalah pra-frame dikelompokkan. Ini berarti bahwa jika status berubah, lalu berubah kembali dalam frame yang sama, komponen yang mengamati status tidak akan melihat perubahan.

Hal ini penting untuk interaksi, karena interaksi dapat dimulai dan diakhiri secara teratur dalam frame 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 frame yang sama, teks tidak akan ditampilkan sebagai "Pressed!". Pada umumnya, hal ini bukanlah masalah. Menampilkan efek visual dalam waktu yang singkat akan menyebabkan layar berkedip, dan tidak akan terlalu terlihat bagi pengguna. Untuk beberapa kasus, seperti menampilkan efek ripple atau animasi serupa, sebaiknya Anda menampilkan efek setidaknya untuk jangka waktu minimum, bukan langsung berhenti jika tombol tidak lagi ditekan. Untuk melakukannya, Anda dapat langsung memulai dan menghentikan animasi dari dalam lambda mengumpulkan, bukan menulis ke status. Terdapat contoh pola ini di bagian Membangun Indication lanjutan dengan batas animasi.

Contoh: Mem-build komponen 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 belanja 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 bagian komponen sebagai respons terhadap Interaction yang berbeda, seperti menampilkan ikon saat ditekan. Pendekatan yang sama ini dapat digunakan untuk mengubah nilai parameter yang Anda berikan pada komponen, atau mengubah konten yang ditampilkan di dalam komponen, tetapi ini hanya berlaku untuk setiap komponen. Sering kali, aplikasi atau sistem desain akan memiliki sistem generik untuk efek visual stateful — efek yang harus diterapkan ke semua komponen secara konsisten.

Jika Anda mem-build sistem desain semacam ini, menyesuaikan satu komponen dan menggunakan kembali penyesuaian ini untuk komponen lain bisa jadi sulit karena alasan berikut:

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

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

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

    Instance ini bisa stateful atau stateless. Karena dibuat per komponen, nilai tersebut dapat mengambil nilai dari CompositionLocal untuk mengubah cara tampilan atau perilakunya dalam komponen tertentu, seperti dengan Modifier.Node lainnya.

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

Ganti efek dengan Indication

Bagian ini menjelaskan cara mengganti efek skala manual yang diterapkan ke satu tombol tertentu dengan indikasi setara yang dapat digunakan kembali di beberapa 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. Jika dipasang, node mengamati sumber interaksi, mirip dengan contoh sebelumnya. Satu-satunya perbedaan di sini adalah tindakan ini langsung meluncurkan animasi, bukan mengonversi Interaksi masuk menjadi status.

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

    Memanggil drawContent() yang tersedia dari penerima ContentDrawScope akan menggambar komponen sebenarnya tempat Indication harus diterapkan, sehingga Anda hanya perlu memanggil fungsi ini dalam transformasi skala. Pastikan implementasi Indication Anda 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. Tanggung jawab satu-satunya adalah membuat instance node baru untuk sumber interaksi yang disediakan. Karena tidak ada parameter untuk mengonfigurasi indikasi, factory dapat 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, Anda hanya perlu 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 Anda mem-build komponen tingkat tinggi yang dapat digunakan kembali menggunakan Indication kustom — tombol akan terlihat seperti ini:

    @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 mengecil 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 API gambar lainnya. Misalnya, Anda dapat menggambar batas animasi di sekitar komponen dan overlay di atas komponen saat ditekan:

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

Implementasi Indication di sini sangat mirip dengan contoh sebelumnya — hanya membuat node dengan beberapa parameter. Karena batas animasi bergantung pada bentuk dan batas komponen yang digunakan Indication, implementasi Indication juga memerlukan bentuk dan lebar batas untuk disediakan 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 kode gambar lebih rumit. Seperti sebelumnya, library 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 utamanya di sini adalah sekarang ada durasi minimum untuk animasi dengan fungsi animateToResting(), jadi meskipun penekanan segera dirilis, animasi pers akan berlanjut. Ada juga penanganan untuk beberapa penekanan cepat di awal animateToPressed — jika penekanan terjadi selama animasi tekan atau istirahat yang ada, animasi sebelumnya akan dibatalkan, dan animasi pers dimulai dari awal. Untuk mendukung beberapa efek serentak (seperti dengan ripple, dengan animasi ripple baru akan digambar di atas ripple lainnya), Anda dapat melacak animasi dalam daftar, bukan membatalkan animasi yang ada dan memulai animasi baru.