Kullanıcı etkileşimlerini yönetme

Kullanıcı arayüzü bileşenleri, kullanıcı etkileşimlerine yanıt verme şekillerine göre cihaz kullanıcısına geri bildirim verir. Her bileşenin, etkileşimlere yanıt vermek için kendi yöntemi vardır. Bu, kullanıcının etkileşimlerinin ne yaptığını bilmesine yardımcı olur. Örneğin, kullanıcı cihazın dokunmatik ekranındaki bir düğmeye dokunursa düğme, muhtemelen bir vurgu rengi ekleyerek bir şekilde değişecek. Bu değişiklik, kullanıcının düğmeye dokunduğunu bilmesini sağlar. Kullanıcı bunu yapmak istemediyse, serbest bırakmadan önce parmağınızı düğmeden uzağa sürüklemesi gerektiğini bilir, aksi takdirde düğme etkinleşir.

Şekil 1. Bas dalgası olmadan her zaman etkin görünen düğmeler.
Şekil 2. Etkinleştirilme durumlarını uygun şekilde yansıtan basın dalgaları içeren düğmeler.

Oluşturma Hareketleri dokümanında, Oluşturma bileşenlerinin işaretçi hareketleri ve tıklamalar gibi düşük seviyeli işaretçi etkinliklerini nasıl işlediği ele alınmaktadır. Compose, kullanıma hazır olduğunda bu alt düzey etkinlikleri daha üst düzey etkileşimlere soyutlar. Örneğin, bir dizi işaretçi etkinliği bir düğmeye basıp serbest bırakma işlevine dönüşebilir. Bu üst düzey soyutlamaları anlamak, kullanıcı arayüzünüzün kullanıcıya nasıl tepki verdiğini özelleştirmenize yardımcı olabilir. Örneğin, kullanıcı etkileşimde bulunduğunda bileşenin görünümünün nasıl değiştiğini özelleştirmek veya bu kullanıcı işlemlerinin bir günlüğünü tutmak isteyebilirsiniz. Bu belgede, standart kullanıcı arayüzü öğelerini değiştirmek veya kendi arayüzünüzü tasarlamak için gereken bilgileri bulabilirsiniz.

Etkileşimler

Çoğu durumda, yalnızca Oluşturma bileşeninizin kullanıcı etkileşimlerini nasıl yorumladığını bilmenize gerek yoktur. Örneğin Button, kullanıcının düğmeyi tıklayıp tıklamadığını belirlemek için Modifier.clickable aracından yararlanır. Uygulamanıza tipik bir düğme ekliyorsanız düğmenin onClick kodunu tanımlayabilirsiniz. Modifier.clickable uygun olduğunda bu kodu çalıştırır. Diğer bir deyişle, kullanıcının ekrana dokunup dokunmadığını veya klavyeyle düğmeyi seçip seçmediğini bilmeniz gerekmez. Modifier.clickable, kullanıcının bir tıklama yaptığını anlar ve onClick kodunuzu çalıştırarak yanıt verir.

Ancak, kullanıcı arayüzü bileşeninizin kullanıcı davranışına yanıtını özelleştirmek isterseniz, arka planda neler olup bittiğini daha iyi bilmeniz gerekebilir. Bu bölümde bu bilgilerden bazıları gösterilmektedir.

Bir kullanıcı bir kullanıcı arayüzü bileşeniyle etkileşimde bulunduğunda, sistem bir dizi Interaction etkinliği oluşturarak bu kullanıcının davranışını temsil eder. Örneğin, kullanıcı bir düğmeye dokunursa düğme PressInteraction.Press oluşturur. Kullanıcı parmağını düğmenin içinde kaldırırsa bir PressInteraction.Release oluşturur ve düğmenin tıklamanın tamamlandığını bilmesini sağlar. Öte yandan, kullanıcı parmağını düğmenin dışına sürükler ve ardından parmağını kaldırırsa düğme, düğmeye basmanın iptal edildiğini, tamamlanmadığını belirtmek için PressInteraction.Cancel değerini oluşturur.

Bu etkileşimler fikirsizdir. Yani bu alt düzey etkileşim etkinlikleri, kullanıcı işlemlerinin anlamını veya sırasını yorumlamaz. Ayrıca hangi kullanıcı işlemlerinin diğer işlemlere göre öncelikli olabileceğini de yorumlamaz.

Bu etkileşimler genellikle başlangıç ve bitiş noktalarıyla çiftler halinde gerçekleşir. İkinci etkileşim, ilkine bir referans içerir. Örneğin, kullanıcı bir düğmeye dokunduktan sonra parmağını kaldırırsa, dokunulduğunda bir PressInteraction.Press etkileşimi oluşturulur ve sürüm, bir PressInteraction.Release oluşturur; Release, ilk PressInteraction.Press kodunu tanımlayan press özelliğine sahiptir.

Belirli bir bileşenin InteractionSource bölümünü inceleyerek bu bileşenin etkileşimlerini görebilirsiniz. InteractionSource, Kotlin akışlarının temeline dayandığından, bu akışlardan etkileşimleri diğer akışlarda olduğu gibi toplayabilirsiniz. Bu tasarım kararı hakkında daha fazla bilgi için Etkileşimleri Aydınlatma blog yayınına bakın.

Etkileşim durumu

Etkileşimleri kendiniz de izleyerek bileşenlerinizin yerleşik işlevlerini genişletmek isteyebilirsiniz. Örneğin, bir düğmeye basıldığında rengi değiştirmesini isteyebilirsiniz. Etkileşimleri izlemenin en basit yolu, uygun etkileşim durumunu gözlemlemektir. InteractionSource, çeşitli etkileşim durumlarını durum olarak gösteren çeşitli yöntemler sunar. Örneğin, belirli bir düğmenin basılı olup olmadığını görmek istiyorsanız ilgili düğmenin InteractionSource.collectIsPressedAsState() yöntemini çağırabilirsiniz:

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

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

Compose, collectIsPressedAsState()'in yanı sıra collectIsFocusedAsState(), collectIsDraggedAsState() ve collectIsHoveredAsState() özelliklerini de sağlar. Bu yöntemler aslında alt düzey InteractionSource API'lerinin üzerine inşa edilen pratik yöntemlerdir. Bazı durumlarda bu alt düzey işlevleri doğrudan kullanmak isteyebilirsiniz.

Örneğin, bir düğmeye basılıp basılmadığını ve ayrıca sürüklenip sürüklenmediğini bilmeniz gerektiğini varsayalım. Hem collectIsPressedAsState() hem de collectIsDraggedAsState() kullanırsanız Compose birçok yinelenen iş çıkarır ve tüm etkileşimleri doğru sırayla elde edeceğinizin garantisi yoktur. Bu gibi durumlarda, doğrudan InteractionSource ile çalışmak isteyebilirsiniz. InteractionSource ile etkileşimleri kendiniz izleme hakkında daha fazla bilgi edinmek için InteractionSource ile çalışma sayfasını inceleyin.

Aşağıdaki bölümde sırasıyla InteractionSource ve MutableInteractionSource ile etkileşimlerin nasıl kullanılacağı ve yayınlanacağı açıklanmaktadır.

Tüketim ve yayma: Interaction

InteractionSource, Interactions öğesinin salt okunur akışını temsil eder; InteractionSource öğesine Interaction gönderilmesi mümkün değildir. Interaction yaymak için InteractionSource ile başlayan bir MutableInteractionSource kullanmanız gerekir.

Değiştiriciler ve bileşenler, Interactions öğesini tüketebilir, yayabilir veya tüketip yayabilir. Aşağıdaki bölümlerde, hem değiştiricilerden hem de bileşenlerden gelen etkileşimlerin nasıl kullanılacağı ve yayınlanacağı açıklanmaktadır.

Tüketim değiştirici örneği

Odaklanılmış durum için kenarlık çizen bir düzenleyicide yalnızca Interactions gözlemlemeniz gerekir. Böylece bir InteractionSource kabul edilir:

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

İşlev imzasından bu değiştiricinin bir tüketici olduğu açıktır. Interaction öğeleri tüketebilir ancak üretemez.

Değiştirici oluşturma örneği

Modifier.hoverable gibi fareyle üzerine gelme etkinliklerini işleyen bir değiştirici için Interactions yayınlamanız ve bunun yerine parametre olarak MutableInteractionSource kabul etmeniz gerekir:

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

Bu değiştirici bir üreticidir. Üzerine gelindiğinde veya fareyle üzerine gelindiğinde HoverInteractions yayınlanması için sağlanan MutableInteractionSource değerini kullanabilir.

Bunları tüketen ve üreten bileşenler oluşturun

Malzeme Button gibi üst düzey bileşenler hem üretici hem de tüketici işlevi görür. Giriş ve odak etkinliklerini yönetir ve ayrıca bu etkinliklere yanıt olarak (ör. dalga göstermek veya yüksekliklerini canlandırmak) görünümlerini değiştirirler. Bunun sonucunda, kendi hatırlanan örneğinizi sağlayabilmeniz için MutableInteractionSource öğesini doğrudan parametre olarak gösterirler:

@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() */ }

Bu, MutableInteractionSource öğesinin bileşenden kaldırılmasına ve bileşen tarafından üretilen tüm Interaction'lerin gözlemlenmesine olanak tanır. Bu bileşenin veya kullanıcı arayüzünüzdeki başka bir bileşenin görünümünü kontrol etmek için bunu kullanabilirsiniz.

Kendi etkileşimli üst düzey bileşenlerinizi oluşturuyorsanız MutableInteractionSource öğesini bu şekilde parametre olarak göstermenizi öneririz. Bu, durum yükseltme en iyi uygulamalarını izlemenin yanı sıra, bir bileşenin görsel durumunu başka herhangi bir tür durumun (ör. etkin olma durumu) okunup kontrol edilebildiği gibi okumayı ve kontrol etmeyi de kolaylaştırır.

Compose, katmanlı bir mimari yaklaşımı uygular. Böylece üst düzey Material bileşenleri, dalgaları ve diğer görsel efektleri kontrol etmek için gereken Interaction'leri üreten temel yapı taşlarının üzerine inşa edilir. Temel kitaplığı Modifier.hoverable, Modifier.focusable ve Modifier.draggable gibi üst düzey etkileşim değiştiriciler sunar.

Fareyle öğelerin üzerine gelerek gerçekleşen etkinliklere yanıt veren bir bileşen oluşturmak için Modifier.hoverable kullanabilir ve parametre olarak bir MutableInteractionSource iletebilirsiniz. Bileşenin üzerine gelindiğinde HoverInteraction sn. yayar. Bileşenin görünümünü değiştirmek için bunu kullanabilirsiniz.

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

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

Bu bileşeni odaklanılabilir hale getirmek için Modifier.focusable öğesini ekleyebilir ve aynı MutableInteractionSource öğesini parametre olarak iletebilirsiniz. Artık hem HoverInteraction.Enter/Exit hem de FocusInteraction.Focus/Unfocus aynı MutableInteractionSource üzerinden yayınlanır ve her iki etkileşim türünün görünümünü aynı yerde özelleştirebilirsiniz:

// 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, hoverable ve focusable soyutlamalarından daha üst düzey bir soyutlamadır. Bir bileşenin tıklanabilir olması için örtülü bir şekilde üzerine gelebilmesi ve tıklanabilen bileşenlere de odaklanılabilmesi gerekir. Alt düzey API'leri birleştirmeye gerek kalmadan fareyle üzerine gelme, odaklama ve basın etkileşimlerini işleyen bir bileşen oluşturmak için Modifier.clickable kullanabilirsiniz. Bileşeninizi de tıklanabilir yapmak isterseniz hoverable ve focusable öğelerini clickable ile değiştirebilirsiniz:

// 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!")
}

InteractionSource ile çalışın

Bir bileşenle olan etkileşimler hakkında alt düzey bilgilere ihtiyacınız varsa söz konusu bileşenin InteractionSource öğesi için standart akış API'lerini kullanabilirsiniz. Örneğin, bir InteractionSource için basma ve sürükleme işlemlerinin listesini tutmak istediğinizi varsayalım. Bu kod işin yarısını yapar ve yeni baskıları listeye ekler.

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)
            }
        }
    }
}

Ancak, yeni etkileşimleri eklemenin yanı sıra sona erdiğinde (örneğin, kullanıcı parmağını bileşenden geri çektiğinde) etkileşimleri de kaldırmanız gerekir. Son etkileşimler her zaman ilişkili başlangıç etkileşimine bir referans taşıdığından, bunu yapmak kolaydır. Bu kod, sona eren etkileşimleri nasıl kaldıracağınızı gösterir:

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)
            }
        }
    }
}

Şimdi, bileşene o anda basıldığını veya sürüklendiğini öğrenmek istiyorsanız yapmanız gereken tek şey interactions öğesinin boş olup olmadığını kontrol etmektir:

val isPressedOrDragged = interactions.isNotEmpty()

En son etkileşimin ne olduğunu öğrenmek istiyorsanız listedeki son öğeye bakın. Örneğin, Oluştur dalgası uygulaması, en son etkileşim için kullanılacak uygun durum yer paylaşımını şu şekilde belirler:

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

Tüm Interaction'ler aynı yapıyı izlediğinden farklı türde kullanıcı etkileşimi türleriyle çalışırken kodda pek bir fark yoktur ve genel kalıp aynıdır.

Bu bölümde yer alan önceki örneklerin, State kullanan etkileşimlerin Flow'sini temsil ettiğini unutmayın. Durum değerinin okunması otomatik olarak yeniden derlemelere neden olacağı için bu, güncellenmiş değerleri gözlemlemeyi kolaylaştırır. Ancak beste, çerçeveden önce toplu hale getirilir. Bu, durumun değişmesi ve ardından tekrar aynı çerçeve içinde değişmesi halinde durumu gözlemleyen bileşenlerin değişikliği görmeyeceği anlamına gelir.

Etkileşimler düzenli olarak aynı çerçeve içinde başlayıp bitebileceğinden, etkileşimler için bu önemlidir. Örneğin, önceki örneği Button ile birlikte kullanarak:

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

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

Basın basınca aynı çerçeve içinde başlar ve biterse metin hiçbir zaman "Basıldı!" olarak görüntülenmez. Çoğu durumda bu bir sorun değildir. Görsel bir efektin bu kadar kısa bir süre için gösterilmesi titremesine yol açar ve kullanıcı tarafından çok fark edilmez. Dalga efekti veya benzer bir animasyon gösterme gibi bazı durumlarda, düğmeye artık basılmadığında hemen durmak yerine efekti en azından minimum bir süre boyunca göstermek isteyebilirsiniz. Bunu yapmak için bir duruma yazmak yerine animasyonları doğrudan Collect lambda'nın içinden başlatabilir ve durdurabilirsiniz. Bu kalıbın bir örneğini Animasyonlu kenarlıkla gelişmiş bir Indication oluşturma bölümünde bulabilirsiniz.

Örnek: Özel etkileşim işlemeyle bileşen oluşturma

Giriş için özel bir yanıtla bileşenleri nasıl derleyebileceğinizi görmek için değiştirilmiş düğme örneğini aşağıda bulabilirsiniz. Bu durumda, görünümü değiştirerek basmalara yanıt veren bir düğme istediğinizi varsayalım:

Tıklandığında dinamik olarak market sepeti simgesi ekleyen bir düğmenin animasyonu
Şekil 3. Tıklandığında dinamik olarak simge ekleyen bir düğme.

Bunu yapmak için Button temelinde özel bir composable oluşturun ve simgeyi çizmek için ek bir icon parametresi (bu örnekte bir alışveriş sepeti) almasını sağlayın. Kullanıcının düğmenin üzerine gelip gelmediğini takip etmek için collectIsPressedAsState() yöntemini çağırırsınız. Düğmenin üzerine geldiğinde ise simge eklersiniz. Kod aşağıdaki gibi görünür:

@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()
    }
}

İşte yeni composable'ı kullanmak şöyle:

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

Bu yeni PressIconButton, mevcut MateryalButton temel alınarak oluşturulduğundan, kullanıcı etkileşimlerine her zamanki gibi tepki verir. Kullanıcı düğmeye bastığında, sıradan bir Malzeme Button olduğu gibi opaklığı da biraz değişir.

Indication ile yeniden kullanılabilir bir özel efekt oluşturup uygulayın

Önceki bölümlerde, basıldığında bir simge gösterilmesi gibi farklı Interaction öğelerine yanıt olarak bileşenin bir bölümünün nasıl değiştirileceğini öğrenmiştiniz. Aynı yaklaşım, bir bileşene sağladığınız parametrelerin değerini veya bir bileşen içinde görüntülenen içeriği değiştirmek için kullanılabilir ancak bu yalnızca her bileşen için geçerlidir. Çoğu zaman bir uygulama veya tasarım sistemi, durum bilgili görsel efektler için genel bir sisteme sahiptir. Bu efekt, tüm bileşenlere tutarlı bir şekilde uygulanması gerekir.

Bu tür bir tasarım sistemi oluşturuyorsanız aşağıdaki nedenlerle bir bileşeni özelleştirmek ve diğer bileşenler için bu özelleştirmeyi yeniden kullanmak zor olabilir:

  • Tasarım sistemindeki tüm bileşenler için aynı ortak metin gereklidir
  • Bu efekti yeni oluşturulan bileşenlere ve özel tıklanabilir bileşenlere uygulamak kolaydır.
  • Özel efekti diğer efektlerle birleştirmek zor olabilir

Bu sorunları önlemek ve sisteminiz genelinde bir özel bileşeni kolayca ölçeklendirmek için Indication kullanabilirsiniz. Indication, bir uygulama veya tasarım sistemindeki bileşenlere uygulanabilen, yeniden kullanılabilir bir görsel efekti temsil eder. Indication iki bölüme ayrılır:

  • IndicationNodeFactory: Bir bileşen için görsel efektler oluşturan Modifier.Node örnekleri oluşturan bir fabrikadır. Bileşenler arasında değişmeyen daha basit uygulamalar için bu, bir tekil (nesne) olabilir ve uygulamanın tamamında yeniden kullanılabilir.

    Bu örnekler durum bilgili veya durum bilgisiz olabilir. Bileşen başına oluşturulduklarından, diğer Modifier.Node öğelerinde olduğu gibi belirli bir bileşenin içinde görünme veya davranış biçimlerini değiştirmek için bir CompositionLocal öğesinden değerler alabilirler.

  • Modifier.indication: Bir bileşen için Indication çizen bir değiştirici. Modifier.clickable ve diğer üst düzey etkileşim değiştiricileri, bir gösterge parametresini doğrudan kabul eder. Böylece, yalnızca Interaction yaymakla kalmaz, aynı zamanda saldıkları Interaction'lar için görsel efektler de oluşturabilirler. Yani basit durumlarda, Modifier.indication eklentisine gerek kalmadan Modifier.clickable kullanabilirsiniz.

Efekti bir Indication ile değiştir

Bu bölümde, belirli bir düğmeye uygulanan manuel ölçeklendirme efektinin, birden fazla bileşende yeniden kullanılabilecek bir gösterim eşdeğeriyle nasıl değiştirileceği açıklanmaktadır.

Aşağıdaki kod, basıldığında aşağı ölçeklenen bir düğme oluşturur:

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")
}

Yukarıdaki snippet'te bulunan ölçek efektini Indication biçimine dönüştürmek için şu adımları uygulayın:

  1. Ölçek efektini uygulamadan sorumlu olan Modifier.Node öğesini oluşturun. Düğüm, bağlandığında önceki örneklere benzer şekilde etkileşim kaynağını gözlemler. Buradaki tek fark, gelen Etkileşimleri duruma dönüştürmek yerine animasyonları doğrudan başlatmasıdır.

    Düğümün, ContentDrawScope#draw() değerini geçersiz kılabilmesi ve Oluşturma'daki diğer grafik API'leriyle aynı çizim komutlarını kullanarak ölçek efekti oluşturabilmesi için DrawModifierNode'i uygulaması gerekir.

    ContentDrawScope alıcısından kullanılabilir drawContent() çağrısı, Indication öğesinin uygulanması gereken asıl bileşeni çizer. Bu yüzden, bu işlevi bir ölçek dönüşümü içinde çağırmanız gerekir. Indication uygulamalarınızın bir noktada her zaman drawContent() çağrısı yaptığından emin olun. Aksi takdirde, Indication kodunu uyguladığınız bileşen çizilmez.

    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. IndicationNodeFactory oluşturun. Tek sorumluluğu, sağlanan etkileşim kaynağı için yeni bir düğüm örneği oluşturmaktır. Göstergeyi yapılandırmak için herhangi bir parametre olmadığından fabrika bir nesne olabilir:

    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, dahili olarak Modifier.indication kullanır. Bu nedenle ScaleIndication ile tıklanabilir bir bileşen oluşturmak için tek yapmanız gereken Indication öğesini clickable öğesine parametre olarak sağlamaktır:

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

    Bu, özel bir Indication kullanarak yeniden kullanılabilir üst düzey bileşenler oluşturmayı da kolaylaştırır. Düğme şuna benzeyebilir:

    @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
        )
    }

Daha sonra, düğmeyi aşağıdaki şekilde kullanabilirsiniz:

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

Basıldığında küçülen alışveriş sepeti simgesi içeren düğme animasyonu
Şekil 4. Özel bir Indication ile oluşturulmuş bir düğme.

Animasyonlu kenarlıkla gelişmiş bir Indication oluşturun

Indication, bileşeni ölçeklendirme gibi dönüştürme efektleriyle sınırlı değildir. IndicationNodeFactory bir Modifier.Node döndürdüğünden, diğer çizim API'lerinde olduğu gibi içeriğin üstüne veya altına herhangi bir efekt çizebilirsiniz. Örneğin, bileşenin etrafına animasyonlu bir kenarlık ve bileşen basıldığında üst kısmında bir yer paylaşımı çizebilirsiniz:

Basıldığında süslü gökkuşağı efektli bir düğme
Şekil 5. Indication ile çizilmiş animasyonlu kenarlık efekti.

Buradaki Indication uygulaması, önceki örneğe çok benzemektedir. Yalnızca bazı parametrelere sahip bir düğüm oluşturulur. Animasyonlu kenarlık, Indication öğesinin kullanıldığı bileşenin şekline ve kenarlığına bağlı olduğundan, Indication uygulaması şekil ve kenarlık genişliğinin parametre olarak sağlanmasını da gerektirir:

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
        )
    }
}

Çizim kodu daha karmaşık olsa bile Modifier.Node uygulaması da kavramsal olarak aynıdır. Daha önce olduğu gibi, eklendiğinde InteractionSource gözlemlenir, animasyonları başlatır ve içeriğin üzerine efekti çizmek için DrawModifierNode uygular:

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
    }
}

Buradaki temel fark, animateToResting() işleviyle animasyon için artık minimum bir sürenin olmasıdır. Böylece, basın hemen serbest bırakılsa bile basın animasyonu devam eder. animateToPressed öğesinin başında birden fazla hızlı basma işlemi de vardır. Mevcut bir basma veya bekleme animasyonu sırasında bir basma işlemi gerçekleşirse önceki animasyon iptal edilir ve basın animasyonu baştan başlar. Birden fazla eşzamanlı efekti desteklemek için (ör. yeni bir dalga animasyonu, diğer dalgaların üzerine yeni bir dalga animasyonu çizildiğinde), mevcut animasyonları iptal edip yenilerini başlatmak yerine animasyonları bir listede izleyebilirsiniz.