Komponenten der Benutzeroberfläche geben dem Nutzer des Geräts Feedback, indem sie auf Nutzerinteraktionen reagieren. Jede Komponente reagiert auf ihre eigene Weise auf Interaktionen, sodass der Nutzer weiß, was durch seine Interaktionen passiert. 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 Akzentfarbe. Durch diese Änderung wird dem Nutzer angezeigt, dass er den Button berührt hat. Wenn der Nutzer das nicht möchte, kann er seinen Finger vom Button wegziehen, bevor er ihn loslässt. Andernfalls wird der Button aktiviert.
In der Dokumentation zu Compose-Gesten wird beschrieben, wie Compose-Komponenten Low-Level-Zeigerereignisse wie Zeigerbewegungen und Klicks verarbeiten. In Compose werden diese Low-Level-Ereignisse standardmäßig in Interaktionen auf höherer Ebene abstrahiert. So kann beispielsweise eine Reihe von Zeigerereignissen zu einem Drücken und Loslassen einer Schaltfläche führen. Wenn Sie diese Abstraktionen auf höherer Ebene verstehen, können Sie anpassen, wie Ihre Benutzeroberfläche auf den Nutzer reagiert. Sie möchten 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 zum Ändern der Standard-UI-Elemente oder zum Entwerfen eigener Elemente benötigen.
Interaktionen
In vielen Fällen müssen Sie nicht wissen, wie Ihre Compose-Komponente Nutzerinteraktionen interpretiert. Button verwendet beispielsweise Modifier.clickable, um herauszufinden, 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 geklickt hat, und reagiert darauf, indem der onClick-Code ausgeführt wird.
Wenn Sie jedoch die Reaktion der UI-Komponente auf das Nutzerverhalten anpassen möchten, müssen Sie möglicherweise mehr über die Vorgänge im Hintergrund wissen. In diesem Abschnitt finden Sie einige dieser Informationen.
Wenn ein Nutzer mit einer UI-Komponente interagiert, wird sein Verhalten vom System durch eine Reihe von Interaction-Ereignissen dargestellt. Wenn ein Nutzer beispielsweise auf eine Schaltfläche tippt, wird PressInteraction.Press generiert.
Wenn der Nutzer den Finger innerhalb der Schaltfläche anhebt, wird ein PressInteraction.Release generiert, das der Schaltfläche signalisiert, dass der Klick beendet ist. Wenn der Nutzer seinen Finger dagegen außerhalb der Schaltfläche zieht und dann anhebt, generiert die Schaltfläche PressInteraction.Cancel, um anzugeben, dass das Drücken der Schaltfläche abgebrochen und nicht abgeschlossen wurde.
Diese Interaktionen sind neutral. Das heißt, diese Low-Level-Interaktionsereignisse sollen nicht die Bedeutung der Nutzeraktionen oder deren Reihenfolge interpretieren. Außerdem wird nicht interpretiert, welche Nutzeraktionen möglicherweise Vorrang vor anderen Aktionen haben.
Diese Interaktionen erfolgen in der Regel paarweise, mit einem Start 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 wieder anhebt, wird durch die Berührung die Interaktion PressInteraction.Press und durch das Anheben die Interaktion PressInteraction.Release ausgelöst. Das Release hat eine press-Eigenschaft, die das ursprüngliche PressInteraction.Press identifiziert.
Sie können die Interaktionen für eine bestimmte Komponente sehen, indem Sie ihre InteractionSource beobachten. 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
Möglicherweise möchten Sie die integrierte Funktionalität Ihrer Komponenten erweitern, indem Sie die Interaktionen selbst erfassen. Vielleicht soll sich die Farbe einer Schaltfläche ändern, wenn sie gedrückt wird. Die einfachste Möglichkeit, die Interaktionen zu erfassen, besteht darin, den entsprechenden Interaktionsstatus zu beobachten. InteractionSource bietet eine Reihe von Methoden, die verschiedene Interaktionsstatus als Status zurückgeben. Wenn Sie beispielsweise prüfen möchten, ob eine bestimmte Schaltfläche gedrückt wurde, können Sie 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 Convenience-Methoden, die auf InteractionSource-APIs auf niedrigerer Ebene basieren. In einigen Fällen möchten Sie diese Funktionen auf niedrigerer Ebene möglicherweise direkt verwenden.
Angenommen, Sie müssen wissen, ob eine Schaltfläche gedrückt wird 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 Fällen sollten Sie sich direkt an InteractionSource wenden. Weitere Informationen zum selbstständigen Erfassen der 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. Wenn Sie Interactions ausgeben möchten, 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 Sie Interaktionen sowohl von Modifizierern als auch von Komponenten empfangen und senden.
Beispiel für einen verbrauchenden Modifikator
Für einen Modifier, der einen Rahmen für den fokussierten Zustand zeichnet, müssen Sie nur Interactions beobachten. Sie können also ein InteractionSource akzeptieren:
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 die Erstellung von Modifikatoren
Für einen Modifier, der Hover-Ereignisse wie Modifier.hoverable verarbeitet, müssen Sie Interactions ausgeben und stattdessen MutableInteractionSource als Parameter akzeptieren:
fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier { // ... }
Dieser Modifier ist ein Producer. Er kann das bereitgestellte MutableInteractionSource verwenden, um HoverInteractions auszugeben, wenn der Mauszeiger darauf bewegt oder davon entfernt wird.
Komponenten erstellen, die Daten nutzen und ausgeben
Komponenten auf hoher Ebene wie ein Material Button fungieren sowohl als Produzenten als auch als Verbraucher. Sie verarbeiten Eingabe- und Fokusereignisse und ändern auch ihr Erscheinungsbild als Reaktion auf diese Ereignisse, z. B. durch Anzeigen eines Ripples oder Animieren der Erhebung. Daher wird MutableInteractionSource direkt als Parameter verfügbar gemacht, 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 das Hoisting des MutableInteractionSource aus der Komponente heraus erfolgen und alle von der Komponente erzeugten Interactions können beobachtet werden. Damit können Sie das Erscheinungsbild dieser oder einer anderen Komponente in Ihrer Benutzeroberfläche steuern.
Wenn Sie eigene interaktive Komponenten auf hoher Ebene erstellen, empfehlen wir, MutableInteractionSource auf diese Weise als Parameter verfügbar zu machen. Neben der Einhaltung der Best Practices für das State Hoisting wird dadurch auch das Lesen und Steuern des visuellen Status einer Komponente auf dieselbe Weise wie bei jedem anderen Status (z. B. dem aktivierten Status) erleichtert.
Compose folgt einem mehrschichtigen Architekturansatz. Die Material-Komponenten auf hoher Ebene basieren also auf grundlegenden Bausteinen, die die Interactions erzeugen, die zum Steuern von Wellen und anderen visuellen Effekten erforderlich sind. Die Foundation-Bibliothek bietet Interaktionsmodifikatoren auf hoher Ebene 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 MutableInteractionSource als Parameter übergeben.
Immer wenn der Mauszeiger auf die Komponente bewegt wird, werden HoverInteraction-Signale ausgegeben. 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 denselben MutableInteractionSource als Parameter übergeben. Sowohl HoverInteraction.Enter/Exit als auch FocusInteraction.Focus/Unfocus werden jetzt über denselben MutableInteractionSource ausgegeben. 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 auch mit dem Mauszeiger erreichbar sein. Komponenten, die angeklickt werden können, sollten auch fokussierbar sein. Mit Modifier.clickable können Sie eine Komponente erstellen, die Hover-, Fokus- und Druckinteraktionen verarbeitet, ohne dass Sie APIs auf niedrigerer Ebene kombinieren 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 standardmäßigen Flow-APIs für das InteractionSource dieser Komponente verwenden.
Angenommen, Sie möchten eine Liste der Interaktionen vom Typ „Drücken und ziehen“ für ein InteractionSource beibehalten. Dieser Code erledigt die Hälfte der Arbeit und fügt die neuen Presseartikel 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 vom Steuerelement nimmt. Das ist ganz einfach, da die Endinteraktionen immer einen Verweis auf die zugehörige Startinteraktion enthalten. Dieser Code zeigt, wie Sie die Interaktionen entfernen, die beendet wurden:
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 nun 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 den letzten Eintrag in der Liste an. So wird beispielsweise in der Compose-Ripple-Implementierung der passende Status-Overlay für die letzte Interaktion ermittelt:
val lastInteraction = when (interactions.lastOrNull()) { is DragInteraction.Start -> "Dragged" is PressInteraction.Press -> "Pressed" else -> "No state" }
Da alle Interactions derselben Struktur folgen, gibt es kaum Unterschiede im Code, wenn Sie mit verschiedenen Arten von Nutzerinteraktionen arbeiten – 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 Statuswerts automatisch Neuzusammenstellungen auslöst. Die Komposition wird jedoch batchweise vor dem Rendern des Frames ausgeführt. Wenn sich der Status ändert und dann innerhalb desselben Frames wieder zurückändert, sehen Komponenten, die den Status beobachten, die Änderung nicht.
Das ist wichtig für Interaktionen, da sie regelmäßig innerhalb desselben Frames beginnen und enden können. Beispiel: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 einen so kurzen Zeitraum angezeigt wird, flackert er und ist für den Nutzer kaum wahrnehmbar. In einigen Fällen, z. B. bei der Anzeige 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 Schaltfläche nicht mehr gedrückt wird. Dazu können Sie Animationen direkt in der 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 sehen Sie ein Beispiel für einen geänderten Button, mit dem Sie Komponenten mit einer benutzerdefinierten Reaktion auf Eingaben erstellen können. Angenommen, Sie möchten eine Schaltfläche, die auf Drücken reagiert, indem sie ihr Aussehen ändert:
Erstellen Sie dazu ein benutzerdefiniertes Composable auf Grundlage von Button und lassen Sie es einen zusätzlichen icon-Parameter zum Zeichnen des Symbols (in diesem Fall ein Einkaufswagen) verwenden. Sie rufen collectIsPressedAsState() auf, um zu erfassen, ob der Nutzer den Mauszeiger über die Schaltfläche bewegt. Wenn dies der Fall ist, 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() } }
So sieht die Verwendung des neuen Composables aus:
PressIconButton( onClick = {}, icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) }, text = { Text("Add to cart") } )
Da dieses neue PressIconButton auf dem vorhandenen Material Button basiert, reagiert es auf Nutzerinteraktionen wie gewohnt. Wenn der Nutzer auf die Schaltfläche drückt, ändert sich ihre 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 Interaction ändern, z. B. ein Symbol anzeigen, wenn darauf getippt wird. Mit derselben Methode können Sie auch den Wert von Parametern ändern, die Sie für eine Komponente angeben, oder die in einer Komponente angezeigten Inhalte ändern. Dies ist jedoch nur auf Komponentenebene möglich. Häufig haben Anwendungen oder Designsysteme 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-Text.
- Es ist leicht, diesen Effekt auf neu erstellte Komponenten und benutzerdefinierte klickbare Komponenten zu vergessen.
- 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 ist ein wiederverwendbarer visueller Effekt, der auf Komponenten in einer Anwendung oder einem Designsystem angewendet werden kann. Indication ist in zwei Teile unterteilt:
IndicationNodeFactory: Eine Factory, dieModifier.Node-Instanzen erstellt, die visuelle Effekte für eine Komponente rendern. Bei einfacheren Implementierungen, die sich nicht zwischen Komponenten ä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
CompositionLocalabrufen, um das Aussehen oder Verhalten in einer bestimmten Komponente zu ändern, wie bei jedem anderenModifier.Node.Modifier.indication: Ein Modifikator, derIndicationfür eine Komponente zeichnet.Modifier.clickableund andere Interaktionsmodifikatoren auf hoher Ebene akzeptieren einen Indikatorparameter direkt. Sie geben also nicht nurInteractions aus, sondern können auch visuelle Effekte für die ausgegebenenInteractions zeichnen. In einfachen Fällen können Sie also einfachModifier.clickableverwenden, ohneModifier.indicationzu benötigen.
Ersetzen Sie den Effekt durch ein Indication.
In diesem Abschnitt wird beschrieben, wie Sie einen manuellen Skalierungseffekt, der auf eine bestimmte Schaltfläche angewendet wird, durch eine gleichwertige Anzeige ersetzen, die in mehreren Komponenten wiederverwendet werden kann.
Mit dem folgenden Code wird eine Schaltfläche erstellt, die 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 wandeln Sie den Skalierungseffekt im Snippet oben in eine Indication um:
Modifier.Nodeerstellen, die für die Anwendung des Skalierungseffekts verantwortlich ist: Wenn der Knoten angehängt ist, wird die Interaktionsquelle beobachtet, ähnlich wie in den vorherigen Beispielen. Der einzige Unterschied besteht darin, dass hier Animationen direkt gestartet werden, anstatt die eingehenden Interaktionen in den Status zu konvertieren.Der Knoten muss
DrawModifierNodeimplementieren, damit erContentDrawScope#draw()überschreiben und einen Skalierungseffekt mit denselben Zeichenbefehlen wie bei jeder anderen Grafik-API in Compose rendern kann.Beim Aufrufen von
drawContent(), das über denContentDrawScope-Empfänger verfügbar ist, wird die tatsächliche Komponente gezeichnet, auf dieIndicationangewendet werden soll. Sie müssen diese Funktion also nur innerhalb einer Skalierungstransformation aufrufen. Achten Sie darauf, dass in IhrenIndication-Implementierungen immer irgendwanndrawContent()aufgerufen wird. Andernfalls wird die Komponente, auf die SieIndicationanwenden, 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() } } }
Erstellen Sie die
IndicationNodeFactory. Die 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 Fabrik 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 }
Modifier.clickableverwendet internModifier.indication. Wenn Sie also eine klickbare Komponente mitScaleIndicationerstellen möchten, müssen Sie nurIndicationals Parameter fürclickableangeben: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 hoher Ebene mit einem benutzerdefinierten
Indicationerstellen. 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 ) }
So verwenden Sie die Schaltfläche:
ScaleButton(onClick = {}) { Icon(Icons.Filled.ShoppingCart, "") Spacer(Modifier.padding(10.dp)) Text(text = "Add to cart!") }
IndicationErstellen Sie ein erweitertes Indication mit animiertem Rahmen.
Indication ist nicht nur auf Transformationseffekte wie das Skalieren 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 über der Komponente zeichnen, wenn sie gedrückt wird:
Indication gezeichnet wird.Die Indication-Implementierung hier ähnelt sehr dem vorherigen Beispiel – es wird lediglich 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 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 Implementierung von Modifier.Node ist auch konzeptionell gleich, 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 die Taste sofort losgelassen wird, wird die Animation fortgesetzt. Es gibt auch eine Verarbeitung für mehrere kurze Drücker am Anfang von animateToPressed – wenn ein Drücker während einer bestehenden Drücker- oder Ruheanimation erfolgt, wird die vorherige Animation abgebrochen und die Drückeranimation 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.
Empfehlungen für Sie
- Hinweis: Linktext wird angezeigt, wenn JavaScript deaktiviert ist.
- Gesten
- Kotlin für Jetpack Compose
- Material-Komponenten und ‑Layouts