Gesty

Pracując nad obsługą gestów w aplikacji, warto zrozumieć kilka terminów i koncepcji. Na tej stronie objaśniamy terminy, związane ze wskaźnikami, zdarzeniami i gestami, a także przedstawiamy różne poziomy abstrakcji używania gestów. Zajmuje się też głębszym omówieniem konsumpcji i propagacji zdarzeń.

Definicje

Aby zrozumieć różne pojęcia na tej stronie, musisz poznać używaną terminologię:

  • Wskaźnik: obiekt fizyczny, którego możesz używać do interakcji z aplikacją. W przypadku urządzeń mobilnych najczęstszym wskaźnikiem jest palec, który wchodzi w interakcję z ekranem dotykowym. Możesz też zastąpić palec rysikiem. W przypadku dużych ekranów możesz pośrednio wchodzić w interakcje z wyświetlaczem za pomocą myszy lub trackpada. Aby można było uznać urządzenie za wskaźnik, urządzenie wejściowe musi być w stanie „wskazać” współrzędną. Nie można więc zaliczyć np. klawiatury do klawiatury. W narzędziu Compose typ wskaźnika jest uwzględniany w zmianach wskaźnika za pomocą PointerType.
  • Zdarzenie wskaźnika opisuje interakcję na niskim poziomie z jednym lub większą liczbą wskaźników z aplikacją w danym momencie. Każda interakcja ze wskaźnikiem, np. umieszczenie palca na ekranie lub przeciągnięcie myszą, wywołuje zdarzenie. W metodzie tworzenia wszystkie istotne informacje o takim zdarzeniu są zawarte w klasie PointerEvent.
  • Gest: sekwencja zdarzeń wskaźnika, które można zinterpretować jako pojedyncze działanie. Na przykład gest kliknięcia może być sekwencją zdarzenia w dół, po którym następuje zdarzenie w górę. W wielu aplikacjach są popularne gesty takie jak klikanie, przeciąganie czy przekształcanie. W razie potrzeby możesz też utworzyć własny gest niestandardowy.

Różne poziomy abstrakcji

Jetpack Compose zapewnia różne poziomy abstrakcji obsługi gestów. Najwyższy poziom to obsługa komponentów. Funkcje kompozycyjne, takie jak Button, automatycznie obsługują gesty. Aby dodać obsługę gestów do komponentów niestandardowych, możesz dodać do dowolnych funkcji kompozycyjnych modyfikatory gestów, takie jak clickable. Jeśli potrzebujesz gestu niestandardowego, możesz też użyć modyfikatora pointerInput.

Zasadniczo należy opierać się na najwyższym poziomie abstrakcji, który zapewnia potrzebną funkcjonalność. W ten sposób korzystasz ze sprawdzonych metod opisanych w warstwie. Na przykład element Button zawiera więcej informacji semantycznych (ułatwiających dostęp) niż tag clickable, który zawiera więcej informacji niż nieprzetworzona implementacja pointerInput.

Obsługa komponentów

Wiele gotowych komponentów do tworzenia wiadomości obejmuje jakąś wewnętrzną obsługę gestów. Na przykład obiekt LazyColumn reaguje na gesty przeciągania, przewijając jego zawartość, po naciśnięciu przycisku w dół Button pokazuje falę, a komponent SwipeToDismiss zawiera logikę przesuwania, która wyłącza element. Ten typ obsługi gestów działa automatycznie.

Oprócz wewnętrznej obsługi gestów wiele komponentów wymaga też od rozmówcy obsługi gestów. Na przykład element Button automatycznie wykrywa kliknięcia i wywołuje zdarzenie kliknięcia. Przekazujesz lambda onClick do interfejsu Button, aby zareagować na ten gest. I podobnie dodajesz lambda onValueChange do elementu Slider, by określić, czy użytkownik przeciągnie uchwyt suwaka.

W miarę możliwości korzystaj z gestów zawartych w komponentach, ponieważ zawierają gotową obsługę funkcji skupienia i ułatwień dostępu, a także są dobrze przetestowane. Na przykład element Button jest oznaczony w specjalny sposób, aby usługi ułatwień dostępu prawidłowo opisywały go jako przycisk, a nie tylko dowolny klikalny element:

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

Więcej informacji o ułatwieniach dostępu w sekcji Utwórz znajdziesz w sekcji Ułatwienia dostępu.

Dodawanie określonych gestów do dowolnych funkcji kompozycyjnych z modyfikatorami

Możesz zastosować modyfikatory gestów do dowolnych funkcji kompozycyjnych, aby funkcja kompozycyjna nasłuchiwała gestów. Możesz na przykład zezwolić na ogólne gesty dotknięcia rączką Box w postaci ikony clickable, a Column użyć przewijania w pionie, stosując verticalScroll.

Istnieje wiele modyfikatorów do obsługi różnych typów gestów:

Z reguły korzystaj z gotowych modyfikatorów gestów, a nie obsługi niestandardowych gestów. Modyfikatory zwiększają funkcjonalność poza obsługą zdarzeń wskaźnika. Na przykład modyfikator clickable nie tylko dodaje wykrywanie naciśnięć i kliknięć, ale też dodaje informacje semantyczne i wizualne informacje o interakcjach, najechanie kursorem, zaznaczenie i obsługę klawiatury. Możesz sprawdzić kod źródłowy clickable, aby dowiedzieć się, jak ta funkcja jest dodawana.

Dodaj gest niestandardowy do dowolnych funkcji kompozycyjnych z modyfikatorem pointerInput

Nie każdy gest jest zaimplementowany z gotowym modyfikatorem gestów. Na przykład nie można używać modyfikatora do reagowania na przeciągnięcie po przytrzymaniu, kliknięciu z naciśniętym klawiszem Control lub kliknięciu 3 palcami. Możesz jednak wpisać własny moduł obsługi gestów, by rozpoznawać te niestandardowe gesty. Możesz utworzyć moduł obsługi gestów z modyfikatorem pointerInput, który daje dostęp do nieprzetworzonych zdarzeń wskaźnika.

Ten kod odsłuchuje nieprzetworzone zdarzenia wskaźnika:

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

Po podzieleniu tego fragmentu kodu główne komponenty to:

  • Modyfikator pointerInput. Przekazujesz do niego co najmniej 1 klucz. Gdy wartość jednego z tych kluczy ulegnie zmianie, funkcja lambda treści modyfikatora jest wykonywana ponownie. Przykład przekazuje opcjonalny filtr do funkcji kompozycyjnej. Jeśli wartość tego filtra ulegnie zmianie, należy ponownie uruchomić moduł obsługi zdarzeń wskaźnika, aby mieć pewność, że rejestrowane są odpowiednie zdarzenia.
  • awaitPointerEventScope tworzy zakres współużytkowania, którego można używać do oczekiwania na zdarzenia wskaźnika.
  • awaitPointerEvent zawiesza współprogram do czasu wystąpienia kolejnego zdarzenia wskaźnika.

Choć nasłuchiwanie nieprzetworzonych danych wejściowych ma duże znaczenie, utworzenie niestandardowego gestu na podstawie tych nieprzetworzonych danych jest też skomplikowane. Aby ułatwić tworzenie gestów niestandardowych, dostępne są różne metody narzędziowe.

Wykrywanie pełnych gestów

Zamiast obsługiwać nieprzetworzone zdarzenia wskaźnika, możesz wykrywać konkretne gesty i reagować na nie. AwaitPointerEventScope udostępnia metody wykrywania:

Są to wzorce do wykrywania treści najwyższego poziomu, więc nie można dodać wielu wzorców do wykrywania treści w ramach jednego modyfikatora pointerInput. Ten fragment kodu wykrywa tylko kliknięcia, a nie przeciąganie:

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

Wewnętrznie metoda detectTapGestures blokuje współprogram, a drugi detektor nigdy nie jest osiągany. Jeśli chcesz dodać do funkcji kompozycyjnej więcej niż 1 detektor gestów, użyj osobnych wystąpień modyfikatora pointerInput:

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

Obsługa zdarzeń według gestu

Z definicji gesty zaczynają się od zdarzenia wskaźnika w dół. Zamiast pętli while(true), która przechodzi przez poszczególne nieprzetworzone zdarzenia, możesz używać metody pomocniczej awaitEachGesture. Metoda awaitEachGesture uruchamia ponownie zawarty blok po podniesieniu wszystkich wskaźników, co oznacza, że gest został zakończony:

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

W praktyce prawie zawsze chcesz używać awaitEachGesture, chyba że odpowiadasz na zdarzenia wskaźnika bez identyfikowania gestów. Przykładem może być mechanizm hoverable, który nie reaguje na zdarzenia typu wskaźnik w dół ani w górę – wystarczy, że będzie wiedzieć, kiedy wskaźnik znajdzie się w jej granicach lub ją opuści.

Poczekaj na określone zdarzenie lub podrzędne gesty

Istnieje zestaw metod, które pomagają rozpoznawać typowe części gestów:

Stosowanie obliczeń w przypadku zdarzeń obejmujących różne interakcje

Gdy użytkownik wykonuje gest wielodotykowy za pomocą więcej niż 1 wskaźnika, zrozumienie wymaganej przekształcenia na podstawie nieprzetworzonych wartości jest skomplikowane. Jeśli modyfikator transformable lub metody detectTransformGestures nie dają wystarczającej kontroli w Twoim przypadku, możesz wychwytywać nieprzetworzone zdarzenia i stosować do nich obliczenia. Dostępne metody pomocnicze to calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation i calculateZoom.

Wysyłanie zdarzeń i testowanie trafień

Nie każde zdarzenie wskaźnika jest wysyłane do każdego modyfikatora pointerInput. Wysyłanie zdarzeń działa w ten sposób:

  • Zdarzenia wskaźnika są wysyłane do hierarchii kompozycyjnej. W momencie, gdy nowy wskaźnik aktywuje swoje pierwsze zdarzenie wskaźnika, system rozpoczyna testowanie działań funkcji kompozycyjnych „kwalifikujących się”. Funkcja kompozycyjna jest uznawana za kwalifikującą się, jeśli ma funkcje obsługi danych wejściowych wskaźnika. Testowanie działań przebiega od góry do dołu drzewa interfejsu. Element kompozycyjny to „działanie”, gdy zdarzenie wskaźnika wystąpiło w granicach tego elementu kompozycyjnego. W wyniku tego powstaje łańcuch elementów kompozycyjnych, które wykazują pozytywne wyniki.
  • Jeśli na tym samym poziomie drzewa jest wiele odpowiednich funkcji kompozycyjnych, domyślnie tylko ten z najwyższym z-indeksem to „działanie”. Jeśli na przykład dodasz do elementu Box 2 pokrywające się elementy kompozycyjne Button, zdarzenia wskaźnika będą wysyłane tylko ten element narysowany u góry. Teoretycznie możesz zastąpić to zachowanie, tworząc własną implementację PointerInputModifierNode i ustawiając sharePointerInputWithSiblings na wartość Prawda.
  • Dalsze zdarzenia związane z tym samym wskaźnikiem są wysyłane do tego samego łańcucha obiektów kompozycyjnych i przebiegają zgodnie z logiką propagacji zdarzeń. System nie będzie więcej testował z nim trafień. Oznacza to, że każdy element kompozycyjny w łańcuchu otrzymuje wszystkie zdarzenia związane z tym wskaźnikiem, nawet jeśli występują one poza granicami funkcji kompozycyjnej. Obiekty kompozycyjne, które nie są w łańcuchu, nigdy nie otrzymują zdarzeń wskaźnika, nawet jeśli wskaźnik mieści się poza ich granicami.

Wyjątkiem od zdefiniowanych tutaj reguł są zdarzenia najechania kursorem, które są wywoływane po najechaniu kursorem myszy lub po najechaniu rysikiem. Zdarzenia najechania kursorem są wysyłane do wszystkich obsługiwanych przez nie funkcji kompozycyjnych. Gdy użytkownik najedzie kursorem na wskaźnik z zakresu jednego elementu kompozycyjnego do następnego, zamiast wysyłać zdarzenia do tego pierwszego elementu kompozycyjnego,

Spożycie zdarzeń

Jeśli więcej niż jeden element kompozycyjny ma przypisany moduł obsługi gestów, nie powinno to powodować konfliktu. Spójrzmy na przykład na ten interfejs:

Element listy z obrazem, kolumną z 2 tekstami i przyciskiem.

Gdy użytkownik kliknie przycisk zakładki, lambda onClick na tym przycisku obsługuje ten gest. Gdy użytkownik kliknie inną część elementu listy, uchwyt ListItem przejdzie do artykułu. W przypadku wprowadzania wskaźnika przycisk musi konwertować to zdarzenie, aby jego element nadrzędny nie miał już na nie reagować. Gesty zawarte w gotowych komponentach i modyfikatory typowych gestów obejmują takie zachowanie, ale jeśli tworzysz własny gest niestandardowy, musisz przetwarzać zdarzenia ręcznie. Użyj do tego metody PointerInputChange.consume:

Modifier.pointerInput(Unit) {

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

Wykorzystanie zdarzenia nie zatrzymuje jego rozpowszechniania do innych funkcji kompozycyjnych. Funkcja kompozycyjna musi zamiast tego ignorować użyte zdarzenia. Pracując nad gestami niestandardowymi, sprawdź, czy zdarzenie nie zostało już przetworzone przez inny element:

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

Rozpowszechnianie zdarzeń

Jak już wspomnieliśmy, zmiany wskaźników są przekazywane do każdego trafionego elementu kompozycyjnego. Jeśli jednak istnieje więcej niż 1 taki obiekt kompozycyjny, w jakiej kolejności są propagowane zdarzenia? Jeśli wykorzystasz przykład z ostatniej sekcji, interfejs ten zostanie przekształcony w takie drzewo interfejsu, w którym tylko ListItem i Button odpowiadają na zdarzenia wskaźnika:

Struktura drzewa. Górna warstwa to ListItem, druga warstwa zawiera obraz, kolumnę i przycisk, a kolumna dzieli się na 2 teksty. Wyróżnione są opcje ListItem i Button.

Zdarzenia wskaźnika przechodzą przez każdy z tych elementów kompozycyjnych 3 razy w ramach 3 „przepustek”:

  • W przypadku karnetu początkowego zdarzenie płynie z góry drzewa interfejsu na dół. Ten proces umożliwia rodzicowi przechwycenie zdarzenia, zanim dziecko będzie mogło je wykorzystać. Na przykład etykiety muszą przechwycić i przechwycić przytrzymanie zamiast przekazywać je dzieciom. W tym przykładzie ListItem otrzymuje zdarzenie przed elementem Button.
  • W karcie głównej zdarzenie przepływa od węzłów liści drzewa interfejsu do poziomu głównego drzewa interfejsu. Na tej fazie zwykle używamy gestów i jest to domyślna faza nasłuchiwania zdarzeń. Obsługa gestów w tym karnetie oznacza, że węzły liści mają pierwszeństwo przed elementami nadrzędnymi, co jest najbardziej logicznym zachowaniem w przypadku większości gestów. W naszym przykładzie Button otrzymuje zdarzenie przed elementem ListItem.
  • W przypadku przejścia końcowego zdarzenie przepływa jeszcze raz z góry drzewa interfejsu do węzłów liści. Dzięki temu elementy znajdujące się wyżej w stosie mogą reagować na zdarzenia przez ich element nadrzędny. Na przykład przycisk usuwa z niego falowanie, gdy jego naciśnięcie zmieni się w przeciąganie przewijanego elementu nadrzędnego.

Przepływ zdarzeń można odzwierciedlić w ten sposób:

Po wykorzystaniu zmiany danych wejściowych te informacje są przekazywane od tego punktu kolejnych etapów procesu:

W kodzie możesz określić kartę, która Cię interesuje:

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

W tym fragmencie kodu każde z tych wywołań metody zwraca to samo zdarzenie, ale dane o wykorzystaniu mogły się zmienić.

Gesty testowe

W metodach testowania możesz ręcznie wysyłać zdarzenia wskaźnika za pomocą metody performTouchInput. Dzięki temu możesz wykonywać gesty pełne wyższego poziomu (np. ściąganie palcami lub przytrzymanie) lub gesty niskiego poziomu (np. przesunięcie kursora o określoną liczbę pikseli):

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

Więcej przykładów znajdziesz w dokumentacji performTouchInput.

Więcej informacji

Więcej informacji o gestach w Jetpack Compose znajdziesz w tych materiałach: