Gesten verstehen

Es gibt einige Begriffe und Konzepte, die für die Gestenhandhabung in einer App wichtig sind. Auf dieser Seite werden die Begriffe „Zeiger“, „Zeigerereignisse“ und „Gesten“ erläutert. Außerdem werden die verschiedenen Abstraktionsebenen für Gesten vorgestellt. Außerdem werden der Ereigniskonsum und die Verbreitung von Ereignissen ausführlicher behandelt.

Definitionen

Um die verschiedenen Konzepte auf dieser Seite zu verstehen, müssen Sie einige der verwendeten Begriffe kennen:

  • Zeiger: Ein physisches Objekt, das Sie zur Interaktion mit Ihrer Anwendung verwenden können. Bei Mobilgeräten ist der Zeiger am häufigsten, wenn ein Finger mit dem Touchscreen interagiert. Alternativ kannst du den Finger auch mit einem Eingabestift ersetzen. Bei großen Bildschirmen können Sie eine Maus oder ein Touchpad verwenden, um indirekt mit dem Display zu interagieren. Ein Eingabegerät muss in der Lage sein, auf eine Koordinate zu „Punkten“, damit es als Zeiger betrachtet wird. Daher kann eine Tastatur beispielsweise nicht als Zeiger betrachtet werden. In „Compose“ wird der Zeigertyp mit PointerType in Zeigeränderungen einbezogen.
  • Zeigerereignis: Beschreibt eine Low-Level-Interaktion eines oder mehrerer Zeiger mit der Anwendung zu einem bestimmten Zeitpunkt. Jede Zeigerinteraktion, z. B. das Platzieren eines Fingers auf den Bildschirm oder das Ziehen einer Maus, würde ein Ereignis auslösen. In Composer befinden sich alle relevanten Informationen für ein solches Ereignis in der Klasse PointerEvent.
  • Geste: Eine Abfolge von Zeigerereignissen, die als eine einzelne Aktion interpretiert werden können. Eine Tippgeste kann beispielsweise als Abfolge eines Abwärtsereignisses gefolgt von einem Aufwärtsereignis betrachtet werden. Es gibt gängige Gesten, die von vielen Apps verwendet werden, z. B. Tippen, Ziehen oder Transformieren. Sie können bei Bedarf aber auch eigene benutzerdefinierte Gesten erstellen.

Verschiedene Abstraktionsebenen

Jetpack Compose bietet verschiedene Abstraktionsebenen für die Verarbeitung von Gesten. Auf der obersten Ebene finden Sie den Support für Komponenten. Zusammensetzbare Funktionen wie Button enthalten automatisch die Unterstützung von Gesten. Wenn Sie Gesten für benutzerdefinierte Komponenten unterstützen möchten, können Sie beliebigen zusammensetzbaren Funktionen Gestenmodifikatoren wie clickable hinzufügen. Wenn Sie eine benutzerdefinierte Geste benötigen, können Sie schließlich den Modifikator pointerInput verwenden.

In der Regel sollten Sie auf der höchsten Abstraktionsebene aufbauen, die die erforderliche Funktionalität bietet. So profitieren Sie von den Best Practices, die in der Ebene enthalten sind. Beispielsweise enthält Button mehr semantische Informationen, die für die Barrierefreiheit verwendet werden, als clickable, das mehr Informationen als eine Rohimplementierung von pointerInput enthält.

Unterstützung von Komponenten

Viele vorkonfigurierte Komponenten in Compose enthalten eine Art interne Gestenbehandlung. Ein LazyColumn reagiert auf Ziehbewegungen, indem es durch seinen Inhalt scrollt, ein Button zeigt eine Welle an, wenn Sie darauf drücken, und die Komponente SwipeToDismiss enthält Wischlogik zum Schließen eines Elements. Diese Art der Gestenhandhabung funktioniert automatisch.

Neben der internen Gestenhandhabung muss der Aufrufer bei vielen Komponenten auch die Geste verarbeiten. Ein Button erkennt beispielsweise automatisch Tippen und löst ein Klickereignis aus. Sie übergeben ein onClick-Lambda an Button, um auf die Geste zu reagieren. Auf ähnliche Weise fügst du einem Slider ein onValueChange-Lambda hinzu, um darauf zu reagieren, dass der Nutzer den Schieberegler zieht.

Wenn es Ihrem Anwendungsfall entspricht, sollten Sie Gesten in Komponenten verwenden, da sie sofort einsatzbereit für Fokus und Bedienungshilfen sind und gut getestet sind. Beispielsweise wird ein Button speziell markiert, damit die Bedienungshilfen sie korrekt als Schaltfläche und nicht als jedes anklickbare Element beschreiben:

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

Weitere Informationen zu den Bedienungshilfen in Compose finden Sie unter Bedienungshilfen in Composer.

Beliebigen zusammensetzbaren Funktionen bestimmte Gesten mit Modifikatoren hinzufügen

Sie können Gestenmodifikatoren auf jede beliebige zusammensetzbare Funktion anwenden, damit die zusammensetzbare Funktion auf Gesten wartet. Du kannst beispielsweise einem allgemeinen Box Tippgesten zuweisen, indem du ihn clickable gibst, oder einem Column vertikalen Scrollen überlassen, indem du verticalScroll anwendest.

Es gibt viele Modifikatoren, mit denen verschiedene Arten von Gesten gehandhabt werden können:

Grundsätzlich sollten Sie sofort einsatzbereite Gestenmodifikatoren gegenüber benutzerdefinierten Gestenhandhabungen bevorzugen. Die Modifikatoren bieten neben der Verarbeitung reiner Zeigerereignisse weitere Funktionen. Der Modifikator clickable fügt beispielsweise nicht nur die Erkennung von Drücken und Tippen hinzu, sondern fügt auch semantische Informationen, visuelle Hinweise zu Interaktionen, Bewegen des Mauszeigers, Fokus und Tastaturunterstützung hinzu. Im Quellcode von clickable können Sie sehen, wie die Funktionen hinzugefügt werden.

Mit dem pointerInput-Modifikator benutzerdefinierte Gesten zu beliebigen zusammensetzbaren Funktionen hinzufügen

Nicht jede Geste wird mit einem vorkonfigurierten Gestenmodifikator implementiert. Beispielsweise ist es nicht möglich, mit einem Modifikator auf langes Drücken, Klicken bei gedrückter Steuerungstaste oder Tippen mit drei Fingern auf Ziehbewegungen zu reagieren. Stattdessen können Sie einen eigenen Gesten-Handler schreiben, um diese benutzerdefinierten Gesten zu identifizieren. Sie können einen Gesten-Handler mit dem Modifikator pointerInput erstellen, der Zugriff auf die Rohzeigerereignisse gewährt.

Der folgende Code überwacht rohe Zeigerereignisse:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

Wenn Sie dieses Snippet aufschlüsseln, bestehen die folgenden Kernkomponenten:

  • Der pointerInput-Modifikator. Sie übergeben einen oder mehrere Schlüssel. Wenn sich der Wert eines dieser Schlüssel ändert, wird die Lambda-Funktion für Modifikatorinhalte noch einmal ausgeführt. Das Beispiel übergibt einen optionalen Filter an die zusammensetzbare Funktion. Wenn sich der Wert dieses Filters ändert, sollte der Zeiger-Event-Handler noch einmal ausgeführt werden, damit die richtigen Ereignisse protokolliert werden.
  • awaitPointerEventScope erstellt einen Koroutinenbereich, der zum Warten auf Zeigerereignisse verwendet werden kann.
  • awaitPointerEvent hält die Koroutine an, bis ein nächstes Zeigerereignis auftritt.

Das Überwachen von Roheingabeereignissen ist zwar leistungsstark, es ist aber auch komplex, eine benutzerdefinierte Geste auf der Grundlage dieser Rohdaten zu schreiben. Es gibt viele Dienstprogrammmethoden, um das Erstellen benutzerdefinierter Touch-Gesten zu vereinfachen.

Vollständige Touch-Gesten erkennen

Anstatt die Rohzeigerereignisse zu verarbeiten, können Sie auf das Auftreten bestimmter Gesten warten und entsprechend reagieren. Der AwaitPointerEventScope bietet Methoden zum Überwachen:

Da es sich um Detektoren der obersten Ebene handelt, können Sie nicht mehrere Detektoren mit einem pointerInput-Modifikator hinzufügen. Das folgende Snippet erkennt nur die Fingertipps, nicht die Ziehbewegungen:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Intern blockiert die Methode detectTapGestures die Koroutine und der zweite Detektor wird nie erreicht. Wenn Sie einer zusammensetzbaren Funktion mehrere Gesten-Listener hinzufügen müssen, verwenden Sie stattdessen separate pointerInput-Modifikatorinstanzen:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Ereignisse pro Geste verarbeiten

Gesten beginnen per Definition mit einem Ereignis vom Typ „Zeiger nach unten“. Sie können die Hilfsmethode awaitEachGesture anstelle der while(true)-Schleife verwenden, die jedes Rohereignis durchläuft. Die Methode awaitEachGesture startet den enthaltenden Block neu, wenn alle Zeiger angehoben wurden, was anzeigt, dass die Geste abgeschlossen ist:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

In der Praxis empfiehlt es sich fast immer, awaitEachGesture zu verwenden, es sei denn, Sie reagieren auf Zeigerereignisse, ohne Gesten zu identifizieren. Ein Beispiel dafür ist hoverable, das nicht auf Zeiger-Nach-unten- oder Nach-oben-Ereignisse reagiert – sie muss nur wissen, wenn ein Zeiger in seine Grenzen hinausgeht oder diese verlässt.

Auf ein bestimmtes Ereignis oder eine untergeordnete Bewegung warten

Es gibt eine Reihe von Methoden, mit denen häufige Teile von Gesten identifiziert werden können:

Berechnungen für Multi-Touch-Ereignisse anwenden

Wenn ein Nutzer eine Multi-Touch-Geste mit mehr als einem Zeiger ausführt, ist es schwierig, die erforderliche Transformation anhand der Rohwerte zu verstehen. Wenn der Modifikator transformable oder die Methoden detectTransformGestures nicht genügend detaillierte Steuerungsmöglichkeiten für Ihren Anwendungsfall bieten, können Sie die Rohereignisse beobachten und Berechnungen darauf anwenden. Diese Hilfsmethoden sind calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation und calculateZoom.

Ereignisweiterleitung und Treffertests

Nicht jedes Zeigerereignis wird an jeden pointerInput-Modifikator gesendet. Die Ereignisweiterleitung funktioniert so:

  • Zeigerereignisse werden an eine zusammensetzbare Hierarchie weitergeleitet. In dem Moment, in dem ein neuer Zeiger sein erstes Zeigerereignis auslöst, beginnt das System mit einem Treffertest der „zulässigen“ zusammensetzbaren Funktionen. Eine zusammensetzbare Funktion gilt als zulässig, wenn sie Funktionen zur Verarbeitung von Zeigereingaben hat. Hit-Testing-Abläufe vom oberen zum unteren Rand des UI-Baums. Eine zusammensetzbare Funktion wird als „Treffer“ bezeichnet, wenn das Zeigerereignis innerhalb der Grenzen dieser zusammensetzbaren Funktion aufgetreten ist. Dieser Prozess führt zu einer Kette von zusammensetzbaren Funktionen, die positive Treffertests durchlaufen.
  • Wenn sich mehrere zulässige zusammensetzbare Funktionen auf derselben Ebene der Struktur befinden, wird standardmäßig nur die zusammensetzbare Funktion mit dem höchsten Z-Index „hit“ verwendet. Wenn Sie beispielsweise zwei überlappende Button-zusammensetzbare Funktionen zu einer Box hinzufügen, empfängt nur die darüber liegende Zeigerereignisse. Sie können dieses Verhalten theoretisch überschreiben, indem Sie eine eigene PointerInputModifierNode-Implementierung erstellen und sharePointerInputWithSiblings auf „true“ setzen.
  • Weitere Ereignisse für denselben Zeiger werden an dieselbe Kette von zusammensetzbaren Funktionen weitergeleitet und fließen gemäß der Ereignis-Weitergabelogik. Das System führt keine weiteren Treffertests für diesen Zeiger durch. Das bedeutet, dass jede zusammensetzbare Funktion in der Kette alle Ereignisse für diesen Zeiger empfängt, auch wenn diese außerhalb der Grenzen dieser zusammensetzbaren Funktion auftreten. Zusammensetzbare Funktionen, die sich nicht in der Kette befinden, erhalten nie Zeigerereignisse, selbst wenn sich der Zeiger innerhalb ihrer Grenzen befindet.

Hover-Ereignisse, die durch Bewegen der Maus oder eines Eingabestifts ausgelöst werden, sind eine Ausnahme zu den hier definierten Regeln. Hover-Ereignisse werden an jede zusammensetzbare Funktion gesendet, auf die sie zutreffen. Wenn also ein Nutzer den Mauszeiger von den Grenzen einer zusammensetzbaren Funktion zur nächsten bewegt, werden die Ereignisse nicht an diese erste zusammensetzbare Funktion gesendet.

Ereignisverbrauch

Wenn mehreren zusammensetzbaren Funktionen ein Gesten-Handler zugewiesen ist, sollten sich diese Handler nicht widersprechen. Sehen Sie sich zum Beispiel diese UI an:

Listenelement mit einem Bild, einer Spalte mit zwei Texten und einer Schaltfläche.

Wenn ein Nutzer auf die Lesezeichenschaltfläche tippt, wird diese Bewegung durch das onClick-Lambda-Element der Schaltfläche ausgeführt. Wenn ein Nutzer auf einen anderen Teil des Listenelements tippt, steuert ListItem diese Geste und ruft den Artikel auf. Was die Zeigereingabe betrifft, muss die Schaltfläche dieses Ereignis aufnehmen, damit das übergeordnete Element weiß, dass es nicht mehr darauf reagieren soll. Zu den Gesten, die in vorkonfigurierten Komponenten enthalten sind, und in den gängigen Gestenmodifikatoren ist dieses Nutzungsverhalten enthalten. Wenn Sie jedoch eine eigene benutzerdefinierte Geste schreiben, müssen Sie Ereignisse manuell verarbeiten. Dazu verwenden Sie die Methode PointerInputChange.consume:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

Durch die Verarbeitung eines Ereignisses wird die Ereignisweitergabe an andere zusammensetzbare Funktionen nicht gestoppt. Stattdessen muss eine zusammensetzbare Funktion verbrauchte Ereignisse explizit ignorieren. Wenn Sie benutzerdefinierte Touch-Gesten schreiben, sollten Sie prüfen, ob ein Ereignis bereits von einem anderen Element verarbeitet wurde:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

Ereignisweitergabe

Wie bereits erwähnt, werden Zeigeränderungen an jede zusammensetzbare Funktion übergeben, auf die sie angewendet wird. Wenn jedoch mehr als eine solche zusammensetzbare Funktion existiert, in welcher Reihenfolge werden die Ereignisse übertragen? Wenn Sie das Beispiel aus dem letzten Abschnitt nehmen, wird diese UI in den folgenden UI-Baum umgewandelt, in dem nur ListItem und Button auf Zeigerereignisse reagieren:

Baumstruktur Die oberste Ebene ist ListItem, die zweite Ebene hat Image, Column und Button und die Spalte teilt sich in zwei Texte. „ListItem“ und „Button“ sind markiert.

Zeigerereignisse fließen durch jede dieser zusammensetzbaren Funktionen dreimal in drei Durchgängen:

  • In der ersten Karte bzw. dem ersten Ticket fließt das Ereignis vom oberen Rand der UI-Struktur nach unten. Dieser Ablauf ermöglicht es einem übergeordneten Element, ein Ereignis abzufangen, bevor das untergeordnete Element es verarbeiten kann. Beispielsweise müssen Kurzinfos ein langes Drücken abfangen, anstatt es an die untergeordneten Elemente weiterzugeben. In unserem Beispiel empfängt ListItem das Ereignis vor dem Button.
  • In der Hauptkarte fließt das Ereignis von den Blattknoten der UI-Struktur bis zum Stamm der UI-Baum. In dieser Phase werden normalerweise Gesten verwendet. Sie ist die Standardkarte beim Überwachen von Ereignissen. Bei der Verarbeitung von Touch-Gesten in dieser Karte bzw. diesem Ticket haben Blattknoten Vorrang vor ihren übergeordneten Elementen. Dies ist für die meisten Gesten das logischste Verhalten. In unserem Beispiel empfängt Button das Ereignis vor dem ListItem.
  • In der Endgültigen Karte fließt das Ereignis noch einmal vom oberen Rand der UI-Baumstruktur zu den Blattknoten. Durch diesen Ablauf können Elemente weiter oben im Stack auf den Ereignisverbrauch durch ihr übergeordnetes Element reagieren. Beispielsweise entfernt eine Schaltfläche die Wellenlinie, wenn sich beim Drücken eine Ziehbewegung des scrollbaren übergeordneten Elements ändert.

Visuell kann der Ereignisfluss so dargestellt werden:

Sobald eine Eingabeänderung vorgenommen wurde, werden diese Informationen ab diesem Punkt im Ablauf übergeben:

Im Code können Sie die gewünschte Karte bzw. das gewünschte Ticket angeben:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

In diesem Code-Snippet wird von jedem dieser Await-Methodenaufrufe dasselbe identische Ereignis zurückgegeben, obwohl sich die Daten zum Verbrauch möglicherweise geändert haben.

Touch-Gesten testen

In Ihren Testmethoden können Sie Zeigerereignisse manuell mit der Methode performTouchInput senden. So können Sie entweder vollständige Touch-Gesten (z. B. Auseinander- und Zusammenziehen oder langes Klicken) oder Low-Level-Gesten (z. B. Bewegen des Cursors um eine bestimmte Anzahl von Pixeln) ausführen:

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

Weitere Beispiele finden Sie in der Dokumentation zu performTouchInput.

Weitere Informationen

Weitere Informationen zu Gesten in Jetpack Compose finden Sie in den folgenden Ressourcen: