Umgang mit Nutzerinteraktionen

Komponenten der Benutzeroberfläche geben dem Gerätenutzer Feedback, indem sie auf Nutzerinteraktionen reagieren. Jede Komponente reagiert auf ihre eigene Weise auf Interaktionen. So weiß der Nutzer, was seine Interaktionen bewirken. 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 die Schaltfläche berührt hat. Wenn der Nutzer das nicht wollte, weiß er, dass er seinen Finger von der Schaltfläche wegziehen muss, bevor er ihn loslässt. Andernfalls wird die Schaltfläche aktiviert.

Abbildung 1. Schaltflächen, die immer aktiviert sind und bei denen kein Welleneffekt angezeigt wird.
Abbildung 2. Buttons mit Welleneffekten, die den aktivierten Status widerspiegeln.

In der Compose-Dokumentation zu Touch-Gesten wird beschrieben, wie Compose-Komponenten Zeigerereignisse auf niedriger Ebene verarbeiten, z. B. Zeigerbewegungen und Klicks. Compose abstrahiert diese Low-Level-Ereignisse standardmäßig in High-Level-Interaktionen. Beispielsweise kann eine Reihe von Zeigerereignissen zu einem Drücken und Loslassen einer Schaltfläche führen. Wenn Sie diese übergeordneten Abstraktionen verstehen, können Sie die Reaktion Ihrer Benutzeroberfläche auf den Nutzer anpassen. Sie können beispielsweise anpassen, wie sich das Erscheinungsbild einer Komponente ändert, wenn der Nutzer mit ihr interagiert, oder Sie möchten einfach nur ein Protokoll dieser Nutzeraktionen führen. In diesem Dokument finden Sie die Informationen, die Sie benötigen, um die Standard-UI-Elemente zu ändern oder eigene zu entwerfen.

Interaktionen

In vielen Fällen müssen Sie nicht wissen, wie Ihre Compose-Komponente Nutzerinteraktionen interpretiert. Button verwendet beispielsweise Modifier.clickable, um festzustellen, ob der Nutzer auf die Schaltfläche geklickt hat. Wenn Sie Ihrer App eine typische Schaltfläche hinzufügen, können Sie 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 reagiert, indem der onClick-Code ausgeführt wird.

Wenn Sie jedoch die Reaktion Ihrer UI-Komponente auf das Nutzerverhalten anpassen möchten, müssen Sie möglicherweise mehr darüber wissen, was im Hintergrund passiert. In diesem Abschnitt finden Sie einige dieser Informationen.

Wenn ein Nutzer mit einer UI-Komponente interagiert, stellt das System sein Verhalten dar, indem es eine Reihe von Interaction-Ereignissen generiert. Wenn ein Nutzer beispielsweise eine Schaltfläche berührt, generiert die Schaltfläche PressInteraction.Press. Wenn der Nutzer den Finger innerhalb der Schaltfläche hebt, wird ein PressInteraction.Release generiert, um der Schaltfläche mitzuteilen, dass der Klick abgeschlossen wurde. Wenn der Nutzer seinen Finger jedoch außerhalb der Schaltfläche zieht und dann den Finger hebt, generiert die Schaltfläche PressInteraction.Cancel, um anzugeben, dass das Drücken der Schaltfläche abgebrochen und nicht abgeschlossen wurde.

Diese Interaktionen sind unvoreingenommen. Das bedeutet, dass diese Low-Level-Interaktionsereignisse nicht die Bedeutung der Nutzeraktionen oder deren Reihenfolge interpretieren sollen. Außerdem wird nicht interpretiert, welche Nutzeraktionen Vorrang vor anderen Aktionen haben könnten.

Diese Interaktionen treten in der Regel paarweise auf, mit einem Anfang und einem Ende. Die zweite Interaktion enthält einen Verweis auf die erste. Wenn ein Nutzer beispielsweise eine Schaltfläche berührt und dann den Finger hebt, wird durch die Berührung eine PressInteraction.Press-Interaktion und durch das Loslassen eine PressInteraction.Release-Interaktion generiert. Die Release hat ein press-Attribut, das die ursprüngliche PressInteraction.Press identifiziert.

Sie können die Interaktionen für eine bestimmte Komponente anhand ihrer InteractionSource sehen. InteractionSource basiert auf Kotlin-Flows. Sie können die Interaktionen also auf dieselbe Weise erfassen wie bei jedem anderen Flow. Weitere Informationen zu dieser Designentscheidung finden Sie im Blogpost Illuminating Interactions.

Interaktionsstatus

Sie können die integrierten Funktionen Ihrer Komponenten erweitern, indem Sie die Interaktionen selbst erfassen. Beispiel: Sie möchten, dass sich die Farbe einer Schaltfläche ändert, wenn sie gedrückt wird. Die einfachste Möglichkeit, die Interaktionen zu verfolgen, besteht darin, den entsprechenden Interaktions-Status zu beobachten. InteractionSource bietet eine Reihe von Methoden, die verschiedene Interaktionsstatus als Status anzeigen. Wenn du beispielsweise wissen möchtest, ob eine bestimmte Taste gedrückt wird, kannst du die Methode InteractionSource.collectIsPressedAsState() 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 Compose auch collectIsFocusedAsState(), collectIsDraggedAsState() und collectIsHoveredAsState(). Diese Methoden sind eigentlich einfache Methoden, die auf untergeordneten InteractionSource-APIs basieren. In einigen Fällen möchten Sie diese untergeordneten Funktionen möglicherweise direkt verwenden.

Angenommen, Sie müssen wissen, ob eine Schaltfläche gedrückt und ob sie gezogen wird. Wenn Sie sowohl collectIsPressedAsState() als auch collectIsDraggedAsState() verwenden, führt Compose viele doppelte Aufgaben aus und es gibt keine Garantie, dass Sie alle Interaktionen in der richtigen Reihenfolge erhalten. In solchen Situationen ist es möglicherweise sinnvoll, direkt mit dem InteractionSource zu arbeiten. Weitere Informationen zum selbstständigen Erfassen von Interaktionen mit InteractionSource finden Sie unter Mit InteractionSource arbeiten.

Im folgenden Abschnitt wird beschrieben, wie Sie Interaktionen mit InteractionSource bzw. MutableInteractionSource nutzen und ausgeben.

Interaction nutzen und ausgeben

InteractionSource stellt einen schreibgeschützten Stream von Interactions dar. Es ist nicht möglich, ein Interaction an ein InteractionSource auszugeben. Zum Ausgeben von Interactions müssen Sie einen MutableInteractionSource verwenden, der von InteractionSource abgeleitet ist.

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

Beispiel für einen konsumierenden Modifikator

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

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

Aus der Funktionssignatur geht hervor, dass dieser Modifikator ein Consumer ist. Er kann Interactions verarbeiten, aber nicht ausgeben.

Beispiel für einen Produktionsmodifikator

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

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

Dieser Modifikator ist ein Producer. Er kann die bereitgestellte MutableInteractionSource verwenden, um HoverInteractions auszugeben, wenn der Mauszeiger darauf bewegt oder davon wegbewegt wird.

Komponenten erstellen, die Daten nutzen und erzeugen

Allgemeine Komponenten wie ein Material-Button fungieren sowohl als Producer als auch als Consumer. Sie verarbeiten Eingabe- und Fokussierungsereignisse und ändern ihr Erscheinungsbild als Reaktion auf diese Ereignisse, z. B. durch Anzeigen eines Welleneffekts oder Animieren ihrer Höhe. Daher wird MutableInteractionSource direkt als Parameter bereitgestellt, sodass Sie Ihre eigene gespeicherte Instanz angeben 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 der MutableInteractionSource aus der Komponente hochgezogen werden und alle von der Komponente erzeugten Interactions können beobachtet werden. Damit können Sie das Erscheinungsbild dieser Komponente oder einer anderen Komponente in Ihrer Benutzeroberfläche steuern.

Wenn Sie eigene interaktive Komponenten auf höherer Ebene erstellen, empfehlen wir, MutableInteractionSource auf diese Weise als Parameter verfügbar zu machen. Neben der Einhaltung der Best Practices für das Status-Hoisting wird dadurch auch der visuelle Status einer Komponente auf die gleiche Weise wie jede andere Art von Status (z. B. der Aktivierungsstatus) gelesen und gesteuert.

Compose folgt einem mehrschichtigen Architekturansatz. Daher werden übergeordnete Material-Komponenten auf grundlegenden Bausteinen aufgebaut, die die Interactions erzeugen, die sie zur Steuerung von Welleneffekten und anderen visuellen Effekten benötigen. Die Foundation-Bibliothek 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. Damit können Sie das Erscheinungsbild der Komponente ändern.

// 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 auch 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 und Sie können das Erscheinungsbild 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 Abstraktionsebene als hoverable und focusable. Damit eine Komponente anklickbar ist, muss sie implizit mit dem Mauszeiger berührbar sein. Komponenten, die angeklickt werden können, sollten auch fokussierbar sein. Mit Modifier.clickable können Sie eine Komponente erstellen, die Interaktionen wie Bewegen des Mauszeigers, Fokussieren und Drücken verarbeitet, ohne APIs auf niedrigerer Ebene kombinieren zu müssen. Wenn Sie Ihre Komponente auch anklickbar machen möchten, können Sie hoverable und focusable durch ein 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 detaillierte Informationen zu Interaktionen mit einer Komponente benötigen, können Sie die Standard-Flow-APIs für die InteractionSource dieser Komponente verwenden. Angenommen, Sie möchten eine Liste der Drück- und Ziehinteraktionen für ein InteractionSource verwalten. Dieser Code erledigt die Hälfte der Arbeit und fügt die neuen Tastendrücke der Liste hinzu, sobald sie eingehen:

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

Neben dem Hinzufügen der neuen Interaktionen müssen Sie auch Interaktionen entfernen, wenn sie enden (z. B. wenn der Nutzer den Finger von der Komponente nimmt). Das ist ganz einfach, da die Endinteraktionen immer einen Verweis auf die zugehörige 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 jetzt wissen möchten, ob die Komponente gerade gedrückt oder gezogen wird, müssen Sie nur prüfen, ob interactions leer ist:

val isPressedOrDragged = interactions.isNotEmpty()

Wenn Sie wissen möchten, was die letzte Interaktion war, sehen Sie sich einfach das letzte Element in der Liste an. So ermittelt die Compose-Ripple-Implementierung beispielsweise das geeignete 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 dieselbe Struktur haben, gibt es beim Arbeiten mit verschiedenen Arten von Nutzerinteraktionen kaum Unterschiede im Code. Das allgemeine Muster ist dasselbe.

Die vorherigen Beispiele in diesem Abschnitt stellen die Flow von Interaktionen mit State dar. Dadurch lassen sich aktualisierte Werte leicht beobachten, da das Lesen des Zustandswerts automatisch zu Neuzusammensetzungen führt. Die Komposition wird jedoch vor dem Frame im Batch ausgeführt. Das bedeutet, dass Komponenten, die den Status beobachten, die Änderung nicht sehen, wenn sich der Status ändert und dann innerhalb desselben Frames wieder zurückändert.

Das ist wichtig für Interaktionen, da diese regelmäßig innerhalb desselben Frames beginnen und enden können. 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 innerhalb desselben Frames beginnt und endet, wird der Text nie als „Gedrückt!“ angezeigt. In den meisten Fällen ist das kein Problem. Wenn ein visueller Effekt für so kurze Zeit angezeigt wird, führt das zu einem Flackern und ist für den Nutzer nicht sehr auffällig. In einigen Fällen, z. B. beim Anzeigen eines Welleneffekts oder einer ähnlichen Animation, möchten Sie den Effekt möglicherweise für eine Mindestdauer anzeigen, anstatt ihn sofort zu beenden, wenn die Taste nicht mehr gedrückt wird. Dazu können Sie Animationen direkt im Collect-Lambda starten und beenden, anstatt in einen Status zu schreiben. Ein Beispiel für dieses Muster finden Sie im Abschnitt Erweiterte Indication mit animiertem Rahmen erstellen.

Beispiel: Komponente mit benutzerdefinierter Interaktionsverarbeitung erstellen

Hier ist ein Beispiel für einen geänderten Button, um zu zeigen, wie Sie Komponenten mit einer benutzerdefinierten Antwort auf Eingaben erstellen können. In diesem Fall möchten Sie eine Schaltfläche, die auf Drücken reagiert, indem sie ihr Aussehen ändert:

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

Erstelle dazu eine benutzerdefinierte komponierbare Funktion auf Basis von Button und füge einen zusätzlichen icon-Parameter hinzu, um das Symbol (in diesem Fall einen Einkaufswagen) zu zeichnen. Sie rufen collectIsPressedAsState() auf, um zu prüfen, ob der Nutzer den Mauszeiger über die Schaltfläche bewegt. Wenn ja, fügen Sie das Symbol hinzu. Der Code sieht so 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 die Verwendung dieser neuen komponierbaren Funktion aus:

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

Da diese neue PressIconButton auf dem vorhandenen Material Button basiert, reagiert sie auf Nutzerinteraktionen wie gewohnt. Wenn der Nutzer auf die Schaltfläche drückt, ändert sich die Deckkraft leicht, genau wie bei einem normalen Material-Button.

Wiederverwendbaren benutzerdefinierten Effekt mit Indication erstellen und anwenden

In den vorherigen Abschnitten haben Sie gelernt, wie Sie einen Teil einer Komponente als Reaktion auf verschiedene Interactions ändern, z. B. ein Symbol anzeigen, wenn die Komponente gedrückt wird. Mit demselben Ansatz können Sie den Wert von Parametern ändern, die Sie einer Komponente bereitstellen, oder den Inhalt ändern, der in einer Komponente angezeigt wird. Dies gilt jedoch nur für einzelne Komponenten. Häufig hat eine Anwendung oder ein Designsystem ein generisches System für zustandsorientierte visuelle Effekte – ein Effekt, der auf alle Komponenten einheitlich 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 Boilerplate-Code.
  • Es kann leicht passieren, dass Sie vergessen, diesen Effekt auf neu erstellte Komponenten und benutzerdefinierte anklickbare Komponenten anzuwenden.
  • Es kann schwierig sein, den benutzerdefinierten Effekt mit anderen Effekten zu kombinieren.

Um diese Probleme zu vermeiden und eine benutzerdefinierte Komponente einfach in Ihrem System zu skalieren, können Sie Indication verwenden. Indication stellt einen wiederverwendbaren visuellen Effekt dar, 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 über Komponenten hinweg ändern, kann dies ein Singleton (Objekt) sein und in der gesamten Anwendung wiederverwendet werden.

    Diese Instanzen können zustandsorientiert oder zustandslos sein. Da sie pro Komponente erstellt werden, können sie Werte aus einem CompositionLocal abrufen, um ihr Aussehen oder Verhalten innerhalb einer bestimmten Komponente zu ändern, wie bei jedem anderen Modifier.Node.

  • Modifier.indication: Ein Modifikator, der Indication für eine Komponente zeichnet. Modifier.clickable und andere Modifikatoren für Interaktionen auf höherer Ebene akzeptieren einen Indikationsparameter direkt. Sie geben also nicht nur Interactions aus, sondern können auch visuelle Effekte für die ausgegebenen Interactions zeichnen. In einfachen Fällen können Sie also einfach Modifier.clickable verwenden, ohne Modifier.indication zu benötigen.

Effekt durch Indication ersetzen

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

Mit dem folgenden Code wird ein Button erstellt, der beim Drücken verkleinert 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 einen Indication:

  1. Erstellen Sie den Modifier.Node, der für die Anwendung des Skalierungseffekts verantwortlich ist. Wenn der Knoten angehängt ist, beobachtet er die Interaktionsquelle, ähnlich wie in den vorherigen Beispielen. Der einzige Unterschied besteht darin, dass Animationen direkt gestartet werden, anstatt die eingehenden Interaktionen in einen Status umzuwandeln.

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

    Wenn Sie drawContent() über den Empfänger ContentDrawScope aufrufen, wird die tatsächliche Komponente gezeichnet, auf die Indication angewendet werden soll. Sie müssen diese Funktion also nur innerhalb einer Skalierungstransformation aufrufen. Achten Sie darauf, dass Ihre Indication-Implementierungen immer irgendwann drawContent() aufrufen. Andernfalls wird die Komponente, auf die Sie Indication anwenden, 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. Wenn Sie also eine anklickbare Komponente mit ScaleIndication erstellen möchten, müssen Sie nur 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)
    }

    So lassen sich auch ganz einfach wiederverwendbare Komponenten auf höherer Ebene mit einem benutzerdefinierten Indication erstellen. 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 so verwenden:

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

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

Erweiterte Indication mit animiertem Rahmen erstellen

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

Eine Schaltfläche mit einem schicken Regenbogeneffekt beim Drücken
Abbildung 5. Ein animierter Rahmeneffekt, der mit Indication gezeichnet wird.

Die Indication-Implementierung hier ist dem vorherigen Beispiel sehr ähnlich. Es wird nur ein Knoten mit einigen Parametern erstellt. Da der animierte Rahmen von der Form und dem Rahmen der Komponente abhängt, für die Indication verwendet wird, müssen bei der Implementierung von Indication auch die Form und die 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 konzeptionell identisch, auch wenn der Zeichencode komplizierter ist. Wie zuvor wird InteractionSource beobachtet, wenn es angehängt ist, Animationen werden gestartet und DrawModifierNode wird implementiert, um den Effekt über dem Inhalt zu zeichnen:

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. Selbst wenn der Nutzer die Taste sofort loslässt, wird die Animation fortgesetzt. Es gibt auch eine Verarbeitung für mehrere schnelle Drückvorgänge am Anfang von animateToPressed. Wenn ein Drückvorgang während eines vorhandenen Drückvorgangs oder einer Ruheanimation erfolgt, wird die vorherige Animation abgebrochen und die Drückanimation beginnt von vorn. Wenn Sie mehrere gleichzeitige Effekte unterstützen möchten (z. B. bei Wellen, bei denen eine neue Wellenanimation über anderen Wellen gezeichnet wird), können Sie die Animationen in einer Liste verfolgen, anstatt vorhandene Animationen abzubrechen und neue zu starten.