Eventi UI

Gli eventi UI sono azioni che devono essere gestite nel livello UI, dall'UI o dal ViewModel. Il tipo di eventi più comune sono gli eventi utente. L'utente genera eventi utente interagendo con l'app, ad esempio toccando lo schermo o generando gesti. L'UI utilizza questi eventi tramite callback come le espressioni lambda definite su diversi elementi componibili.

Il ViewModel è in genere responsabile della gestione della logica di business di un particolare evento utente, ad esempio l'utente che fa clic su un pulsante per aggiornare alcuni dati. In genere, il ViewModel gestisce questa operazione esponendo le funzioni che l'UI può chiamare. Gli eventi utente potrebbero anche avere una logica di comportamento dell'UI che l'UI può gestire direttamente, ad esempio la navigazione a una schermata diversa o la visualizzazione di un Snackbar.

Mentre la logica di business rimane la stessa per la stessa app su piattaforme mobili o fattori di forma diversi, la logica di comportamento dell'UI è un dettaglio di implementazione che potrebbe variare tra questi casi. La pagina del livello UI definisce questi tipi di logica come segue:

  • La logica di business si riferisce a cosa fare con le modifiche dello stato, ad esempio effettuare un pagamento o memorizzare le preferenze dell'utente. In genere, i livelli di dominio e dati gestiscono questa logica. In questa guida, la classe ViewModel dei componenti dell'architettura viene utilizzata come soluzione basata su opinioni per le classi che gestiscono la logica di business.
  • La logica di comportamento dell'UI o logica dell'UI si riferisce a come visualizzare le modifiche dello stato, ad esempio la logica di navigazione o come mostrare i messaggi all'utente. L'UI gestisce questa logica.

Albero decisionale degli eventi UI

Il seguente diagramma mostra un albero decisionale per trovare l'approccio migliore per la gestione di un particolare caso d'uso degli eventi. Il resto di questa guida spiega questi approcci in dettaglio.

Se l'evento ha avuto origine nel ViewModel, aggiorna lo stato dell'UI. Se
    l'evento ha avuto origine nell'interfaccia utente e richiede una logica di business, delega
    la logica di business al ViewModel. Se l'evento ha avuto origine nell'interfaccia utente e
    richiede una logica di comportamento dell'interfaccia utente, modifica lo stato dell'elemento dell'interfaccia utente direttamente
    nell'interfaccia utente.
Figura 1. Albero decisionale per la gestione degli eventi.

Gestire gli eventi utente

L'UI può gestire direttamente gli eventi utente se questi eventi sono correlati alla modifica dello stato di un elemento UI, ad esempio lo stato di un elemento espandibile. Se l'evento richiede l'esecuzione della logica di business, ad esempio l'aggiornamento dei dati sullo schermo, deve essere elaborato dal ViewModel.

L'esempio seguente mostra come vengono utilizzati diversi pulsanti per espandere un elemento UI (logica UI) e per aggiornare i dati sullo schermo (logica di business):

@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // State of whether more details should be shown
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
        // The expand details event is processed by the UI that
        // modifies this composable's internal state.
        onClick = { expanded = !expanded }
        ) {
        val expandText = if (expanded) "Collapse" else "Expand"
        Text("$expandText details")
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the UI's business logic.
        Button(onClick = { viewModel.refreshNews() }) {
            Text("Refresh data")
        }
    }
}

Eventi utente negli elenchi lazy

Se l'azione viene generata più in basso nell'albero dell'UI, ad esempio in un elemento LazyColumn, il ViewModel deve comunque gestire gli eventi utente.

Ad esempio, considera un elenco di elementi selezionabili. Non passare l'istanza ViewModel nell'elemento componibile dell'elenco (MyList), perché questo accoppia strettamente il componente UI ai dettagli di implementazione.

Esporre invece l'evento come parametro della funzione lambda nell'elemento componibile. In questo modo, l'elenco può attivare l'evento senza sapere chi lo gestisce o come.

data class MyItem(val id: Int)

@Composable
fun MyList(
    items: List<String>,
    onItemClick: (MyItem) -> Unit
) {
    Card {
        LazyColumn {
            itemsIndexed(items) { index, string ->
                ListItem(
                    modifier = Modifier.clickable {
                        onItemClick(MyItem(index))
                    },
                    headlineContent = {
                        Text(text = string)
                    }
                )
            }
        }
    }
}

Con questo approccio, l'elemento componibile MyList funziona solo con i dati che visualizza e gli eventi che espone. Non ha accesso al ViewModel. L'evento viene sollevato e passato a un ViewModel in un elemento componibile precedente.

Per saperne di più sulla gestione degli eventi, consulta Eventi in Compose.

Convenzioni di denominazione per le funzioni degli eventi utente e i gestori di eventi

In questa guida, le funzioni ViewModel che gestiscono gli eventi utente vengono denominate con un verbo basato sull'azione che gestiscono, ad esempio: validateInput() o login().

I gestori di eventi in Compose seguono una convenzione di denominazione standard per rendere ovvio il flusso di dati:

  • Nome parametro: on + Verb + Target (ad esempio, onExpandClicked o onValueChange).
  • Espressione lambda: quando si chiama l'elemento componibile, l'espressione lambda è spesso solo l'implementazione di quell'evento.

Gestire gli eventi ViewModel

Le azioni dell'UI che hanno origine dal ViewModel, ovvero gli eventi ViewModel, devono sempre comportare un aggiornamento dello stato dell'UI. Ciò è conforme ai principi del flusso di dati unidirezionale. Rende gli eventi riproducibili dopo le modifiche alla configurazione e garantisce che le azioni dell'UI non vadano perse. Se utilizzi il modulo dello stato salvato , puoi anche rendere gli eventi riproducibili dopo l'interruzione del processo.

Il mapping delle azioni dell'UI allo stato dell'UI non è sempre un processo semplice, ma porta a una logica più semplice. Il tuo processo di pensiero non dovrebbe terminare con la determinazione di come fare in modo che l'UI passi a una schermata specifica, ad esempio. Devi pensare ulteriormente e considerare come rappresentare il flusso utente nello stato dell'UI. In altre parole: non pensare alle azioni che l'UI deve eseguire, ma a come queste azioni influiscono sullo stato dell'UI.

Ad esempio, considera il caso di una schermata di accesso. Potresti modellare lo stato dell'UI di questa schermata come segue:

data class LoginUiState(
    val isLoginInProgress: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

La schermata di accesso reagisce alle modifiche dello stato dell'UI.

class LoginViewModel : ViewModel() {

    var uiState by mutableStateOf(LoginUiState())

    fun tryLogin(username: String, password: String) {
        viewModelScope.launch {
            // Emit a new state indicating that login is in progress
            uiState = uiState.copy(isLoginInProgress = true)

            uiState = if (login(username, password)) {
                // Emit a new state indicating that login was successful
                uiState.copy(isLoginInProgress = false, isUserLoggedIn = true)
            } else {
                // Emit a new state with the error message
                LoginUiState(isLoginInProgress = false, errorMessage = "Login failed")
            }
        }
    }

    private suspend fun login(username: String, password: String): Boolean {
        delay(1000)
        return (username == "Hello" && password == "World!")
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel, onSuccessfulLogin: () -> Unit) {

    val uiState = viewModel.uiState

    LaunchedEffect(uiState) {
        if (uiState.isUserLoggedIn) {
            onSuccessfulLogin()
        }
    }

    if (uiState.isLoginInProgress) {
        CircularProgressIndicator()
    } else {
        LoginForm(
            onLoginAttempt = { username, password ->
                viewModel.tryLogin(username, password)
            },
            errorMessage = uiState.errorMessage
        )
    }
}

Il consumo di eventi può attivare aggiornamenti dello stato

Il consumo di determinati eventi ViewModel nell'UI potrebbe comportare altri aggiornamenti dello stato dell'UI. Ad esempio, quando si mostrano messaggi temporanei sullo schermo per informare l'utente che è successo qualcosa, l'UI deve notificare al ViewModel di attivare un altro aggiornamento dello stato quando il messaggio è stato visualizzato sullo schermo. L'evento che si verifica quando l'utente ha consumato il messaggio (chiudendolo o dopo un timeout) può essere trattato come "input utente" e, di conseguenza, il ViewModel deve esserne a conoscenza. In questa situazione, lo stato dell'UI può essere modellato come segue:

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

Il ViewModel aggiornerà lo stato dell'UI come segue quando la logica di business richiede la visualizzazione di un nuovo messaggio temporaneo per l'utente:

class LatestNewsViewModel(/* ... */) : ViewModel() {

    var uiState by mutableStateOf(LatestNewsUiState())
        private set

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                uiState = uiState.copy(userMessage = "No Internet connection")
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        uiState = uiState.copy(userMessage = null)
    }
}

Il ViewModel non deve sapere come l'UI mostra il messaggio sullo schermo, ma solo che è presente un messaggio utente che deve essere visualizzato. Una volta visualizzato il messaggio temporaneo, l'UI deve notificare al ViewModel, causando un altro aggiornamento dello stato dell'UI per cancellare la proprietà userMessage:

@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show it and notify the ViewModel.
    viewModel.uiState.userMessage?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown()
        }
    }
}

Anche se il messaggio è temporaneo, lo stato dell'UI è una rappresentazione fedele di ciò che viene visualizzato sullo schermo in ogni singolo momento. Il messaggio utente viene visualizzato o meno.

La sezione Il consumo di eventi può attivare aggiornamenti dello stato descrive in dettaglio come utilizzare lo stato dell'UI per visualizzare i messaggi utente sullo schermo. Gli eventi di navigazione sono anche un tipo di evento comune in un'app per Android.

Se l'evento viene attivato nell'UI perché l'utente ha toccato un pulsante, l'UI se ne occupa esponendo l'evento all'elemento componibile chiamante.

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the help screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI
    Button(
        onClick = dropUnlessResumed { onHelp() }
    ) {
        Text("Get help")
    }
}

dropUnlessResumed fa parte della libreria del ciclo di vita e ti consente di eseguire la funzione onHelp solo quando il ciclo di vita è almeno RESUMED.

Se l'input dei dati richiede una convalida della logica di business prima della navigazione, il ViewModel deve esporre questo stato all'UI. L'UI reagirà alla modifica dello stato e navigherà di conseguenza. La sezione Gestire gli eventi ViewModel tratta questo caso d'uso. Ecco un codice simile:

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.tryLogin()
        }
    ) {
        Text("Log in")
    }
    // Rest of the UI

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
    LaunchedEffect(viewModel, lifecycle)  {
        // Whenever the uiState changes, check if the user is logged in and
        // call the `onUserLogin` event when `lifecycle` is at least STARTED
        snapshotFlow { viewModel.uiState }
            .filter { it.isUserLoggedIn }
            .flowWithLifecycle(lifecycle)
            .collect {
                currentOnUserLogIn()
            }
    }
}

Nell'esempio precedente, l'app funziona come previsto perché la destinazione corrente, Accesso, non viene mantenuta nello stack Back. Gli utenti non possono tornare indietro se premono il pulsante Indietro. Tuttavia, nei casi in cui ciò potrebbe accadere, la soluzione richiederebbe una logica aggiuntiva.

Quando un ViewModel imposta uno stato che genera un evento di navigazione dalla schermata A alla schermata B e la schermata A viene mantenuta nello stack Back di navigazione, potrebbe essere necessaria una logica aggiuntiva per non avanzare automaticamente a B. Per implementare questa funzionalità, devi aggiungere uno stato per indicare se l'UI deve passare all'altra schermata. In genere, questo stato viene mantenuto nell'UI perché la logica di navigazione è di competenza dell'UI, non del ViewModel. Per illustrare questo concetto, considera il seguente caso d'uso.

Supponiamo che tu stia seguendo il flusso di registrazione della tua app. Nella schermata di convalida della data di nascita, quando l'utente inserisce una data, questa viene convalidata dal ViewModel quando l'utente tocca il pulsante "Continua". Il ViewModel delega la logica di convalida al livello dati. Se la data è valida, l'utente passa alla schermata successiva. Come funzionalità aggiuntiva, gli utenti possono andare avanti e indietro tra le diverse schermate di registrazione se vogliono modificare alcuni dati. Pertanto, tutte le destinazioni nel flusso di registrazione vengono mantenute nello stesso stack Back. Dati questi requisiti, potresti implementare questa schermata come segue:

class DobValidationViewModel(/* ... */) : ViewModel() {
    var uiState by mutableStateOf(DobValidationUiState())
        private set
}

@Composable
fun DobValidationScreen(
    onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
    viewModel: DobValidationViewModel = viewModel()
) {
    // TextField that updates the ViewModel when a date of birth is selected

    var validationInProgress by rememberSaveable { mutableStateOf(false) }

    Button(
        onClick = {
            viewModel.validateInput()
            validationInProgress = true
        }
    ) {
        Text("Continue")
    }
    // Rest of the UI

    /*
        * The following code implements the requirement of advancing automatically
        * to the next screen when a valid date of birth has been introduced
        * and the user wanted to continue with the registration process.
        */

    if (validationInProgress) {
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
        LaunchedEffect(viewModel, lifecycle) {
            // If the date of birth is valid and the validation is in progress,
            // navigate to the next screen when `lifecycle` is at least STARTED,
            // which is the default Lifecycle.State for the `flowWithLifecycle` operator.
            snapshotFlow { viewModel.uiState }
                .filter { it.isDobValid }
                .flowWithLifecycle(lifecycle)
                .collect {
                    validationInProgress = false
                    currentNavigateToNextScreen()
                }
        }
    }
}

La convalida della data di nascita è una logica di business di cui è responsabile il ViewModel. La maggior parte delle volte, il ViewModel delega questa logica al livello dati. La logica per indirizzare l'utente alla schermata successiva è una logica UI perché questi requisiti potrebbero variare a seconda della configurazione dell'UI. Ad esempio, potresti non voler passare automaticamente a un'altra schermata su un tablet se stai mostrando più passaggi di registrazione contemporaneamente. La variabile validationInProgress nel codice precedente implementa questa funzionalità e gestisce se l'UI deve passare automaticamente alla schermata successiva ogni volta che la data di nascita è valida e l'utente vuole continuare con il passaggio di registrazione successivo.

Altri casi d'uso

Se ritieni che il tuo caso d'uso degli eventi UI non possa essere risolto con gli aggiornamenti dello stato dell'UI, potresti dover riconsiderare il flusso di dati nella tua app. Tieni presente i seguenti principi:

  • Ogni classe deve svolgere le attività di sua competenza, non di più. L'UI è responsabile della logica di comportamento specifica della schermata, come le chiamate di navigazione, gli eventi di clic e l'ottenimento delle richieste di autorizzazione. Il ViewModel contiene la logica di business e converte i risultati dei livelli inferiori della gerarchia in stato dell'UI.
  • Pensa all'origine dell'evento. Segui l'albero decisionale presentato all'inizio di questa guida e fai in modo che ogni classe gestisca le attività di sua competenza. Ad esempio, se l'evento ha origine dall'UI e genera un evento di navigazione, deve essere gestito nell'UI. Alcune logiche potrebbero essere delegate al ViewModel, ma la gestione dell'evento non può essere delegata interamente al ViewModel.
  • Se hai più consumatori e temi che l'evento venga consumato più volte, potresti dover riconsiderare l'architettura dell'app. La presenza di più consumatori simultanei rende estremamente difficile garantire il contratto recapitato esattamente una volta, quindi la quantità di complessità e comportamenti sottili aumenta. Se riscontri questo problema, valuta la possibilità di spostare questi problemi verso l'alto nell'albero dell'UI. Potresti aver bisogno di un'entità diversa con un ambito più elevato nella gerarchia.
  • Pensa a quando lo stato deve essere consumato. In alcune situazioni, potresti non voler continuare a consumare lo stato quando l'app è in background, ad esempio mostrare un Toast. In questi casi, valuta la possibilità di consumare lo stato quando l'UI è in primo piano.

Esempi

I seguenti esempi di Google mostrano gli eventi UI nel livello UI. Esplorali per vedere queste indicazioni in pratica:

Risorse aggiuntive

Per saperne di più sugli eventi UI, consulta le seguenti risorse aggiuntive:

Codelab

Documentazione

Visualizzare i contenuti