Umgang mit Nutzerinteraktionen

Komponenten der Benutzeroberfläche geben dem Gerätenutzer Feedback darüber, wie sie auf Nutzerinteraktionen reagieren. Jede Komponente reagiert auf ihre eigene Art und Weise auf Interaktionen, sodass der Nutzer weiß, was seine Interaktionen tun. Wenn ein Nutzer beispielsweise eine Schaltfläche auf dem Touchscreen eines Geräts berührt, ändert sich die Schaltfläche wahrscheinlich in irgendeiner Weise, z. B. durch Hinzufügen einer Hervorhebungsfarbe. Durch diese Änderung weiß der Nutzer, dass er auf die Schaltfläche geklickt hat. Wenn der Nutzer das nicht tun wollte, wird er den Finger von der Schaltfläche wegziehen, bevor er sie wieder loslässt. Andernfalls wird die Schaltfläche aktiviert.

Abbildung 1: Schaltflächen, die immer aktiviert sind, ohne die Ripple drücken zu müssen.
Abbildung 2. Schaltflächen mit Ripples, die den aktivierten Status entsprechend widerspiegeln.

In der Dokumentation zu Touch-Gesten wird beschrieben, wie Composer-Komponenten Low-Level-Zeigerereignisse wie Zeigerbewegungen und Klicks verarbeiten. Diese untergeordneten Ereignisse werden standardmäßig in übergeordnete Interaktionen abstrahiert. So kann eine Reihe von Zeigerereignissen zum Drücken und Loslassen der Schaltfläche hinzugefügt werden. Wenn Sie diese übergeordneten Abstraktionen verstehen, können Sie anpassen, wie Ihre UI auf den Nutzer reagiert. Sie können beispielsweise anpassen, wie sich die Darstellung einer Komponente ändert, wenn der Nutzer mit ihr interagiert, oder ein Protokoll dieser Nutzeraktionen führen. Dieses Dokument enthält die Informationen, die Sie benötigen, um die Standard-UI-Elemente anzupassen oder eigene zu entwerfen.

Interaktionen

In vielen Fällen müssen Sie nicht wissen, wie die Komponente „Compose“ Nutzerinteraktionen interpretiert. Button stützt sich beispielsweise auf Modifier.clickable, um herauszufinden, ob der Nutzer auf die Schaltfläche geklickt hat. Wenn du eine typische Schaltfläche zu deiner App hinzufügst, kannst du den onClick-Code der Schaltfläche definieren. Modifier.clickable führt diesen Code dann bei Bedarf aus. Sie müssen also nicht wissen, ob der Nutzer auf den Bildschirm getippt oder die Schaltfläche mit einer Tastatur ausgewählt hat. Modifier.clickable erkennt, dass der Nutzer einen Klick ausgeführt hat, und antwortet, indem er Ihren onClick-Code ausführt.

Wenn Sie jedoch die Reaktion der UI-Komponente auf das Nutzerverhalten anpassen möchten, müssen Sie möglicherweise mehr darüber wissen, was im Hintergrund passiert. Dieser Abschnitt enthält einige dieser Informationen.

Wenn ein Nutzer mit einer UI-Komponente interagiert, repräsentiert das System sein Verhalten, indem eine Reihe von Interaction-Ereignissen generiert wird. Wenn ein Nutzer beispielsweise eine Schaltfläche berührt, wird über die Schaltfläche PressInteraction.Press generiert. Wenn der Nutzer den Finger von der Schaltfläche hebt, wird ein PressInteraction.Release generiert. Dadurch wird die Schaltfläche darüber informiert, dass der Klick abgeschlossen ist. Wenn der Nutzer hingegen seinen Finger außerhalb der Schaltfläche zieht und dann wieder loshebt, wird für die Schaltfläche PressInteraction.Cancel generiert. Das bedeutet, dass das Drücken der Schaltfläche abgebrochen, aber nicht abgeschlossen wurde.

Diese Interaktionen sind ohne Meinung. Das heißt, diese Interaktionen auf niedriger Ebene haben nicht die Absicht, die Bedeutung der Nutzeraktionen oder deren Reihenfolge zu interpretieren. Sie interpretieren auch nicht, welche Nutzeraktionen Vorrang vor anderen Aktionen haben könnten.

Diese Interaktionen erfolgen in der Regel paarweise, jeweils mit einem Anfang und einem Ende. Die zweite Interaktion enthält einen Verweis auf die erste. Wenn ein Nutzer beispielsweise auf eine Schaltfläche tippt und dann seinen Finger hebt, wird durch die Berührung eine PressInteraction.Press-Interaktion generiert und durch den Release wird ein PressInteraction.Release generiert. Release hat eine press-Eigenschaft, die die Anfangs-PressInteraction.Press identifiziert.

Sie können die Interaktionen für eine bestimmte Komponente sehen, indem Sie deren InteractionSource beobachten. InteractionSource basiert auf Kotlin-Abläufen. Sie können also die Interaktionen daraus wie bei jedem anderen Ablauf erfassen. Weitere Informationen zu dieser Designentscheidung finden Sie im Blogpost Illuminating Interactions (auf Englisch).

Interaktionsstatus

Sie können die integrierte Funktionalität Ihrer Komponenten erweitern, indem Sie auch die Interaktionen selbst erfassen. Vielleicht möchten Sie z. B., dass sich die Farbe einer Schaltfläche ändert, wenn sie gedrückt wird. Die einfachste Möglichkeit zum Verfolgen der Interaktionen besteht darin, den entsprechenden Interaktionsstatus zu beobachten. InteractionSource bietet eine Reihe von Methoden, mit denen verschiedene Interaktionsstatus als Status angezeigt werden. Wenn Sie beispielsweise sehen möchten, ob eine bestimmte Schaltfläche gedrückt wurde, können Sie die entsprechende InteractionSource.collectIsPressedAsState()-Methode aufrufen:

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

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

Neben collectIsPressedAsState() bietet die Funktion collectIsFocusedAsState(), collectIsDraggedAsState() und collectIsHoveredAsState(). Diese Methoden sind praktische Methoden, die auf untergeordneten InteractionSource APIs basieren. In einigen Fällen kann es sinnvoll sein, diese untergeordneten Funktionen direkt zu verwenden.

Angenommen, Sie müssen auch wissen, ob eine Schaltfläche gedrückt wird und ob sie gezogen wird. Wenn Sie sowohl collectIsPressedAsState() als auch collectIsDraggedAsState() verwenden, werden bei der Funktion „Compose“ viele doppelte Arbeitsschritte ausgeführt und es kann nicht garantiert werden, dass alle Interaktionen in der richtigen Reihenfolge angezeigt werden. In solchen Fällen können Sie direkt mit der InteractionSource arbeiten. Weitere Informationen zum Tracking der Interaktionen mit InteractionSource finden Sie unter Mit InteractionSource arbeiten.

Im folgenden Abschnitt wird beschrieben, wie Interaktionen mit InteractionSource bzw. MutableInteractionSource aufgenommen und ausgegeben werden.

Interaction abrufen und ausgeben

InteractionSource stellt einen schreibgeschützten Stream von Interactions dar. Ein Interaction kann nicht an ein InteractionSource gesendet werden. Zum Ausgeben von Interactions müssen Sie eine MutableInteractionSource verwenden, die über InteractionSource erweitert wird.

Modifikatoren und Komponenten können Interactions verarbeiten, ausgeben oder ausgeben und ausgeben. In den folgenden Abschnitten wird beschrieben, wie Interaktionen von Modifikatoren und Komponenten übernommen und ausgegeben werden.

Beispiel für einen Konsummodifikator

Bei einem Modifikator, der einen Rahmen für den fokussierten Zustand zeichnet, müssen Sie nur Interactions beobachten, sodass Sie ein InteractionSource akzeptieren können:

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

Aus der Funktionssignatur geht klar hervor, dass dieser Modifikator ein Nutzer ist. Er kann Interactions verbrauchen, aber nicht ausgeben.

Beispiel für produzierenden Modifikator

Für einen Modifikator, der Hover-Ereignisse wie Modifier.hoverable verarbeitet, müssen Sie Interactions ausgeben und stattdessen einen MutableInteractionSource als Parameter akzeptieren:

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

Dieser Modifikator ist ein Producer. Er kann den angegebenen MutableInteractionSource verwenden, um HoverInteractions auszugeben, wenn der Mauszeiger darauf bewegt wird oder nicht.

Komponenten erstellen, die

Allgemeine Komponenten wie ein Material-Button fungieren sowohl als Ersteller als auch als Nutzer. Sie verarbeiten Eingabe- und Fokusereignisse und ändern auch ihr Aussehen als Reaktion auf diese Ereignisse, z. B. das Einblenden von Wellen oder das Animieren von Höhenprofilen. Daher wird MutableInteractionSource direkt als Parameter verfügbar gemacht, sodass Sie Ihre eigene gespeicherte Instanz bereitstellen können:

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

Dadurch kann die MutableInteractionSource aus der Komponente gezogen und alle von der Komponente erzeugten Interactions beobachtet werden. Damit lässt sich die Darstellung dieser Komponente oder einer anderen Komponente auf der UI steuern.

Wenn Sie Ihre eigenen interaktiven übergeordneten Komponenten erstellen, empfehlen wir, MutableInteractionSource auf diese Weise als Parameter verfügbar zu machen. Abgesehen von den Best Practices für das Hochheben des Zustands ist dies auch das Lesen und Steuern des visuellen Zustands einer Komponente auf die gleiche Weise, wie jede andere Art von Status (z. B. aktivierter Zustand) gelesen und gesteuert werden kann.

Compose folgt einem mehrschichtigen Architekturansatz. Übergeordnete Material-Komponenten werden also auf grundlegenden Bausteinen aufgebaut, aus denen die Interactions erzeugt werden, die sie zum Steuern von Wellen und anderen visuellen Effekten benötigen. Die Basisbibliothek bietet allgemeine Interaktionsmodifikatoren wie Modifier.hoverable, Modifier.focusable und Modifier.draggable.

Wenn Sie eine Komponente erstellen möchten, die auf Hover-Ereignisse reagiert, können Sie einfach Modifier.hoverable verwenden und einen MutableInteractionSource als Parameter übergeben. Wenn der Mauszeiger auf die Komponente bewegt wird, gibt sie HoverInteractions aus, mit denen Sie die Darstellung der Komponente ändern können.

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

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

Wenn Sie diese Komponente außerdem fokussierbar machen möchten, können Sie Modifier.focusable hinzufügen und dieselbe MutableInteractionSource als Parameter übergeben. Jetzt werden sowohl HoverInteraction.Enter/Exit als auch FocusInteraction.Focus/Unfocus über dieselbe MutableInteractionSource ausgegeben. Sie können die Darstellung für beide Arten von Interaktionen an derselben Stelle anpassen:

// 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 ist eine noch höhere Abstraktion als hoverable und focusable. Damit eine Komponente anklickbar ist, muss der Mauszeiger darüber bewegt werden. Komponenten, auf die geklickt werden kann, sollten ebenfalls fokussierbar sein. Mit Modifier.clickable können Sie eine Komponente erstellen, die Interaktionen mit dem Mauszeiger verarbeitet, fokussiert und betätigt, ohne untergeordnete APIs kombinieren zu müssen. Wenn die Komponente auch anklickbar sein soll, können Sie hoverable und focusable durch clickable ersetzen:

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

Mit InteractionSource arbeiten

Wenn Sie allgemeine Informationen zu Interaktionen mit einer Komponente benötigen, können Sie standardmäßige Flow APIs für die InteractionSource dieser Komponente verwenden. Angenommen, Sie möchten eine Liste der Presse- und Drag-Interaktionen für ein InteractionSource verwalten. Dieser Code erledigt die Hälfte der Arbeit, d. h., die neuen Vorgänge werden der Liste hinzugefügt:

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

Allerdings müssen Sie nicht nur die neuen Interaktionen hinzufügen, sondern auch Interaktionen am Ende entfernen (z. B. wenn der Nutzer den Finger von der Komponente hebt). Das ist einfach, da die Endinteraktionen immer einen Verweis auf die verknüpfte Startinteraktion enthalten. Dieser Code zeigt, wie Sie die beendeten Interaktionen entfernen:

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

Wenn Sie wissen möchten, ob die Komponente gerade gedrückt oder gezogen wird, müssen Sie lediglich prüfen, ob interactions leer ist:

val isPressedOrDragged = interactions.isNotEmpty()

Wenn Sie wissen möchten, was die letzte Interaktion war, schauen Sie sich einfach das letzte Element in der Liste an. Die Ripple-Implementierung von „Compose“ findet beispielsweise das passende Status-Overlay für die letzte Interaktion:

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

Da alle Interactions derselben Struktur folgen, gibt es keinen großen Unterschied im Code, wenn Sie mit verschiedenen Arten von Nutzerinteraktionen arbeiten. Das Muster ist insgesamt gleich.

Die vorherigen Beispiele in diesem Abschnitt stellen die Flow von Interaktionen mit State dar. So lassen sich aktualisierte Werte leichter beobachten, da das Lesen des Statuswerts automatisch zu Neuzusammensetzungen führt. Die Zusammensetzung erfolgt jedoch als Batch für den Pre-Frame. Wenn sich also der Status ändert und innerhalb desselben Frames wieder zurückkehrt, ist die Änderung für Komponenten, die den Status beobachten, nicht sichtbar.

Dies ist für Interaktionen wichtig, da Interaktionen regelmäßig im selben Frame beginnen und enden können. Hier ein Beispiel vom vorherigen Beispiel mit Button:

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

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

Wenn ein Drücken im selben Frame beginnt und endet, wird der Text nie als „Gepresst!“ angezeigt. In den meisten Fällen ist das kein Problem. Wenn für einen so kurzen Zeitraum ein visueller Effekt angezeigt wird, kommt es zu Flimmern und ist für den Nutzer nicht sehr wahrnehmbar. In einigen Fällen, z. B. bei der Darstellung eines Welleneffekts oder einer ähnlichen Animation, kann es sinnvoll sein, den Effekt mindestens für einen minimalen Zeitraum zu zeigen, anstatt die Wiedergabe sofort zu stoppen, wenn Sie nicht mehr auf die Schaltfläche drücken. Dazu können Sie Animationen direkt aus dem Collect-Lambda heraus starten und stoppen, anstatt in einen Zustand zu schreiben. Ein Beispiel für dieses Muster finden Sie im Abschnitt Erweitertes Indication mit animiertem Rahmen erstellen.

Beispiel: Build-Komponente mit benutzerdefinierter Interaktionsverwaltung

Im Folgenden finden Sie ein Beispiel für eine geänderte Schaltfläche, um zu sehen, wie Sie Komponenten mit einer benutzerdefinierten Eingabeantwort erstellen können. Angenommen, Sie möchten eine Schaltfläche, die durch Ändern ihres Aussehens auf Drücken reagiert:

Animation einer Schaltfläche, die beim Anklicken dynamisch ein Einkaufswagensymbol hinzufügt
Abbildung 3: Eine Schaltfläche, die beim Anklicken dynamisch ein Symbol hinzufügt.

Erstellen Sie dazu eine benutzerdefinierte zusammensetzbare Funktion basierend auf Button und legen Sie fest, dass ein zusätzlicher icon-Parameter benötigt wird, um das Symbol zu zeichnen (in diesem Fall ein Einkaufswagen). Sie rufen collectIsPressedAsState() auf, um zu erfassen, ob der Nutzer den Mauszeiger auf die Schaltfläche bewegt. Ist dies der Fall, fügen Sie das Symbol hinzu. So sieht der Code aus:

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

Und so sieht es aus, wenn Sie diese neue zusammensetzbare Funktion verwenden:

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

Da dieses neue PressIconButton auf dem bestehenden Material Button basiert, reagiert es wie gewohnt auf Nutzerinteraktionen. Wenn der Nutzer auf die Schaltfläche drückt, ändert sich die Deckkraft leicht, wie bei einem gewöhnlichen Material-Button.

Mit Indication einen wiederverwendbaren benutzerdefinierten Effekt erstellen und anwenden

In den vorherigen Abschnitten haben Sie erfahren, wie Sie einen Teil einer Komponente in Reaktion auf verschiedene Interactions ändern, z. B. wenn beim Drücken ein Symbol angezeigt wird. Auf die gleiche Weise können Sie den Wert von Parametern, die Sie für eine Komponente angeben, oder den in einer Komponente angezeigten Inhalt ändern. Dies gilt jedoch nur für einzelne Komponenten. Häufig hat eine Anwendung oder ein Designsystem ein generisches System für zustandsorientierte visuelle Effekte – einen Effekt, der einheitlich auf alle Komponenten angewendet werden sollte.

Wenn Sie ein solches Designsystem erstellen, kann es aus folgenden Gründen schwierig sein, eine Komponente anzupassen und diese Anpassung für andere Komponenten wiederzuverwenden:

  • Jede Komponente im Designsystem benötigt denselben Textbaustein
  • Es kann leicht vergessen werden, diesen Effekt auf neu erstellte Komponenten und benutzerdefinierte anklickbare Komponenten anzuwenden.
  • Es kann schwierig sein, den benutzerdefinierten Effekt mit anderen Effekten zu kombinieren

Mit Indication können Sie diese Probleme vermeiden und eine benutzerdefinierte Komponente auf Ihrem gesamten System problemlos skalieren. Indication steht für einen wiederverwendbaren visuellen Effekt, der auf Komponenten in einer Anwendung oder einem Designsystem angewendet werden kann. Indication ist in zwei Teile unterteilt:

  • IndicationNodeFactory: Eine Factory, die Modifier.Node-Instanzen erstellt, die visuelle Effekte für eine Komponente rendern. Bei einfacheren Implementierungen, die sich nicht komponentenübergreifend ändern, kann dies ein Singleton (Objekt) sein, das für die gesamte Anwendung wiederverwendet wird.

    Diese Instanzen können zustandsorientiert oder zustandslos sein. Da sie pro Komponente erstellt werden, können sie Werte aus einer CompositionLocal abrufen, um ihre Darstellung oder ihr Verhalten in einer bestimmten Komponente wie bei jeder anderen Modifier.Node-Komponente zu ändern.

  • Modifier.indication: Ein Modifikator, der Indication für eine Komponente zieht. Modifier.clickable und andere Interaktionsmodifikatoren auf hoher Ebene akzeptieren einen Anzeigeparameter direkt. Sie geben also nicht nur Interactions aus, sondern können auch visuelle Effekte für die von ihnen ausgegebenen Interactions darstellen. Für einfache Fälle können Sie also einfach Modifier.clickable verwenden, ohne Modifier.indication zu benötigen.

Effekt durch ein Indication ersetzen

In diesem Abschnitt wird beschrieben, wie ein manueller Skalierungseffekt, der auf eine bestimmte Schaltfläche angewendet wurde, durch ein Indikationsäquivalent ersetzt wird, das für mehrere Komponenten wiederverwendet werden kann.

Mit dem folgenden Code wird eine Schaltfläche erstellt, die beim Drücken nach unten skaliert wird:

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

So konvertieren Sie den Skalierungseffekt im Snippet oben in ein Indication:

  1. Erstellen Sie das Modifier.Node, mit dem der Skalierungseffekt angewendet wird. Wenn der Knoten verbunden ist, beobachtet er die Interaktionsquelle, ähnlich wie in den vorherigen Beispielen. Der einzige Unterschied besteht darin, dass Animationen direkt gestartet und nicht die eingehenden Interaktionen in den Status konvertiert werden.

    Der Knoten muss DrawModifierNode implementieren, damit er ContentDrawScope#draw() überschreiben und einen Skalierungseffekt mit denselben Zeichenbefehlen wie jede andere Grafik-API in Compose rendern kann.

    Wenn Sie drawContent() aufrufen, die vom ContentDrawScope-Empfänger verfügbar ist, wird die eigentliche Komponente gezeichnet, auf die die Indication angewendet werden soll. Sie müssen diese Funktion also nur innerhalb einer Skalierungstransformation aufrufen. Achte darauf, dass deine Indication-Implementierungen an irgendeinem Punkt immer drawContent() aufrufen. Andernfalls wird die Komponente, auf die du Indication anwendest, nicht gezeichnet.

    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. Erstellen Sie die IndicationNodeFactory. Seine einzige Aufgabe besteht darin, eine neue Knoteninstanz für eine bereitgestellte Interaktionsquelle zu erstellen. Da es keine Parameter zum Konfigurieren der Anzeige gibt, kann die Factory ein Objekt sein:

    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 verwendet intern Modifier.indication. Um eine anklickbare Komponente mit ScaleIndication zu erstellen, müssen Sie lediglich Indication als Parameter für clickable angeben:

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

    Dies vereinfacht auch das Erstellen wiederverwendbarer übergeordneter Komponenten mit einem benutzerdefinierten Indication – eine Schaltfläche könnte so aussehen:

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

Sie können die Schaltfläche dann wie folgt verwenden:

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

Eine Animation einer Schaltfläche mit einem Einkaufswagensymbol, die beim Drücken kleiner wird
Abbildung 4: Eine mit einem benutzerdefinierten Indication erstellte Schaltfläche.

Erweiterte Indication mit animiertem Rahmen erstellen

Indication ist nicht nur auf Transformationseffekte wie die Skalierung einer Komponente beschränkt. Da IndicationNodeFactory ein Modifier.Node zurückgibt, können Sie wie bei anderen Zeichen-APIs beliebige Effekte über oder unter dem Inhalt zeichnen. Sie können beispielsweise einen animierten Rahmen um die Komponente und ein Overlay auf der Komponente zeichnen, wenn sie gedrückt wird:

Eine Taste mit einem schicken Regenbogeneffekt beim Drücken
Abbildung 5: Ein animierter Rahmeneffekt, der mit Indication gezeichnet wurde.

Die Indication-Implementierung ist dem vorherigen Beispiel sehr ähnlich. Sie erstellt lediglich einen Knoten mit einigen Parametern. Da der animierte Rahmen von der Form und dem Rahmen der Komponente abhängt, für die Indication verwendet wird, müssen für die Indication-Implementierung auch Form und Rahmenbreite als Parameter angegeben werden:

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

Die Modifier.Node-Implementierung ist außerdem vom Konzept her identisch, auch wenn der Zeichencode komplizierter ist. Wie zuvor beobachtet sie InteractionSource, wenn sie angehängt wird, startet Animationen und implementiert DrawModifierNode, um den Effekt über den Inhalt zu ziehen:

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

Der Hauptunterschied besteht darin, dass es jetzt eine Mindestdauer für die Animation mit der Funktion animateToResting() gibt. Das heißt, selbst wenn das Drücken der Taste sofort loslässt, wird die Animation fortgesetzt. Auch mehrere schnelles Drücken wird zu Beginn von animateToPressed ausgeführt. Wenn ein Drücken während einer vorhandenen „Betätigen“- oder „Pause“-Animation erfolgt, wird die vorherige Animation abgebrochen und die Drücken-Animation von vorn beginnen. Um mehrere Effekte gleichzeitig zu unterstützen (z. B. bei Wellen, bei denen eine neue Wellenanimation auf anderen Wellen gezeichnet wird), können Sie die Animationen in einer Liste verfolgen, anstatt vorhandene Animationen zu verwerfen und neue zu starten.