Comprendere i gesti

Quando si lavora alla gestione dei gesti in un'applicazione, è importante comprendere vari termini e concetti. Questa pagina illustra i termini relativi a puntatori, eventi di puntamento e gesti e introduce i diversi livelli di astrazione per i gesti. Approfondisce inoltre il consumo e la propagazione degli eventi.

Definizioni

Per comprendere i vari concetti di questa pagina, devi comprendere alcune della terminologia utilizzata:

  • Puntatore: un oggetto fisico che puoi utilizzare per interagire con l'applicazione. Per i dispositivi mobili, il puntatore più comune è l'interazione del dito con il touchscreen. In alternativa, puoi usare uno stilo per sostituire il dito. Per gli schermi di grandi dimensioni, puoi utilizzare un mouse o un trackpad per interagire indirettamente con il display. Per essere considerato un puntatore, un dispositivo di input deve essere in grado di "puntare" su una coordinata; pertanto, una tastiera, ad esempio, non può essere considerata un puntatore. In Scrivi, il tipo di puntatore viene incluso nelle modifiche del puntatore utilizzando PointerType.
  • Evento puntatore: descrive un'interazione di basso livello di uno o più puntatori con l'applicazione in un determinato momento. Qualsiasi interazione con il puntatore, ad esempio un dito sullo schermo o il trascinamento del mouse, attiva un evento. In Compose, tutte le informazioni pertinenti per un evento di questo tipo si trovano nella classe PointerEvent.
  • Gesto: una sequenza di eventi puntatore che può essere interpretata come una singola azione. Ad esempio, un gesto di tocco può essere considerato una sequenza di un evento giù seguito da un evento Up. Esistono gesti comuni utilizzati da molte app, come il tocco, il trascinamento o la trasformazione, ma puoi anche creare gesti personalizzati quando necessario.

Diversi livelli di astrazione

Jetpack Compose offre diversi livelli di astrazione per la gestione dei gesti. Il livello principale è il supporto dei componenti. Gli elementi componibili come Button includono automaticamente il supporto dei gesti. Per aggiungere il supporto dei gesti a componenti personalizzati, puoi aggiungere modificatori di gesti come clickable ai componibili arbitrari. Infine, se ti serve un gesto personalizzato, puoi utilizzare il modificatore pointerInput.

Come regola, sfrutta il massimo livello di astrazione che offre la funzionalità di cui hai bisogno. In questo modo, puoi trarre vantaggio dalle best practice incluse nel livello. Ad esempio, Button contiene più informazioni semantiche, utilizzato per l'accessibilità, rispetto a clickable, che contiene più informazioni rispetto a un'implementazione pointerInput non elaborata.

Supporto dei componenti

Molti componenti predefiniti di Compose includono una sorta di gestione interna dei gesti. Ad esempio, una LazyColumn risponde ai gesti di trascinamento facendo scorrere i contenuti, un Button mostra un'onda quando ci premi e il componente SwipeToDismiss contiene una logica di scorrimento per ignorare un elemento. Questo tipo di gestione dei gesti funziona automaticamente.

Oltre alla gestione dei gesti interna, molti componenti richiedono anche che il chiamante gestisca il gesto. Ad esempio, una Button rileva automaticamente i tocchi e attiva un evento di clic. Passi una lambda di onClick a Button per reagire al gesto. Allo stesso modo, aggiungi una funzione lambda onValueChange a Slider per reagire all'utente trascinando il punto di manipolazione del cursore.

Se è adatto al tuo caso d'uso, preferisci i gesti inclusi nei componenti, che includono supporto immediato per la messa a fuoco e l'accessibilità e sono ben testati. Ad esempio, Button viene contrassegnato in un modo speciale per consentire ai servizi di accessibilità di descriverlo correttamente come pulsante, anziché come elemento selezionabile:

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

Per scoprire di più sull'accessibilità in Compose, vedi Accessibilità in Compose.

Aggiungere gesti specifici a componibili arbitrari con i modificatori

Puoi applicare modificatori di gesti a qualsiasi componibile arbitrario per rendere il componibile di ascolto dei gesti. Ad esempio, puoi consentire a un Box generico di gestire i gesti di tocco impostandolo su clickable o lasciare che sia un Column a gestire lo scorrimento verticale applicando verticalScroll.

Esistono molti modificatori per gestire diversi tipi di gesti:

Come regola, preferisci i modificatori di gesti pronti all'uso rispetto alla gestione dei gesti personalizzata. Oltre alla gestione degli eventi di puntatore pura, i modificatori aggiungono altre funzionalità. Ad esempio, il modificatore clickable non solo aggiunge il rilevamento di pressioni e tocchi, ma anche informazioni semantiche, indicazioni visive su interazioni, passaggio del mouse, stato attivo e supporto della tastiera. Puoi controllare il codice sorgente di clickable per vedere come viene aggiunta la funzionalità.

Aggiungi un gesto personalizzato ai componibili arbitrari con il modificatore pointerInput

Non tutti i gesti sono implementati con un modificatore di gesti predefinito. Ad esempio, non puoi utilizzare un modificatore per reagire a un trascinamento dopo una pressione prolungata, un clic Ctrl o un tocco con tre dita. Per identificare questi gesti personalizzati, puoi scrivere un gestore di gesti personalizzato. Puoi creare un gestore di gesti con il modificatore pointerInput, che ti permette di accedere agli eventi puntatore non elaborati.

Il codice seguente rimane in ascolto degli eventi di puntatore non elaborati:

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

Se suddividi questo snippet, i componenti principali sono:

  • Il modificatore pointerInput. Passi una o più chiavi. Quando il valore di una di queste chiavi cambia, la funzione lambda dei contenuti di modifica viene nuovamente eseguita. L'esempio passa un filtro facoltativo al componibile. Se il valore di quel filtro cambia, il gestore di eventi puntatore deve essere rieseguito per garantire che vengano registrati gli eventi corretti.
  • awaitPointerEventScope crea un ambito a coroutine che può essere utilizzato per attendere eventi di puntatore.
  • awaitPointerEvent sospende la coroutine fino a quando non si verifica un evento di puntamento successivo.

Sebbene sia molto utile ascoltare eventi di input non elaborati, è anche complesso scrivere un gesto personalizzato basato su questi dati non elaborati. Per semplificare la creazione di gesti personalizzati, sono disponibili molti metodi di utilità.

Rileva gesti completi

Anziché gestire gli eventi di puntamento non elaborati, puoi rimanere in ascolto dei gesti specifici che si verificano e rispondere in modo appropriato. Il AwaitPointerEventScope fornisce metodi per ascoltare:

Si tratta di rilevatori di primo livello, pertanto non puoi aggiungere più rilevatori all'interno di un solo modificatore pointerInput. Lo snippet seguente rileva solo i tocchi, non i trascinamenti:

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

Internamente, il metodo detectTapGestures blocca la coroutine e il secondo rilevatore non viene mai raggiunto. Se devi aggiungere più di un listener di gesti a un componibile, utilizza invece istanze di modifica pointerInput separate:

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

Gestire gli eventi per gesto

Per definizione, i gesti iniziano con un evento puntatore verso il basso. Puoi utilizzare il metodo helper awaitEachGesture anziché il loop while(true) che trasmette ogni evento non elaborato. Il metodo awaitEachGesture riavvia il blocco contenitore quando tutti i puntatori sono stati sollevati, ad indicare che il gesto è stato completato:

@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 pratica, vorrai usare quasi sempre awaitEachGesture, a meno che tu non risponda agli eventi di puntamento senza identificare i gesti. Un esempio è hoverable, che non risponde agli eventi di puntatore verso il basso o verso l'alto, ma deve solo sapere quando un puntatore entra o esce dai propri limiti.

Attendi un evento o un gesto secondario specifico

È disponibile una serie di metodi che consentono di identificare gli elementi comuni dei gesti:

Applicare calcoli per gli eventi multi-touch

Quando un utente esegue un gesto multi-touch utilizzando più di un puntatore, è complesso comprendere la trasformazione richiesta in base ai valori non elaborati. Se il modificatore transformable o i metodi detectTransformGestures non offrono un controllo abbastanza granulare per il tuo caso d'uso, puoi ascoltare gli eventi non elaborati e applicare calcoli su questi eventi. Questi metodi di supporto sono calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation e calculateZoom.

Invio di eventi e test degli hit

Non tutti gli eventi di puntatore vengono inviati a ogni modificatore pointerInput. L'invio di eventi funziona come segue:

  • Gli eventi puntatore vengono inviati a una gerarchia componibile. Nel momento in cui un nuovo puntatore attiva il suo primo evento di puntatore, il sistema inizia a eseguire l'hit test degli componibili "idonei". Un componibile è considerato idoneo quando dispone di funzionalità di gestione dell'input del puntatore. L'hit test è eseguito dall'alto verso il basso della struttura ad albero dell'interfaccia utente. Un componibile è un "hit" quando l'evento puntatore si è verificato entro i limiti dell'elemento componibile. Questo processo genera una catena di componibili che eseguono il test positivo.
  • Per impostazione predefinita, quando sono presenti più elementi componibili idonei nello stesso livello dell'albero, solo il componibile con lo z-index più alto è "hit". Ad esempio, quando aggiungi due elementi componibili Button sovrapposti a una Box, solo l'elemento disegnato nella parte superiore riceve eventuali eventi puntatore. In teoria, puoi eseguire l'override di questo comportamento creando una tua implementazione di PointerInputModifierNode e impostando sharePointerInputWithSiblings su true.
  • Ulteriori eventi per lo stesso puntatore vengono inviati alla stessa catena di elementi componibili e si basano sulla logica di propagazione degli eventi. Il sistema non esegue altri test degli hit per questo puntatore. Ciò significa che ogni elemento componibile nella catena riceve tutti gli eventi per quel puntatore, anche quando si verificano al di fuori dei limiti dell'elemento componibile. I componibili che non sono nella catena non ricevono mai eventi puntatore, anche quando il puntatore si trova all'interno dei propri limiti.

Gli eventi di passaggio del mouse, attivati dal passaggio del mouse o dello stilo, sono un'eccezione alle regole qui definite. Gli eventi di passaggio del mouse vengono inviati a qualsiasi componibile raggiunto. Di conseguenza, quando un utente passa il puntatore del mouse dai margini di un elemento componibile a quello successivo, invece di inviare gli eventi al primo elemento componibile, gli eventi vengono inviati al nuovo elemento componibile.

Fruizione di eventi

Quando a più di un elemento componibile è assegnato un gestore di gesti, questi gestori non devono entrare in conflitto. Ad esempio, dai un'occhiata alla seguente UI:

Voce di elenco con un'immagine, una colonna con due testi e un pulsante.

Quando un utente tocca il pulsante dei preferiti, la funzione lambda onClick del pulsante gestisce il gesto. Quando un utente tocca qualsiasi altra parte dell'elemento dell'elenco, l'ListItem gestisce il gesto e accede all'articolo. In termini di input del puntatore, il pulsante deve consumare questo evento, in modo che l'elemento padre non sappia più che deve reagire. I gesti inclusi nei componenti pronti all'uso e nei modificatori di gesti comuni includono questo comportamento di consumo, ma se stai scrivendo un gesto personalizzato devi utilizzare gli eventi manualmente. Per farlo, utilizza il metodo PointerInputChange.consume:

Modifier.pointerInput(Unit) {

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

L'utilizzo di un evento non interrompe la propagazione dell'evento in altri componibili. Un componibile deve invece ignorare esplicitamente gli eventi consumati. Quando scrivi gesti personalizzati, devi controllare se un evento è già stato utilizzato da un altro elemento:

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

Propagazione di eventi

Come accennato in precedenza, le modifiche al puntatore vengono passate a ogni componibile interessato. Ma se esistono più elementi componibili di questo tipo, in quale ordine si propagano gli eventi? Prendiamo l'esempio dell'ultima sezione. Questa UI diventa la seguente struttura ad albero dell'interfaccia utente, in cui solo ListItem e Button rispondono agli eventi puntatore:

Struttura ad albero. Il livello superiore è ListItem, il secondo è composto da Immagine, Colonna e Pulsante, mentre la colonna è suddivisa in due testi. ElencoItem e Pulsante sono evidenziati.

Gli eventi puntatore scorrono attraverso ciascuno di questi componibili tre volte, durante tre "passi":

  • Nel passaggio iniziale, l'evento scorre dalla parte superiore della struttura ad albero dell'interfaccia utente verso il basso. Questo flusso consente a un elemento padre di intercettare un evento prima che l'elemento figlio possa utilizzarlo. Ad esempio, le descrizione comando devono intercettare una pressione prolungata invece di trasmetterla ai figli. Nel nostro esempio, ListItem riceve l'evento prima di Button.
  • Nel passaggio principale, l'evento passa dai nodi foglia dell'albero dell'interfaccia utente alla directory principale dell'albero dell'interfaccia utente. Questa è la fase in cui solitamente utilizzi i gesti ed è il pass predefinito per l'ascolto degli eventi. La gestione dei gesti in questa tessera significa che i nodi foglia hanno la precedenza sui loro genitori, il che è il comportamento più logico per la maggior parte dei gesti. Nel nostro esempio, Button riceve l'evento prima di ListItem.
  • Nel passaggio finale, l'evento scorre ancora una volta dalla parte superiore dell'albero dell'interfaccia utente ai nodi foglia. Questo flusso consente agli elementi più in alto nello stack di rispondere al consumo di eventi da parte dell'elemento padre. Ad esempio, un pulsante rimuove l'indicazione dell'onda quando una pressione si trasforma in un trascinamento dell'elemento principale scorrevole.

A livello visivo, il flusso degli eventi può essere rappresentato come segue:

Una volta utilizzata una modifica dell'input, queste informazioni vengono trasmesse da quel punto nel flusso in poi:

Nel codice, puoi specificare la tessera che ti interessa:

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

In questo snippet di codice, ciascuna di queste chiamate al metodo await restituisce lo stesso evento identico, anche se i dati relativi al consumo potrebbero essere cambiati.

Prova gesti

Nei tuoi metodi di test, puoi inviare manualmente eventi puntatore utilizzando il metodo performTouchInput. In questo modo puoi eseguire gesti completi a livello più alto (come pizzicare o fare clic lungo) o gesti a basso livello (ad esempio spostare il cursore di una determinata quantità di pixel):

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

Per altri esempi, consulta la documentazione di performTouchInput.

Scopri di più

Puoi scoprire di più sui gesti in Jetpack Compose nelle seguenti risorse: