Gesty

Podczas pracy nad obsługą gestów w aplikacji warto poznać kilka ważnych terminów i pojęć. Na tej stronie wyjaśniamy pojęcia wskaźników, zdarzeń wskaźnika i gestów oraz przedstawiamy różne poziomy abstrakcji gestów. Omawiamy też szczegółowo wykorzystanie i propagację zdarzeń.

Definicje

Aby zrozumieć różne pojęcia na tej stronie, musisz znać niektóre z używanych terminów:

  • Wskaźnik: fizyczny obiekt, 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ż użyć rysika zamiast palca. W przypadku dużych ekranów możesz używać myszy lub trackpada, aby pośrednio wchodzić w interakcje z wyświetlaczem. Aby urządzenie wejściowe mogło być uznane za wskaźnik, musi mieć możliwość „wskazywania” współrzędnych, dlatego na przykład klawiatura nie może być uznana za wskaźnik. W Compose typ wskaźnika jest uwzględniany w zmianach wskaźnika za pomocą PointerType.
  • Zdarzenie wskaźnika: opisuje interakcję niskiego poziomu co najmniej 1 wskaźnika z aplikacją w danym momencie. Każda interakcja ze wskaźnikiem, np. dotknięcie ekranu palcem lub przeciągnięcie myszą, wywołuje zdarzenie. W Compose wszystkie istotne informacje o takim zdarzeniu są zawarte w klasie PointerEvent.
  • Gest: sekwencja zdarzeń wskaźnika, którą można interpretować jako pojedyncze działanie. Na przykład gest kliknięcia można uznać za sekwencję zdarzenia naciśnięcia, po którym następuje zdarzenie zwolnienia. Istnieją typowe gesty, które są używane przez wiele aplikacji, takie jak kliknięcie, przeciągnięcie lub przekształcenie, ale w razie potrzeby możesz też utworzyć własny gest niestandardowy.

Różne poziomy abstrakcji

Jetpack Compose udostępnia różne poziomy abstrakcji do obsługi gestów. Na najwyższym poziomie znajduje się obsługa komponentów. Komponenty takie jak Button automatycznie obsługują gesty. Aby dodać obsługę gestów do komponentów niestandardowych, możesz dodać modyfikatory gestów, takie jak clickable, do dowolnych funkcji kompozycyjnych. Jeśli potrzebujesz niestandardowego gestu, możesz użyć modyfikatora pointerInput.

Zasadniczo należy tworzyć na najwyższym poziomie abstrakcji, który oferuje potrzebne funkcje. W ten sposób możesz korzystać ze sprawdzonych metod zawartych w warstwie. Na przykład Button zawiera więcej informacji semantycznych używanych na potrzeby ułatwień dostępu niż clickable, który zawiera więcej informacji niż podstawowa implementacja pointerInput.

Obsługa komponentów

Wiele gotowych komponentów w Compose zawiera pewien rodzaj wewnętrznej obsługi gestów. Na przykład LazyColumn reaguje na gesty przeciągania, przewijając zawartość, Button pokazuje efekt fali, gdy go naciśniesz, a komponent SwipeToDismiss zawiera logikę przesuwania, która umożliwia odrzucenie elementu. Ten typ obsługi gestów działa automatycznie.

Oprócz wewnętrznej obsługi gestów wiele komponentów wymaga również, aby gest obsługiwał element wywołujący. Na przykład Button automatycznie wykrywa kliknięcia i wywołuje zdarzenie kliknięcia. Przekazujesz funkcję lambda onClick do Button, aby reagować na gest. Podobnie dodajesz funkcję lambda onValueChange do Slider, aby reagować na przeciąganie uchwytu suwaka przez użytkownika.

Jeśli pasuje to do Twojego przypadku użycia, preferuj gesty zawarte w komponentach, ponieważ obejmują one gotową obsługę zaznaczenia i ułatwień dostępu oraz są dobrze przetestowane. Na przykład Button jest oznaczony w specjalny sposób, dzięki czemu usługi ułatwień dostępu prawidłowo opisują go jako przycisk, a nie jako dowolny element klikalny:

// 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 Compose znajdziesz w artykule Ułatwienia dostępu w Compose.

Dodawanie konkretnych gestów do dowolnych funkcji kompozycyjnych za pomocą modyfikatorów

Modyfikatory gestów możesz stosować do dowolnej funkcji kompozycyjnej, aby umożliwić jej odbieranie gestów. Możesz na przykład pozwolić ogólnemu elementowi Box na obsługę gestów kliknięcia, ustawiając go jako clickable, lub pozwolić elementowi Column na obsługę przewijania w pionie, stosując verticalScroll.

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

Zasadniczo preferuj gotowe modyfikatory gestów zamiast niestandardowej obsługi gestów. Modyfikatory dodają więcej funkcji do obsługi zdarzeń wskaźnika. Na przykład modyfikator clickable nie tylko dodaje wykrywanie naciśnięć i kliknięć, ale też informacje semantyczne, wskazówki wizualne dotyczące interakcji, najechania kursorem, zaznaczenia i obsługi klawiatury. Możesz sprawdzić kod źródłowy clickable, aby zobaczyć, jak dodawana jest ta funkcja.

Dodawanie niestandardowego gestu do dowolnych funkcji kompozycyjnych za pomocą modyfikatora pointerInput

Nie każdy gest jest implementowany za pomocą gotowego modyfikatora gestów. Nie możesz na przykład użyć modyfikatora, aby zareagować na przeciągnięcie po przytrzymaniu, kliknięcie z klawiszem Control lub kliknięcie trzema palcami. Zamiast tego możesz napisać własny moduł obsługi gestów, który będzie rozpoznawać te niestandardowe gesty. Możesz utworzyć moduł obsługi gestów za pomocą modyfikatora pointerInput, który zapewnia dostęp do nieprzetworzonych zdarzeń wskaźnika.

Ten kod nasłuchuje nieprzetworzonych zdarzeń 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}"
                            }
                        }
                    }
                }
        )
    }
}

Jeśli podzielisz ten fragment kodu, jego podstawowe elementy to:

  • Modyfikator pointerInput. Przekazujesz mu co najmniej 1 klucz. Gdy wartość jednego z tych kluczy ulegnie zmianie, funkcja lambda zawartości modyfikatora zostanie ponownie wykonana. Próbka przekazuje do funkcji kompozycyjnej opcjonalny filtr. Jeśli wartość tego filtra ulegnie zmianie, moduł obsługi zdarzeń wskaźnika powinien zostać ponownie wykonany, aby upewnić się, że rejestrowane są właściwe zdarzenia.
  • awaitPointerEventScope tworzy zakres współprogramu, który może służyć do oczekiwania na zdarzenia wskaźnika.
  • awaitPointerEvent zawiesza współprogram do czasu wystąpienia następnego zdarzenia wskaźnika.

Chociaż nasłuchiwanie nieprzetworzonych zdarzeń wejściowych jest bardzo przydatne, napisanie niestandardowego gestu na podstawie tych nieprzetworzonych danych jest skomplikowane. Aby uprościć tworzenie gestów niestandardowych, dostępnych jest wiele metod pomocniczych.

Wykrywanie pełnych gestów

Zamiast obsługiwać nieprzetworzone zdarzenia wskaźnika, możesz nasłuchiwać określonych gestów i odpowiednio na nie reagować. AwaitPointerEventScope udostępnia metody nasłuchiwania:

Są to detektory najwyższego poziomu, więc nie możesz dodać wielu detektorów w ramach jednego modyfikatora pointerInput. Ten fragment kodu wykrywa tylko kliknięcia, a nie przeciągnięcia:

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 instancji 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ń na gest

Z definicji gesty zaczynają się od zdarzenia naciśnięcia wskaźnika. Zamiast pętli while(true), która przechodzi przez każde nieprzetworzone zdarzenie, możesz użyć metody pomocniczej awaitEachGesture. Metoda awaitEachGesture ponownie uruchamia blok zawierający, gdy wszystkie wskaźniki zostaną podniesione, 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 warto używać awaitEachGesture, chyba że reagujesz na zdarzenia wskaźnika bez identyfikowania gestów. Przykładem jest hoverable, który nie reaguje na zdarzenia naciśnięcia lub zwolnienia wskaźnika – musi tylko wiedzieć, kiedy wskaźnik wchodzi w jego granice lub je opuszcza.

Czekanie na określone zdarzenie lub gest podrzędny

Istnieje zestaw metod, które pomagają identyfikować wspólne części gestów:

Zastosuj obliczenia dla zdarzeń uwzględniających różne interakcje

Gdy użytkownik wykonuje gest wielodotykowy za pomocą więcej niż jednego wskaźnika, trudno jest zrozumieć wymaganą transformację na podstawie nieprzetworzonych wartości. Jeśli modyfikator transformable lub metody detectTransformGestures nie zapewniają wystarczającej kontroli nad danym przypadkiem użycia, możesz nasłuchiwać nieprzetworzonych zdarzeń i stosować do nich obliczenia. Te metody pomocnicze to calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation i calculateZoom.

Wysyłanie zdarzeń i test pozycji wskaźnika

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

  • Zdarzenia wskaźnika są wysyłane do hierarchii funkcji kompozycyjnych. W momencie, gdy nowy wskaźnik wywoła pierwsze zdarzenie, system rozpocznie test pozycji wskaźnika w „kwalifikujących się” elementach kompozycyjnych. Funkcja kompozycyjna jest uznawana za kwalifikującą się, gdy ma zdolność obsługi danych wejściowych ze wskaźnika. Test pozycji wskaźnika przebiega od góry drzewa interfejsu do dołu. Funkcja kompozycyjna jest „trafiona”, gdy zdarzenie wskaźnika wystąpiło w jej granicach. W wyniku tego procesu powstaje łańcuch funkcji kompozycyjnych, które przechodzą test pozycji wskaźnika.
  • Domyślnie, gdy na tym samym poziomie drzewa znajduje się kilka kwalifikujących się elementów kompozycyjnych, „trafiony” jest tylko element kompozycyjny o najwyższym indeksie Z. Jeśli na przykład dodasz do elementu Box 2 nakładające się elementy kompozycyjne Button, tylko ten narysowany na górze będzie otrzymywać zdarzenia wskaźnika. Teoretycznie możesz zastąpić to zachowanie, tworząc własną implementację PointerInputModifierNode i ustawiając wartość sharePointerInputWithSiblings na „true”.
  • Kolejne zdarzenia dotyczące tego samego wskaźnika są wysyłane do tego samego łańcucha funkcji kompozycyjnych i przepływają zgodnie z logiką propagacji zdarzeń. System nie przeprowadza już testów pozycji wskaźnika. Oznacza to, że każdy element kompozycyjny w łańcuchu otrzymuje wszystkie zdarzenia dotyczące tego wskaźnika, nawet jeśli występują one poza granicami tego elementu kompozycyjnego. Komponenty, które nie znajdują się w łańcuchu, nigdy nie otrzymują zdarzeń wskaźnika, nawet jeśli wskaźnik znajduje się w ich granicach.

Zdarzenia najechania, wywoływane przez najechanie myszą lub rysikiem, są wyjątkiem od zdefiniowanych tutaj reguł. Zdarzenia najechania kursorem są wysyłane do wszystkich funkcji kompozycyjnych, które zostały trafione. Gdy użytkownik najedzie wskaźnikiem z granic jednego elementu kompozycyjnego na następny, zdarzenia nie będą wysyłane do pierwszego elementu kompozycyjnego, ale do nowego.

Wykorzystanie zdarzeń

Jeśli do więcej niż 1 komponentu kompozycyjnego przypisany jest moduł obsługi gestów, te moduły nie powinny być ze sobą w konflikcie. Spójrz na przykład na ten interfejs:

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

Gdy użytkownik kliknie przycisk zakładki, funkcja lambda onClick przycisku obsłuży ten gest. Gdy użytkownik kliknie dowolną inną część elementu listy, ListItem obsłuży ten gest i przejdzie do artykułu. Jeśli chodzi o dane wejściowe ze wskaźnika, przycisk musi wykorzystać to zdarzenie, aby jego element nadrzędny wiedział, że nie musi już na nie reagować. Gesty zawarte w gotowych komponentach i typowe modyfikatory gestów obejmują to zachowanie, ale jeśli piszesz własny gest niestandardowy, musisz ręcznie wykorzystywać zdarzenia. Możesz to zrobić za pomocą 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 propagacji do innych funkcji kompozycyjnych. Zamiast tego element kompozycyjny musi wyraźnie ignorować wykorzystane zdarzenia. Podczas pisania gestów niestandardowych należy sprawdzić, czy zdarzenie zostało już wykorzystane 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
            }
        }
    }
}

Propagacja zdarzeń

Jak wspomnieliśmy wcześniej, zmiany wskaźnika są przekazywane do każdego elementu kompozycyjnego, który zostanie przez niego trafiony. Jeśli jednak istnieje więcej niż 1 taki element, w jakiej kolejności zdarzenia są rozpowszechniane? Jeśli weźmiesz przykład z ostatniej sekcji, ten interfejs użytkownika zostanie przekształcony w to drzewo interfejsu, w którym tylko ListItem i Button reagują na zdarzenia wskaźnika:

Struktura drzewa. Górna warstwa to ListItem, druga warstwa zawiera Image, Column i Button, a Column dzieli się na 2 elementy Text. ListItem i Button są wyróżnione.

Zdarzenia wskaźnika przechodzą przez każdy z tych komponentów kompozycyjnych 3 razy podczas 3 „przejść”:

  • W pierwszym przebiegu zdarzenie przepływa od góry drzewa interfejsu do dołu. Ten proces umożliwia rodzicowi przechwycenie zdarzenia, zanim dziecko będzie mogło je wykorzystać. Na przykład etykietki muszą przechwytywać przytrzymanie, zamiast przekazywać je do elementów podrzędnych. W naszym przykładzie ListItem otrzymuje zdarzenie przed Button.
  • W głównym przebiegu zdarzenie przepływa od węzłów liści drzewa interfejsu do korzenia drzewa interfejsu. W tej fazie zwykle wykorzystywane są gesty. Jest to domyślny etap podczas nasłuchiwania zdarzeń. Obsługa gestów w tym przebiegu oznacza, że węzły liści mają pierwszeństwo przed węzłami nadrzędnymi, co jest najbardziej logicznym zachowaniem w przypadku większości gestów. W naszym przykładzie Button otrzymuje zdarzenie przed ListItem.
  • W ostatnim przebiegu zdarzenie przepływa jeszcze raz od góry drzewa interfejsu do węzłów liści. Ten przepływ umożliwia elementom znajdującym się wyżej w stosie reagowanie na wykorzystanie zdarzenia przez element nadrzędny. Na przykład przycisk usuwa efekt fali, gdy naciśnięcie zmienia się w przeciąganie przewijanego elementu nadrzędnego.

Wizualnie przepływ zdarzeń można przedstawić w ten sposób:

Po wykorzystaniu zmiany danych wejściowych te informacje są przekazywane od tego momentu w przepływie:

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 await zwraca to samo identyczne zdarzenie, chociaż dane o wykorzystaniu mogły się zmienić.

Testowanie gestów

W metodach testowych możesz ręcznie wysyłać zdarzenia wskaźnika za pomocą metody performTouchInput. Umożliwia to wykonywanie gestów wyższego poziomu (takich jak uszczypnięcie lub długie kliknięcie) lub gestów niższego poziomu (takich jak 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: