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 verme şekli farklıdır. Bu sayede kullanıcılar, etkileşimlerinin ne işe yaradığını bilir. Örneğin, bir kullanıcı cihazın dokunmatik ekranındaki bir düğmeye dokunduğunda düğme, vurgu rengi eklenerek değişebilir. Bu değişiklik, kullanıcının düğmeye dokunduğunu anlamasını sağlar. Kullanıcı bu işlemi yapmak istemiyorsa parmağını düğmeden uzaklaştırarak bırakması gerektiğini bilir. Aksi takdirde düğme etkinleşir.

Şekil 1. Basıldığında dalgalanma efekti olmadan her zaman etkin görünen düğmeler.
Şekil 2. Etkin durumlarını yansıtan, basıldığında dalgalanma efekti oluşan düğmeler.

Compose Hareketleri dokümanında, Compose bileşenlerinin işaretçi hareketleri ve tıklamalar gibi düşük seviyeli işaretçi etkinliklerini nasıl işlediği açıklanmaktadır. Compose, kullanıma hazır olarak bu düşük düzeyli etkinlikleri daha yüksek düzeyli etkileşimlere dönüştürür. Örneğin, bir dizi işaretçi etkinliği, bir düğmeye basma ve bırakma işlemine karşılık gelebilir. Bu üst düzey soyutlamaları anlamak, kullanıcı arayüzünüzün kullanıcıya nasıl yanıt vereceğini özelleştirmenize yardımcı olabilir. Örneğin, kullanıcının bir bileşenle etkileşime girdiğinde bileşenin görünümünün nasıl değişeceğini özelleştirmek veya yalnızca bu kullanıcı işlemlerinin günlüğünü tutmak isteyebilirsiniz. Bu belge, standart kullanıcı arayüzü öğelerini değiştirmeniz veya kendi öğelerinizi tasarlamanız için gereken bilgileri sağlar.

Etkileşimler

Çoğu durumda, Compose bileşeninizin kullanıcı etkileşimlerini nasıl yorumladığını bilmeniz gerekmez. Örneğin, Button, kullanıcının düğmeyi tıklayıp tıklamadığını anlamak için Modifier.clickable'e dayanır. Uygulamanıza normal bir düğme ekliyorsanız düğmenin onClick kodunu tanımlayabilirsiniz ve Modifier.clickable uygun olduğunda bu kodu çalıştırır. Yani 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 işlemi gerçekleştirdiğini anlar ve onClick kodunuzu çalıştırarak yanıt verir.

Ancak kullanıcı arayüzü bileşeninizin kullanıcı davranışına verdiği yanıtı özelleştirmek istiyorsanız arka planda neler olup bittiği hakkında daha fazla bilgi edinmeniz gerekebilir. Bu bölümde, bu bilgilerden bazıları verilmektedir.

Bir kullanıcı bir kullanıcı arayüzü bileşeniyle etkileşimde bulunduğunda sistem, kullanıcının davranışını bir dizi Interaction etkinliği oluşturarak temsil eder. Örneğin, bir kullanıcı düğmeye dokunduğunda düğme PressInteraction.Press oluşturur. Kullanıcı parmağını düğmenin içinde kaldırırsa PressInteraction.Release oluşturulur. Bu, düğmenin tıklamanın tamamlandığını anlamasını sağlar. Diğer yandan, kullanıcı parmağını düğmenin dışına sürükleyip kaldırırsa düğmeye basma işleminin tamamlanmadığını, iptal edildiğini belirtmek için PressInteraction.Cancel oluşturulur.

Bu etkileşimler tarafsızdır. Yani bu düşük düzeyli etkileşim etkinlikleri, kullanıcı işlemlerinin anlamını veya sırasını yorumlamayı amaçlamaz. Ayrıca, hangi kullanıcı işlemlerinin diğer işlemlerden daha öncelikli olabileceğini de yorumlamazlar.

Bu etkileşimler genellikle bir başlangıç ve bir bitişle birlikte çiftler halinde gelir. İkinci etkileşim, ilk etkileşime referans içerir. Örneğin, bir kullanıcı bir düğmeye dokunup parmağını kaldırdığında dokunma işlemi PressInteraction.Press etkileşimi, bırakma işlemi ise PressInteraction.Release etkileşimi oluşturur. Release, ilk PressInteraction.Press'yi tanımlayan bir press özelliğine sahiptir.

Belirli bir bileşenin etkileşimlerini, InteractionSource gözlemleyerek görebilirsiniz. InteractionSource, Kotlin akışlarının üzerine kurulmuştur. Bu nedenle, diğer akışlarla çalıştığınız gibi etkileşimleri toplayabilirsiniz. Bu tasarım kararı hakkında daha fazla bilgi için Illuminating Interactions blog yayınını inceleyin.

Etkileşim durumu

Etkileşimleri kendiniz de izleyerek bileşenlerinizin yerleşik işlevselliğini genişletmek isteyebilirsiniz. Örneğin, bir düğmenin basıldığında renk 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 bir dizi yöntem sunar. Örneğin, belirli bir düğmenin basılıp basılmadığını görmek istiyorsanız 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")
}

Oluşturma, collectIsPressedAsState() dışında collectIsFocusedAsState(), collectIsDraggedAsState() ve collectIsHoveredAsState() özelliklerini de sunar. Bu yöntemler aslında daha düşük düzeydeki InteractionSource API'leri üzerine inşa edilmiş kolaylık yöntemleridir. Bazı durumlarda, bu alt düzey işlevleri doğrudan kullanmak isteyebilirsiniz.

Örneğin, bir düğmenin 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 Oluşturma çok fazla tekrarlayan iş yapar ve tüm etkileşimleri doğru sırada alacağınız garanti edilmez. Bu gibi durumlarda doğrudan InteractionSource ile çalışmak isteyebilirsiniz. InteractionSource ile etkileşimleri kendiniz izleme hakkında daha fazla bilgi için InteractionSource ile çalışma başlıklı makaleyi inceleyin.

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

Tüketme ve yayınlama Interaction

InteractionSource, Interactions öğesinin salt okunur bir akışını temsil eder. InteractionSource öğesine Interaction göndermek mümkün değildir. Interaction yayınlamak için InteractionSource'den uzanan bir MutableInteractionSource kullanmanız gerekir.

Değiştiriciler ve bileşenler Interactions kullanabilir, yayınlayabilir veya kullanıp yayınlayabilir. Aşağıdaki bölümlerde hem değiştiricilerden hem de bileşenlerden etkileşimlerin nasıl tüketileceği ve yayınlanacağı açıklanmaktadır.

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

Odaklanılmış durum için kenarlık çizen bir değiştirici için yalnızca Interactions değerini gözlemlemeniz gerekir. Bu nedenle, InteractionSource değerini kabul edebilirsiniz:

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

Bu değiştiricinin bir tüketici olduğu, işlev imzasından anlaşılmaktadır. Bu değiştirici Interaction tüketebilir ancak yayımlayamaz.

Üretim değiştiricisi ö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. Fareyle üzerine gelindiğinde veya üzerinden ayrıldığında HoverInteractions yaymak için sağlanan MutableInteractionSource öğesini kullanabilir.

Tüketen ve üreten bileşenler oluşturma

Malzeme Button gibi üst düzey bileşenler hem üretici hem de tüketici olarak işlev görür. Giriş ve odak etkinliklerini işlerler ve bu etkinliklere yanıt olarak görünümlerini de değiştirirler (ör. dalgalanma gösterir veya yükseltilerini canlandırırlar). Bu nedenle, MutableInteractionSource öğesini doğrudan parametre olarak kullanıma sunarlar. Böylece, hatırlanan kendi örneğinizi sağlayabilirsiniz:

@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 yükseltilmesine ve bileşen tarafından üretilen tüm Interaction öğelerinin gözlemlenmesine olanak tanır. Bu özelliği, söz konusu bileşenin veya kullanıcı arayüzünüzdeki diğer bileşenlerin görünümünü kontrol etmek için kullanabilirsiniz.

Kendi etkileşimli üst düzey bileşenlerinizi oluşturuyorsanız MutableInteractionSource öğesini bu şekilde parametre olarak kullanmanızı öneririz. Bu, durum yükseltme ile ilgili en iyi uygulamaları takip etmenin yanı sıra bir bileşenin görsel durumunun, diğer durum türleri (ör. etkin durum) gibi okunmasını ve kontrol edilmesini de kolaylaştırır.

Compose, katmanlı bir mimari yaklaşım izler. Bu nedenle, üst düzey Material bileşenleri, dalgalanmaları ve diğer görsel efektleri kontrol etmek için gereken Interaction değerlerini üreten temel yapı taşlarının üzerine inşa edilir. Temel kitaplık, Modifier.hoverable, Modifier.focusable ve Modifier.draggable gibi üst düzey etkileşim değiştiriciler sağlar.

Fareyle üzerine gelme etkinliklerine yanıt veren bir bileşen oluşturmak için Modifier.hoverable öğesini kullanıp parametre olarak MutableInteractionSource öğesini iletmeniz yeterlidir. Fareyle üzerine gelindiğinde bileşen HoverInteraction yayar. Bunu, bileşenin görünümünü değiştirmek için 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şenin odaklanılabilir olmasını sağlamak için Modifier.focusable ekleyebilir ve aynı MutableInteractionSource öğesini parametre olarak iletebilirsiniz. Artık hem HoverInteraction.Enter/Exit hem de FocusInteraction.Focus/Unfocus aynı MutableInteractionSource üzerinden yayınlanıyor ve her iki etkileşim türünün görünümünü aynı yerden özelleştirebiliyorsunuz:

// 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'den daha yüksek bir soyutlama düzeyidir. Bir bileşenin tıklanabilir olması için üzerine gelinmesi gerekir ve tıklanabilen bileşenler odaklanılabilir olmalıdır. Alt düzey API'leri birleştirmenize gerek kalmadan, fareyle üzerine gelme, odaklanma ve basma etkileşimlerini işleyen bir bileşen oluşturmak için Modifier.clickable kullanabilirsiniz. Bileşeninizi de tıklanabilir hale getirmek istiyorsanız hoverable ve focusable yerine clickable kullanabilirsiniz:

// 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ışma

Bir bileşenle etkileşimler hakkında düşük düzeyli bilgilere ihtiyacınız varsa bu bileşenin InteractionSource için standart akış API'lerini kullanabilirsiniz. Örneğin, bir InteractionSource için basma ve sürükleme etkileşimlerinin 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 etkileşimleri sona erdiklerinde (örneğin, kullanıcı parmağını bileşenden kaldırdığında) kaldırmanız da gerekir. Bu işlem kolaydır. Çünkü son etkileşimler her zaman ilişkili başlangıç etkileşimine referans verir. 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)
            }
        }
    }
}

Bileşenin şu anda basılı tutulup tutulmadığını veya sürüklenip sürüklenmediğini öğrenmek için interactions öğesinin boş olup olmadığını kontrol etmeniz yeterlidir:

val isPressedOrDragged = interactions.isNotEmpty()

En son etkileşimin ne olduğunu öğrenmek istiyorsanız listedeki son öğeye bakmanız yeterlidir. Örneğin, Compose ripple uygulaması, en son etkileşim için kullanılacak uygun durum katmanı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ı kullanıcı etkileşimi türleriyle çalışırken kodda pek bir fark yoktur. Genel kalıp aynıdır.

Bu bölümdeki önceki örneklerin, State kullanılarak yapılan etkileşimlerin Flow değerini temsil ettiğini unutmayın. Bu sayede, durum değeri okunduğunda otomatik olarak yeniden oluşturma gerçekleşeceğinden güncellenen değerleri gözlemlemek kolaylaşır. Ancak kompozisyon, kare öncesinde toplu olarak yapılır. Bu, durum değişip aynı kare içinde tekrar değişirse durumu gözlemleyen bileşenlerin değişikliği görmeyeceği anlamına gelir.

Etkileşimler düzenli olarak aynı karede başlayıp sona erebileceğinden bu durum etkileşimler için önemlidir. Örneğin, Button ile önceki örneği kullanma:

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

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

Bir basma işlemi aynı kare içinde başlar ve biterse metin hiçbir zaman "Basıldı!" olarak gösterilmez. Çoğu durumda bu bir sorun değildir. Bu kadar kısa bir süre için görsel efekt göstermek titremeye neden olur ve kullanıcı tarafından pek fark edilmez. Bazı durumlarda (ör. dalgalanma efekti veya benzer bir animasyon gösterme) düğmeye basılmayı bırakıldığında hemen durdurmak yerine efekti en az minimum süre boyunca göstermek isteyebilirsiniz. Bunu yapmak için, bir duruma yazmak yerine doğrudan collect lambda'nın içinden animasyonları başlatıp durdurabilirsiniz. Bu kalıbın bir örneğini Animasyonlu kenarlığa sahip gelişmiş bir Indication oluşturma bölümünde bulabilirsiniz.

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

Girişe özel yanıt veren bileşenleri nasıl oluşturabileceğinizi görmek için değiştirilmiş bir düğme örneğini inceleyin. Bu durumda, görünümünü değiştirerek basma işlemine yanıt veren bir düğme istediğinizi varsayalım:

Tıklandığında dinamik olarak alışveriş 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 (bu örnekte alışveriş sepeti) çizmek için ek bir icon parametresi almasını sağlayın. Kullanıcının düğmenin üzerine gelip gelmediğini izlemek için collectIsPressedAsState() işlevini çağırırsınız. Kullanıcı düğmenin üzerine geldiğinde simgeyi eklersiniz. Kod şu şekilde 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()
    }
}

Bu yeni composable'ı kullanmak şu şekilde görünür:

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

Bu yeni PressIconButton, mevcut Material'ın üzerine inşa edildiğinden Button, kullanıcı etkileşimlerine her zamanki gibi tepki verir. Kullanıcı düğmeye bastığında, normal bir Materyal Button gibi opaklığı biraz değişir.

Indication ile yeniden kullanılabilir özel efekt oluşturma ve uygulama

Önceki bölümlerde, bir bileşenin bir kısmını farklı Interaction'lara yanıt olarak nasıl değiştireceğinizi (ör. basıldığında bir simge gösterme) öğrenmiştiniz. Aynı yaklaşım, bir bileşene sağladığınız parametrelerin değerini değiştirmek veya bir bileşenin içinde görüntülenen içeriği değiştirmek için de kullanılabilir ancak bu yalnızca bileşen bazında geçerlidir. Bir uygulama veya tasarım sistemi genellikle durum bilgisi içeren görsel efektler için genel bir sisteme sahiptir. Bu efekt, tüm bileşenlere tutarlı bir şekilde uygulanmalıdır.

Bu tür bir tasarım sistemi oluşturuyorsanız bir bileşeni özelleştirip bu özelleştirmeyi diğer bileşenlerde yeniden kullanmak aşağıdaki nedenlerden dolayı zor olabilir:

  • Tasarım sistemindeki her bileşen aynı standart metni gerektirir.
  • Bu efekti yeni oluşturulan bileşenlere ve özel tıklanabilir bileşenlere uygulamayı unutmak kolaydır.
  • Özel efekti diğer efektlerle birleştirmek zor olabilir.

Bu sorunları önlemek ve özel bir bileşeni sisteminizde kolayca ölçeklendirmek için Indication kullanabilirsiniz. Indication, bir uygulamadaki 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şenin görsel efektlerini oluşturmak için Modifier.Node örnekleri oluşturan bir fabrika. Bileşenler arasında değişmeyen daha basit uygulamalar için bu, tekil (nesne) olabilir ve uygulamanın tamamında yeniden kullanılabilir.

    Bu örnekler durum bilgili veya durum bilgisi olmayan olabilir. Her bileşen için oluşturulduklarından, diğer Modifier.Node'lerde olduğu gibi, belirli bir bileşende görünme veya davranma şekillerini değiştirmek için CompositionLocal'den değer 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ştiriciler, doğrudan bir gösterge parametresini kabul eder. Bu nedenle, yalnızca Interaction'ler yaymakla kalmaz, aynı zamanda yaydıkları Interaction'ler için görsel efektler de çizebilirler. Bu nedenle, basit durumlarda Modifier.indication'ye gerek kalmadan yalnızca Modifier.clickable kullanabilirsiniz.

Efekti Indication ile değiştirme

Bu bölümde, belirli bir düğmeye uygulanan manuel ölçek efekti yerine birden fazla bileşende yeniden kullanılabilen eşdeğer bir göstergenin nasıl kullanılacağı açıklanmaktadır.

Aşağıdaki kod, basıldığında aşağı doğru ö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'teki ölçek efektini Indication simgesine dönüştürmek için aşağıdaki adımları uygulayın:

  1. Ölçek efektini uygulamaktan sorumlu Modifier.Node oluşturun. Eklendiğinde, önceki örneklerde olduğu gibi düğüm etkileşim kaynağını gözlemler. Buradaki tek fark, gelen etkileşimleri duruma dönüştürmek yerine doğrudan animasyonları başlatmasıdır.

    Düğümün, DrawModifierNode öğesini geçersiz kılabilmesi ve Compose'daki diğer grafik API'lerinde olduğu gibi aynı çizim komutlarını kullanarak ölçek efekti oluşturabilmesi için ContentDrawScope#draw() öğesini uygulaması gerekir.

    ContentDrawScope alıcısından kullanılabilen drawContent() işlevi, Indication öğesinin uygulanması gereken gerçek bileşeni çizer. Bu nedenle, bu işlevi yalnızca bir ölçek dönüştürme içinde çağırmanız gerekir. Indication uygulamalarınızın her zaman bir noktada drawContent() çağırdığından emin olun. Aksi takdirde, Indication 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 bir etkileşim kaynağı için yeni bir düğüm örneği oluşturmaktır. Göstergeyi yapılandıracak parametreler 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 kullandığından ScaleIndication ile tıklanabilir bir bileşen oluşturmak için Indication öğesini clickable için parametre olarak sağlamanız yeterlidir:

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

    Bu sayede, özel bir Indication kullanarak üst düzeyde, yeniden kullanılabilir bileşenler oluşturmak da kolaylaşır. Örneğin, bir düğme şu şekilde görünebilir:

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

Ardından, düğmeyi aşağıdaki şekilde kullanabilirsiniz:

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

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

Animasyonlu kenarlığa sahip gelişmiş bir Indication oluşturma

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

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

Buradaki Indication uygulaması, önceki örneğe çok benzer. Yalnızca bazı parametrelerle bir düğüm oluşturur. Animasyonlu kenarlık, Indication için kullanılan bileşenin şekline ve kenarlığına bağlı olduğundan Indication uygulamasında şekil ve kenarlık genişliğinin parametre olarak sağlanması da gerekir:

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ı kavramsal olarak aynıdır. Daha önce olduğu gibi, InteractionSource eklenirken gözlemlenir, animasyonlar başlatılır ve DrawModifierNode uygulanarak efekt içeriğin üzerine çizilir:

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 animasyonun artık minimum süresinin olmasıdır. Bu nedenle, basma işlemi hemen bırakılsa bile basma animasyonu devam eder. Ayrıca, animateToPressed başlangıcında birden fazla hızlı basma işlemi için de işlem yapılıyor. Mevcut bir basma veya dinlenme animasyonu sırasında basma işlemi gerçekleşirse önceki animasyon iptal ediliyor ve basma animasyonu baştan başlıyor. Aynı anda birden fazla efekti desteklemek için (ör. dalgalanma efektinde yeni bir dalgalanma animasyonu diğer dalgalanmaların üzerine çizilir) mevcut animasyonları iptal edip yenilerini başlatmak yerine animasyonları bir listede izleyebilirsiniz.