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 produce eventi utente interagendo con l'app, ad esempio toccando lo schermo o generando gesti. L'interfaccia utente utilizza quindi questi eventi tramite callback come i listener onClick().

La ViewModel è normalmente responsabile della gestione della logica di business di un determinato evento utente, ad esempio quando l'utente fa clic su un pulsante per aggiornare alcuni dati. In genere, ViewModel gestisce questa operazione esponendo funzioni che l'interfaccia utente può chiamare. Gli eventi utente potrebbero anche avere una logica di comportamento della UI che la UI può gestire direttamente, ad esempio passare a una schermata diversa o mostrare un Snackbar.

Anche se la logica di business rimane la stessa per la stessa app su piattaforme o fattori di forma mobile diversi, la logica di comportamento dell'interfaccia utente è 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, questa logica viene gestita dai livelli di dominio e dati. In questa guida, la classe Architecture Components ViewModel viene utilizzata come soluzione basata su opinioni per le classi che gestiscono la logica di business.
  • La logica di comportamento dell'interfaccia utente o la logica dell'interfaccia utente si riferisce a come visualizzare le modifiche di stato, ad esempio la logica di navigazione o come mostrare i messaggi all'utente. L'interfaccia utente gestisce questa logica.

Albero decisionale degli eventi UI

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

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'interfaccia utente può gestire direttamente gli eventi utente se questi riguardano la modifica dello stato di un elemento dell'interfaccia utente, ad esempio lo stato di un elemento espandibile. Se l'evento richiede l'esecuzione di una logica di business, ad esempio l'aggiornamento dei dati sullo schermo, deve essere elaborato da ViewModel.

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

Visualizzazioni

class LatestNewsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLatestNewsBinding
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        // The expand details event is processed by the UI that
        // modifies a View's internal state.
        binding.expandButton.setOnClickListener {
            binding.expandedSection.visibility = View.VISIBLE
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the business logic.
        binding.refreshButton.setOnClickListener {
            viewModel.refreshNews()
        }
    }
}

Scrivi

@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 in RecyclerView

Se l'azione viene prodotta più in basso nell'albero della UI, ad esempio in un elemento RecyclerView o in un View personalizzato, ViewModel deve comunque essere quello che gestisce gli eventi utente.

Ad esempio, supponi che tutti gli articoli di notizie di NewsActivity contengano un pulsante per i preferiti. L'ViewModel deve conoscere l'ID della notizia aggiunta ai preferiti. Quando l'utente aggiunge un segnalibro a una notizia, l'adattatore RecyclerView non chiama la funzione addBookmark(newsId) esposta da ViewModel, che richiederebbe una dipendenza da ViewModel. ViewModel espone invece un oggetto di stato denominato NewsItemUiState che contiene l'implementazione per la gestione dell'evento:

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    val publicationDate: String,
    val onBookmark: () -> Unit
)

class LatestNewsViewModel(
    private val formatDateUseCase: FormatDateUseCase,
    private val repository: NewsRepository
)
    val newsListUiItems = repository.latestNews.map { news ->
        NewsItemUiState(
            title = news.title,
            body = news.body,
            bookmarked = news.bookmarked,
            publicationDate = formatDateUseCase(news.publicationDate),
            // Business logic is passed as a lambda function that the
            // UI calls on click events.
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

In questo modo, l'adattatore RecyclerView funziona solo con i dati di cui ha bisogno: l'elenco degli oggetti NewsItemUiState. L'adattatore non ha accesso all'intero ViewModel, il che riduce la probabilità di un utilizzo improprio della funzionalità esposta dal ViewModel. Se consenti solo alla classe dell'attività di interagire con il ViewModel, separi le responsabilità. In questo modo, gli oggetti specifici dell'interfaccia utente, come le visualizzazioni o gli adattatori RecyclerView, non interagiscono direttamente con il ViewModel.

Convenzioni di denominazione per le funzioni degli eventi utente

In questa guida, le funzioni ViewModel che gestiscono gli eventi utente sono denominate con un verbo basato sull'azione che gestiscono, ad esempio addBookmark(id) o logIn(username, password).

Gestire gli eventi ViewModel

Le azioni della UI che hanno origine dal ViewModel, ovvero gli eventi ViewModel, devono sempre comportare un aggiornamento dello stato della 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'interfaccia utente non vengano perse. (Facoltativo) Puoi anche rendere gli eventi riproducibili dopo l'interruzione del processo se utilizzi il modulo di stato salvato.

Mappare le azioni dell'interfaccia utente allo stato dell'interfaccia utente non è sempre un processo semplice, ma porta a una logica più semplice. Il tuo processo di pensiero non deve terminare con la determinazione di come far navigare la UI su una schermata specifica, ad esempio. Devi pensare più a fondo e considerare come rappresentare il flusso utente nello stato dell'interfaccia utente. In altre parole: non pensare a quali azioni deve eseguire la UI, ma a come queste azioni influiscono sullo stato della UI.

Ad esempio, considera il caso di navigazione alla schermata Home quando l'utente ha eseguito l'accesso nella schermata di accesso. Puoi modellare questo stato della UI nel seguente modo:

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

Questa UI reagisce alle modifiche dello stato di isUserLoggedIn e passa alla destinazione corretta in base alle esigenze:

Visualizzazioni

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
    /* ... */
}

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

Scrivi

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

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel(),
    onUserLogIn: () -> Unit
) {
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)

    // Whenever the uiState changes, check if the user is logged in.
    LaunchedEffect(viewModel.uiState)  {
        if (viewModel.uiState.isUserLoggedIn) {
            currentOnUserLogIn()
        }
    }

    // Rest of the UI for the login screen.
}

L'utilizzo degli eventi può attivare aggiornamenti dello stato

L'utilizzo di determinati eventi ViewModel nella UI potrebbe comportare altri aggiornamenti dello stato della UI. Ad esempio, quando vengono mostrati messaggi temporanei sullo schermo per comunicare all'utente che si è verificato un evento, la UI deve notificare alla ViewModel di attivare un altro aggiornamento dello stato quando il messaggio è stato mostrato sullo schermo. L'evento che si verifica quando l'utente ha consumato il messaggio (chiudendolo o dopo un timeout) può essere considerato "input utente" e, in quanto tale, il ViewModel deve esserne a conoscenza. In questa situazione, lo stato della 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
)

ViewModel aggiornerebbe lo stato della UI nel seguente modo quando la logica di business richiede di mostrare un nuovo messaggio temporaneo all'utente:

Visualizzazioni

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

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
    val uiState: StateFlow<LatestNewsUiState> = _uiState

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

            // Do something else.
        }
    }

    fun userMessageShown() {
        _uiState.update { currentUiState ->
            currentUiState.copy(userMessage = null)
        }
    }
}

Scrivi

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

ViewModel non deve sapere come l'UI mostra il messaggio sullo schermo, ma solo che esiste un messaggio per l'utente che deve essere mostrato. Una volta visualizzato il messaggio temporaneo, la UI deve comunicarlo al ViewModel, il che causa un altro aggiornamento dello stato della UI per cancellare la proprietà userMessage:

Visualizzazioni

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.userMessage?.let {
                        // TODO: Show Snackbar with userMessage.

                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown()
                    }
                    ...
                }
            }
        }
    }
}

Scrivi

@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'interfaccia utente è una rappresentazione fedele di ciò che viene visualizzato sullo schermo in ogni momento. Il messaggio dell'utente viene visualizzato o meno.

La sezione Gli eventi di consumo possono attivare aggiornamenti di stato descrive in dettaglio come utilizzare lo stato dell'interfaccia utente per visualizzare i messaggi utente sullo schermo. Anche gli eventi di navigazione sono un tipo comune di eventi in un'app per Android.

Se l'evento viene attivato nell'interfaccia utente perché l'utente ha toccato un pulsante, l'interfaccia utente se ne occupa chiamando il controller di navigazione o esponendo l'evento al composable chiamante in modo appropriato.

Visualizzazioni

class LoginActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLoginBinding
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        binding.helpButton.setOnClickListener {
            navController.navigate(...) // Open help screen
        }
    }
}

Scrivi

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI

    Button(onClick = onHelp) {
        Text("Get help")
    }
}

Se l'inserimento dei dati richiede una convalida della logica di business prima della navigazione, il ViewModel deve esporre questo stato all'interfaccia utente. La UI reagirebbe a questa modifica dello stato e si sposterebbe di conseguenza. La sezione Gestisci gli eventi ViewModel tratta questo caso d'uso. Ecco un codice simile:

Visualizzazioni

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

Scrivi

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.login()
        }
    ) {
        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 attuale, Login, non viene mantenuta nel back stack. Gli utenti non possono tornare indietro se premuto il pulsante Indietro. Tuttavia, nei casi in cui ciò potrebbe accadere, la soluzione richiederebbe una logica aggiuntiva.

Quando un ViewModel imposta uno stato che produce un evento di navigazione dalla schermata A alla schermata B e la schermata A viene mantenuta nello stack di navigazione indietro, potresti aver bisogno di una logica aggiuntiva per non avanzare automaticamente alla schermata B. Per implementare questa funzionalità, è necessario uno stato aggiuntivo che indichi se l'interfaccia utente deve prendere in considerazione lo spostamento all'altra schermata. Normalmente, questo stato viene mantenuto nella UI perché la logica di navigazione è una preoccupazione della UI, non del ViewModel. Per illustrare questo concetto, prendiamo in considerazione 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". 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 tornare indietro e avanti tra le diverse schermate di registrazione nel caso in cui vogliano modificare alcuni dati. Pertanto, tutte le destinazioni nel flusso di registrazione vengono mantenute nello stesso back stack. Tenendo conto di questi requisiti, potresti implementare questa schermata nel seguente modo:

Visualizzazioni

// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"

class DobValidationFragment : Fragment() {

    private var validationInProgress: Boolean = false
    private val viewModel: DobValidationViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val binding = // ...
        validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false

        binding.continueButton.setOnClickListener {
            viewModel.validateDob()
            validationInProgress = true
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState
                .flowWithLifecycle(viewLifecycleOwner.lifecycle)
                .collect { uiState ->
                    // Update other parts of the UI ...

                    // If the input is valid and the user wants
                    // to navigate, navigate to the next screen
                    // and reset `validationInProgress` flag
                    if (uiState.isDobValid && validationInProgress) {
                        validationInProgress = false
                        navController.navigate(...) // Navigate to next screen
                    }
                }
        }

        return binding
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
    }
}

Scrivi

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. Nella maggior parte dei casi, ViewModel delega questa logica al livello dati. La logica per indirizzare l'utente alla schermata successiva è la logica dell'interfaccia utente perché questi requisiti potrebbero cambiare a seconda della configurazione dell'interfaccia utente. Ad esempio, potresti non voler passare automaticamente a un'altra schermata su un tablet se mostri più passaggi di registrazione contemporaneamente. La variabile validationInProgress nel codice precedente implementa questa funzionalità e gestisce se l'interfaccia utente deve spostarsi automaticamente ogni volta che la data di nascita è valida e l'utente vuole proseguire 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 della UI, potresti dover riconsiderare il flusso di dati nella tua app. Tieni presente i seguenti principi:

  • Ogni classe deve fare ciò di cui è responsabile, non di più. L'interfaccia utente è responsabile della logica di comportamento specifica dello schermo, ad esempio chiamate di navigazione, eventi di clic e ottenimento di richieste di autorizzazione. Il ViewModel contiene la logica di business e converte i risultati dei livelli inferiori della gerarchia nello stato dell'interfaccia utente.
  • Pensa all'origine dell'evento. Segui l'albero decisionale presentato all'inizio di questa guida e fai in modo che ogni classe gestisca ciò di cui è responsabile. Ad esempio, se l'evento ha origine dall'interfaccia utente e genera un evento di navigazione, quest'ultimo deve essere gestito nell'interfaccia utente. Alcune logiche potrebbero essere delegate al ViewModel, ma la gestione dell'evento non può essere delegata interamente al ViewModel.
  • Se hai più consumer e ti preoccupa che l'evento venga utilizzato più volte, potresti dover riconsiderare l'architettura della tua app. La presenza di più consumer simultanei rende estremamente difficile garantire il contratto consegna esatta una volta, quindi la quantità di complessità e il comportamento sottile aumentano in modo esponenziale. Se riscontri questo problema, valuta la possibilità di spostare questi problemi verso l'alto nell'albero della UI. Potresti aver bisogno di un'entità diversa con un ambito più ampio nella gerarchia.
  • Pensa a quando deve essere utilizzato lo stato. In determinate situazioni, potresti non voler mantenere lo stato di consumo quando l'app è in background, ad esempio la visualizzazione di un Toast. In questi casi, valuta la possibilità di utilizzare lo stato quando la UI è in primo piano.

Esempi

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