Eventi UI

Gli eventi di UI sono azioni che devono essere gestite nel livello UI dall'interfaccia utente o dal modello ViewModel. I tipi più comuni di eventi sono gli eventi utente. L'utente genera eventi utente interagendo con l'app, ad esempio toccando lo schermo o generando gesti. La UI consuma quindi questi eventi utilizzando callback come i listener onClick().

ViewModel è normalmente responsabile della gestione della logica di business di un determinato evento utente, ad esempio l'utente che fa clic su un pulsante per aggiornare alcuni dati. Di solito, ViewModel gestisce questo problema 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 la navigazione su una schermata diversa o la visualizzazione di un elemento Snackbar.

Sebbene la logica di business rimanga la stessa per la stessa app su piattaforme mobile o fattori di forma diversi, la logica di comportamento dell'interfaccia utente è un dettaglio di implementazione che potrebbe differire tra i due casi. La pagina del livello UI definisce questi tipi di logica come segue:

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

Struttura decisionale dell'evento UI

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

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

Gestire gli eventi utente

La UI può gestire direttamente gli eventi utente se questi sono correlati alla 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 dal ViewModel.

L'esempio seguente mostra come vengono utilizzati diversi pulsanti per espandere un elemento dell'interfaccia utente (logica UI) e 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 nella struttura ad albero dell'interfaccia utente, ad esempio in un elemento RecyclerView o in una View personalizzata, ViewModel deve comunque essere quella che gestisce gli eventi utente.

Ad esempio, supponiamo che tutte le notizie di NewsActivity contengano un pulsante segnalibro. ViewModel deve conoscere l'ID della notizia aggiunta ai preferiti. Quando l'utente aggiunge una notizia ai preferiti, l'adattatore RecyclerView non chiama la funzione addBookmark(newsId) esposta da ViewModel, il che richiederebbe una dipendenza da ViewModel. Invece, ViewModel espone un oggetto di stato chiamato 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 necessari: l'elenco di oggetti NewsItemUiState. L'adattatore non ha accesso all'intero modello ViewModel, il che riduce le probabilità di utilizzare in modo illecito la funzionalità esposta dal modello. Quando si consente solo alla classe attività di lavorare con ViewModel, le responsabilità vengono separate. Ciò garantisce che gli oggetti specifici dell'interfaccia utente come le viste o gli adattatori RecyclerView non interagiscano direttamente con ViewModel.

Convenzioni di denominazione per le funzioni di eventi utente

In questa guida, alle funzioni ViewModel che gestiscono gli eventi utente viene assegnato un nome con un verbo in base all'azione che gestiscono, ad esempio: addBookmark(id) o logIn(username, password).

Gestire gli eventi ViewModel

Le azioni dell'interfaccia utente che hanno origine dagli eventi ViewModel - ViewModel devono sempre generare un aggiornamento dello stato UI. Questo è conforme ai principi di Unidirectional Data Flow. Rende riproducibili gli eventi dopo le modifiche alla configurazione e garantisce che le azioni della UI non andranno perse. Facoltativamente, puoi anche rendere gli eventi riproducibili dopo l'interruzione del processo se utilizzi il modulo dello stato salvato.

La mappatura delle azioni dell'interfaccia utente allo stato della UI non è sempre un processo semplice, ma porta a una logica più semplice. Ad esempio, il tuo processo mentale non deve finire con la determinazione di come impostare l'interfaccia utente su una schermata specifica. Devi valutare ulteriormente come rappresentare il flusso di utenti nello stato della tua interfaccia utente. In altre parole: non pensare alle azioni che deve compiere la UI, ma a come queste azioni influiscono sullo stato dell'interfaccia utente.

Ad esempio, considera il caso di accedere alla schermata Home quando l'utente ha eseguito l'accesso dalla schermata di accesso. Puoi modellarlo nello stato dell'interfaccia utente nel seguente modo:

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

Questa UI reagisce alle modifiche allo stato 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 di eventi può attivare aggiornamenti dello stato

L'utilizzo di determinati eventi ViewModel nella UI potrebbe comportare aggiornamenti dello stato di altri UI. Ad esempio, quando vengono mostrati messaggi temporanei sullo schermo per comunicare all'utente che è successo qualcosa, l'interfaccia utente deve notificare al ViewModel di attivare un altro aggiornamento dello stato quando il messaggio viene mostrato sullo schermo. L'evento che si verifica quando l'utente ha utilizzato il messaggio (ignorandolo o dopo un timeout) può essere considerato come "input utente" e, come tale, ViewModel deve esserne consapevole. In questa situazione, lo stato dell'interfaccia utente 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 aggiorna lo stato dell'interfaccia utente come segue 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)
    }
}

Il ViewModel non ha bisogno di sapere come l'UI mostra il messaggio sullo schermo, sa solo che c'è un messaggio utente che deve essere mostrato. Una volta mostrato il messaggio temporaneo, l'interfaccia utente deve notificarlo al ViewModel, causando 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 singolo momento. Può essere visualizzato o meno il messaggio per l'utente.

La sezione L'utilizzo di eventi può attivare aggiornamenti dello stato descrive nel dettaglio come utilizzare lo stato dell'interfaccia utente per visualizzare i messaggi per gli utenti sullo schermo. Anche gli eventi di navigazione sono un tipo comune di eventi nelle app per Android.

Se l'evento viene attivato nella UI perché l'utente ha toccato un pulsante, l'interfaccia utente lo gestisce chiamando il controller di navigazione o esponendo l'evento al chiamante come componibile.

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'input dei dati richiede una convalida della logica di business prima della navigazione, il ViewModel deve esporre questo stato all'interfaccia utente. L'interfaccia utente reagisce al cambiamento di stato e si sposta di conseguenza. La sezione Gestire gli eventi ViewModel descrive 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 corrente, Login, non verrebbe mantenuta nello stack di backup. Gli utenti non possono tornare indietro se premono Indietro. Tuttavia, nei casi in cui ciò può 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 posteriore, potrebbe essere necessaria una logica aggiuntiva per non continuare ad avanzare automaticamente alla schermata B. Per implementare questa funzionalità, è necessario disporre di uno stato aggiuntivo che indichi se l'interfaccia utente deve considerare il passaggio all'altra schermata. Normalmente, questo stato si trova nell'interfaccia utente, perché la logica di navigazione è un problema dell'interfaccia utente, non del ViewModel. Per spiegare meglio, prendiamo in considerazione il caso d'uso che segue.

Supponiamo che ti trovi nel 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 ulteriore funzionalità, gli utenti possono spostarsi tra le diverse schermate di registrazione nel caso in cui vogliano modificare alcuni dati. Di conseguenza, tutte le destinazioni nel flusso di registrazione vengono mantenute nello stesso back stack. Dati questi requisiti, puoi 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 data di convalida della data di nascita è la logica aziendale di cui è responsabile ViewModel. Nella maggior parte dei casi, ViewModel delega questa logica al livello dati. La logica per far passare 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 del tablet se visualizzi contemporaneamente più passaggi di registrazione. La variabile validationInProgress nel codice riportato sopra implementa questa funzionalità e gestisce se l'interfaccia utente deve navigare automaticamente ogni volta che la data di nascita è valida e l'utente vuole andare al passaggio di registrazione successivo.

Altri casi d'uso

Se ritieni che il tuo caso d'uso per gli eventi UI non possa essere risolto con gli aggiornamenti dello stato della UI, potrebbe essere necessario riconsiderare il flusso di dati nella tua app. Considera i seguenti principi:

  • Ogni classe deve fare ciò di cui è responsabile, non di più. La UI è responsabile della logica di comportamento specifica per le schermate, come le chiamate di navigazione, gli eventi di clic e l'ottenimento di richieste di autorizzazione. ViewModel contiene la logica di business e converte i risultati dei livelli inferiori della gerarchia nello stato dell'interfaccia utente.
  • Pensa a dove ha origine l'evento. Segui l'albero delle decisioni presentato all'inizio di questa guida e fai in modo che ogni classe si occupi di ciò di cui è responsabile. Ad esempio, se l'evento ha origine dalla UI e genera un evento di navigazione, deve essere gestito nell'interfaccia utente. Alcune logiche potrebbero essere delegate a ViewModel, ma la gestione dell'evento non può essere delegata completamente a ViewModel.
  • Se hai più consumatori e temi che l'evento venga consumato più volte, potrebbe essere necessario riconsiderare l'architettura dell'app. La presenza di più consumatori simultanei fa sì che il contratto consegnato esattamente una volta diventi estremamente difficile da garantire, quindi la quantità di complessità e comportamenti discreti esplode. Se hai questo problema, valuta la possibilità di spostare questi problemi nella struttura ad albero dell'interfaccia utente; potresti aver bisogno di un'entità diversa con ambito più in alto nella gerarchia.
  • Pensa a quando lo stato ha bisogno di essere fruito. In determinate situazioni potresti non voler continuare a utilizzare lo stato quando l'app è in background, ad esempio mostrando un Toast. In questi casi, puoi utilizzare lo stato quando l'interfaccia utente è in primo piano.

Samples

I seguenti esempi di Google mostrano gli eventi UI nel livello UI. Esplorale per vedere concretamente queste indicazioni: