Comprendere i gesti

Esistono diversi termini e concetti che è importante comprendere quando si lavora sulla gestione dei gesti in un'applicazione. Questa pagina spiega i termini puntatori, eventi puntatore e gesti e introduce i diversi livelli di astrazione per i gesti. Inoltre, approfondisce il consumo e la propagazione degli eventi.

Definizioni

Per comprendere i vari concetti di questa pagina, devi conoscere alcuni dei termini utilizzati:

  • Puntatore: un oggetto fisico che puoi utilizzare per interagire con la tua applicazione. Per i dispositivi mobili, il puntatore più comune è il dito che interagisce con il touchscreen. In alternativa, puoi utilizzare uno stilo al posto del dito. Per gli schermi di grandi dimensioni, puoi utilizzare un mouse o un trackpad per interagire indirettamente con il display. Un dispositivo di input deve essere in grado di "puntare" a una coordinata per essere considerato un puntatore, quindi una tastiera, ad esempio, non può essere considerata un puntatore. In Compose, il tipo di puntatore è 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 toccare lo schermo con un dito o trascinare un mouse, attiva un evento. In Compose, tutte le informazioni pertinenti per un evento di questo tipo sono contenute nella classe PointerEvent.
  • Gesto: una sequenza di eventi del puntatore che può essere interpretata come una singola azione. Ad esempio, un gesto di tocco può essere considerato una sequenza di un evento di pressione seguito da un evento di rilascio. Esistono gesti comuni utilizzati da molte app, come tocco, trascinamento o trasformazione, ma puoi anche creare un gesto personalizzato quando necessario.

Diversi livelli di astrazione

Jetpack Compose offre diversi livelli di astrazione per la gestione dei gesti. Al livello più alto si trova l'assistenza per i componenti. I componibili come Button includono automaticamente il supporto dei gesti. Per aggiungere il supporto dei gesti ai componenti personalizzati, puoi aggiungere modificatori dei gesti come clickable a elementi componibili arbitrari. Infine, se hai bisogno di un gesto personalizzato, puoi utilizzare il modificatore pointerInput.

Come regola generale, crea sul livello di astrazione più alto che offre la funzionalità di cui hai bisogno. In questo modo, puoi usufruire delle best practice incluse nel livello. Ad esempio, Button contiene più informazioni semantiche, utilizzate per l'accessibilità, rispetto a clickable, che contiene più informazioni rispetto a un'implementazione pointerInput non elaborata.

Supporto dei componenti

Molti componenti pronti all'uso in Compose includono un qualche tipo di gestione interna dei gesti. Ad esempio, un LazyColumn risponde ai gesti di trascinamento scorrendo i contenuti, un Button mostra un'onda quando lo premi e il componente SwipeToDismiss contiene la logica di scorrimento per ignorare un elemento. Questo tipo di gestione dei gesti funziona automaticamente.

Oltre alla gestione interna dei gesti, molti componenti richiedono anche che il chiamante gestisca il gesto. Ad esempio, un Button rileva automaticamente i tocchi e attiva un evento di clic. Passi una funzione lambda onClick a Button per reagire al gesto. Analogamente, aggiungi una funzione lambda onValueChange a un Slider per reagire al trascinamento del cursore da parte dell'utente.

Quando si adattano al tuo caso d'uso, preferisci i gesti inclusi nei componenti, in quanto includono il supporto pronto all'uso per lo stato attivo e l'accessibilità e sono ben testati. Ad esempio, un Button è contrassegnato in modo speciale in modo che i servizi di accessibilità lo descrivano correttamente come un pulsante, anziché come un qualsiasi elemento cliccabile:

// 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, consulta Accessibilità in Compose.

Aggiungi gesti specifici a elementi componibili arbitrari con i modificatori

Puoi applicare i modificatori dei gesti a qualsiasi composable arbitrario per fare in modo che il composable ascolti i gesti. Ad esempio, puoi consentire a un Box generico di gestire i gesti di tocco rendendolo clickable o consentire a un Column di gestire lo scorrimento verticale applicando verticalScroll.

Esistono molti modificatori per gestire diversi tipi di gesti:

Come regola generale, preferisci i modificatori dei gesti pronti all'uso rispetto alla gestione dei gesti personalizzati. I modificatori aggiungono ulteriori funzionalità alla gestione degli eventi del puntatore puro. Ad esempio, il modificatore clickable non solo aggiunge il rilevamento di pressioni e tocchi, ma aggiunge anche informazioni semantiche, indicazioni visive sulle 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 a composabili arbitrari con il modificatore pointerInput

Non tutti i gesti vengono implementati con un modificatore di gesti pronto all'uso. Ad esempio, non puoi utilizzare un modificatore per reagire a un trascinamento dopo una pressione prolungata, un clic con il tasto Ctrl o un tocco con tre dita. Puoi invece scrivere il tuo gestore dei gesti per identificare questi gesti personalizzati. Puoi creare un gestore di gesti con il modificatore pointerInput, che ti consente di accedere agli eventi puntatore non elaborati.

Il seguente codice ascolta gli eventi del 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. Devi passare una o più chiavi. Quando il valore di una di queste chiavi cambia, la funzione lambda dei contenuti del modificatore viene eseguita di nuovo. L'esempio passa un filtro facoltativo al composable. Se il valore di questo filtro cambia, il gestore di eventi del puntatore deve essere eseguito nuovamente per assicurarsi che vengano registrati gli eventi corretti.
  • awaitPointerEventScope crea un ambito di coroutine che può essere utilizzato per attendere gli eventi del puntatore.
  • awaitPointerEvent sospende la coroutine fino al verificarsi di un evento puntatore successivo.

Sebbene l'ascolto di eventi di input non elaborati sia efficace, è 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 i gesti completi

Anziché gestire gli eventi di puntatore non elaborati, puoi ascoltare gesti specifici e rispondere di conseguenza. AwaitPointerEventScope fornisce metodi per l'ascolto di:

Si tratta di rilevatori di primo livello, quindi non puoi aggiungere più rilevatori all'interno di un modificatore pointerInput. Il seguente snippet 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 composable, utilizza istanze di modificatore 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" }
            }
    )
}

Gestisci gli eventi per gesto

Per definizione, i gesti iniziano con un evento pointerdown. Puoi utilizzare il metodo helper awaitEachGesture anziché il loop while(true) che passa attraverso ogni evento non elaborato. Il metodo awaitEachGesture riavvia il blocco contenitore quando tutti i puntatori sono stati sollevati, indicando 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, quasi sempre è preferibile utilizzare awaitEachGesture, a meno che tu non stia rispondendo a eventi del puntatore senza identificare i gesti. Un esempio è hoverable, che non risponde agli eventi di pressione o rilascio del puntatore, ma deve solo sapere quando un puntatore entra o esce dai suoi limiti.

Attendi un evento o un gesto secondario specifico

Esiste un insieme di metodi che aiuta a identificare le parti comuni dei gesti:

Applica i calcoli per gli eventi multi-touch

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

Invio di eventi e hit test

Non tutti gli eventi del puntatore vengono inviati a ogni modificatore pointerInput. L'invio degli eventi funziona nel seguente modo:

  • Gli eventi del puntatore vengono inviati a una gerarchia componibile. Nel momento in cui un nuovo puntatore attiva il suo primo evento puntatore, il sistema avvia l'hit test dei componibili "idonei". Un composable è considerato idoneo quando ha funzionalità di gestione dell'input del puntatore. I flussi di hit test vanno dall'alto verso il basso dell'albero della UI. Un componibile viene "colpito" quando l'evento del puntatore si verifica entro i limiti di quel componibile. Questo processo genera una catena di componibili che superano l'hit test.
  • Per impostazione predefinita, quando sono presenti più componibili idonei allo stesso livello dell'albero, viene "colpito" solo il componibile con l'indice z più alto. Ad esempio, quando aggiungi due componibili Button sovrapposti a un Box, solo quello disegnato in alto riceve gli eventi del puntatore. In teoria, puoi eseguire l'override di questo comportamento creando la tua implementazione di PointerInputModifierNode e impostando sharePointerInputWithSiblings su true.
  • Gli eventi successivi per lo stesso puntatore vengono inviati alla stessa catena di componibili e seguono la logica di propagazione degli eventi. Il sistema non esegue altri hit test per questo puntatore. Ciò significa che ogni componibile della catena riceve tutti gli eventi per quel puntatore, anche quando si verificano al di fuori dei limiti del componibile. I componibili che non fanno parte della catena non ricevono mai eventi del puntatore, anche quando il puntatore si trova all'interno dei loro limiti.

Gli eventi di passaggio del mouse, attivati dal passaggio del mouse o dello stilo, sono un'eccezione alle regole definite qui. Gli eventi di passaggio del mouse vengono inviati a qualsiasi composable che colpiscono. Quindi, quando un utente passa il puntatore dai limiti di un composable a quello successivo, anziché inviare gli eventi al primo composable, gli eventi vengono inviati al nuovo composable.

Consumo di eventi

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

Elemento dell'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 un'altra parte dell'elemento dell'elenco, ListItem gestisce il gesto e passa all'articolo. In termini di input del puntatore, il pulsante deve utilizzare questo evento, in modo che il relativo elemento principale sappia di non dover più reagire. I gesti inclusi nei componenti pronti all'uso e i modificatori dei gesti comuni includono questo comportamento di consumo, ma se stai scrivendo un gesto personalizzato, devi consumare gli eventi manualmente. A questo scopo, 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 ad altri elementi componibili. Un composable deve invece ignorare esplicitamente gli eventi consumati. Quando scrivi gesti personalizzati, devi verificare 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 degli eventi

Come accennato in precedenza, le modifiche del puntatore vengono passate a ogni composable che colpisce. Tuttavia, se esistono più composabili di questo tipo, in quale ordine si propagano gli eventi? Se prendiamo l'esempio della sezione precedente, questa UI si traduce nel seguente albero della UI, in cui solo ListItem e Button rispondono agli eventi del puntatore:

Struttura ad albero. Lo strato superiore è ListItem, il secondo strato contiene Image, Column e Button e Column si divide in due Text. ListItem e Button sono evidenziati.

Gli eventi del puntatore passano attraverso ciascuno di questi composabili tre volte, durante tre "passaggi":

  • Nella fase iniziale, l'evento passa dalla parte superiore dell'albero della UI alla parte inferiore. Questo flusso consente a un elemento padre di intercettare un evento prima che l'elemento figlio possa utilizzarlo. Ad esempio, i tooltip devono intercettare una pressione prolungata anziché trasmetterla ai relativi elementi secondari. Nel nostro esempio, ListItem riceve l'evento prima di Button.
  • Nella fase principale, l'evento passa dai nodi foglia dell'albero della UI alla radice dell'albero della UI. In questa fase, in genere, vengono consumati i gesti ed è il passaggio predefinito durante l'ascolto degli eventi. La gestione dei gesti in questa fase significa che i nodi foglia hanno la precedenza sui relativi nodi principali, 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 passa ancora una volta dalla parte superiore dell'albero della UI ai nodi foglia. Questo flusso consente agli elementi più in alto nello stack di rispondere al consumo di eventi da parte del relativo elemento principale. Ad esempio, un pulsante rimuove l'indicazione di ripple quando una pressione si trasforma in un trascinamento del relativo elemento padre scorrevole.

Visivamente, il flusso di eventi può essere rappresentato come segue:

Una volta consumata una modifica dell'input, queste informazioni vengono trasmesse da quel punto del 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, lo stesso identico evento viene restituito da ciascuna di queste chiamate al metodo await, anche se i dati relativi al consumo potrebbero essere cambiati.

Testa i gesti

Nei metodi di test, puoi inviare manualmente gli eventi del puntatore utilizzando il metodo performTouchInput. In questo modo puoi eseguire gesti completi di livello superiore (come pizzicare o fare clic e tenere premuto) o gesti di livello inferiore (come spostare il cursore di un certo numero 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: