Événements de l'UI

Les événements de l'UI sont des actions qui doivent être gérées dans la couche d'UI, que ce soit par l'UI ou par le ViewModel. Les événements les plus courants sont des événements utilisateur. L'utilisateur produit des événements utilisateur en interagissant avec l'application, par exemple en appuyant sur l'écran ou en faisant des gestes. L'UI exploite ensuite ces événements à l'aide de rappels comme les écouteurs onClick().

ViewModel gère en principe la logique métier d'un événement utilisateur spécifique (par exemple, l'utilisateur qui clique sur un bouton pour actualiser certaines données). En général, ViewModel gère cette opération en proposant des fonctions que l'UI peut appeler. Les événements utilisateur peuvent également avoir une logique de comportement d'UI que l'interface utilisateur peut gérer directement, comme la navigation vers un autre écran ou l'affichage d'un Snackbar.

Bien que la logique métier reste identique pour la même application sur différents plates-formes mobiles ou facteurs de forme, la logique de comportement de l'UI est un détail d'intégration qui peut varier selon les cas. La page de la couche de l'UI définit ces types de logiques comme suit :

  • La logique métier fait référence à la procédure à suivre en cas de changement d'état (par exemple, effectuer un paiement ou stocker des préférences utilisateur). En général, le domaine et les couches de données gèrent cette logique. Dans ce guide, la classe ViewModel des composants de l'architecture est utilisée comme solution catégorique pour les classes qui gèrent la logique métier.
  • La logique de comportement de l'UI, ou logique de l'UI, désigne la manière d'afficher les changements d'état (par exemple, la logique de navigation ou la manière de montrer des messages à l'utilisateur). L'UI gère cette logique.

Arbre de décision pour les événements d'UI

Le schéma suivant représente un arbre de décision destiné à identifier la meilleure méthode pour gérer un cas d'utilisation spécifique. Le reste de ce guide détaille ces approches.

Si l'événement provient de ViewModel, mettez à jour l'état de l'UI. Si l'événement provient de l'UI et nécessite une logique métier, déléguez-la à ViewModel. Si l'événement provient de l'UI et nécessite une logique de comportement de celle-ci, modifiez l'état de l'élément d'UI directement dans l'interface.
Figure 1. Arbre de décision pour gérer les événements.

Gérer les événements utilisateur

L'UI peut directement gérer les événements utilisateur si ceux-ci concernent la modification de l'état d'un élément de l'UI (par exemple, l'état d'un élément extensible). Si l'événement nécessite une logique métier, telle que l'actualisation des données à l'écran, il doit être traité par ViewModel.

L'exemple suivant montre comment différents boutons sont utilisés pour développer un élément d'interface utilisateur (logique d'UI) et pour actualiser les données à l'écran (logique métier) :

Vues

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

Compose

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

Événements utilisateur dans RecyclerViews

Si l'action est produite plus bas dans l'arborescence de l'UI, comme dans un élément RecyclerView ou un élément View personnalisé, ViewModel doit toujours être celui qui gère les événements utilisateur.

Par exemple, supposons que tous les articles d'actualité de NewsActivity contiennent un bouton de favori. ViewModel doit connaître l'identifiant de l'article ajouté aux favoris. Lorsque l'utilisateur ajoute un article à ses favoris, l'adaptateur RecyclerView n'appelle pas la fonction addBookmark(newsId) présentée à partir du ViewModel, ce qui nécessiterait une dépendance sur ViewModel. Au lieu de cela, ViewModel expose un objet d'état appelé NewsItemUiState qui contient l'intégration permettant de gérer l'événement :

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

Ainsi, l'adaptateur RecyclerView ne fonctionne qu'avec les données dont il a besoin, à savoir la liste d'objets NewsItemUiState. L'adaptateur n'a pas accès à l'intégralité du ViewModel, ce qui réduit les risques d'abus des fonctionnalités présentées par le ViewModel. Lorsque vous autorisez uniquement la classe d'activité à utiliser le ViewModel, vous devez séparer les responsabilités. Cela garantit que des objets spécifiques à l'UI, comme les vues ou les adaptateurs RecyclerView, n'interagissent pas directement avec le ViewModel.

Conventions de dénomination pour les fonctions d'événements utilisateur

Dans ce guide, les fonctions ViewModel qui gèrent les événements utilisateur sont nommées à partir d'un verbe en fonction de l'action qu'elles traitent, par exemple : addBookmark(id) ou logIn(username, password).

Gérer les événements ViewModel

Les actions de l'UI qui proviennent de ViewModel, à savoir les événements ViewModel, doivent toujours entraîner une mise à jour de l'état de l'UI. Cela est conforme aux principes de flux de données unidirectionnel. Il permet de reproduire les événements après les changements de configuration et garantit que les actions de l'UI ne sont pas perdues. Si vous utilisez le module d'état enregistré, vous pouvez également rendre les événements reproductibles après l'arrêt du processus.

Mapper les actions de l'UI avec l'état de l'UI n'est pas toujours simple, mais permet de simplifier la logique. Par exemple, votre réflexion ne doit pas se cantonner à déterminer la façon dont l'UI doit accéder à un écran particulier. Vous devez réfléchir plus avant, en imaginant une façon de représenter ce parcours utilisateur dans votre état de l'UI. En d'autres termes, ne réfléchissez pas aux actions que l'UI doit effectuer, mais plutôt à l'effet de ces actions sur son état.

Prenons l'exemple de la navigation vers l'écran d'accueil lorsque l'utilisateur est connecté sur l'écran de connexion. Vous pouvez modéliser cela dans l'état de l'UI comme suit :

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

Cette interface utilisateur réagit aux changements de l'état isUserLoggedIn et accède à la destination appropriée si nécessaire :

Views

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

Compose

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'utilisation d'événements peut déclencher des mises à jour de l'état

L'utilisation de certains événements ViewModel dans l'UI peut entraîner la mise à jour d'autres états de celle-ci. Par exemple, lors de l'affichage de messages transitoires à l'écran pour informer l'utilisateur qu'un événement s'est produit, l'UI doit avertir l'élément ViewModel afin de déclencher une autre mise à jour de l'état lorsque le message s'est affiché à l'écran. L'événement qui se produit lorsque le message est "consommé" par l'utilisateur (en le fermant ou après un délai d'inactivité) peut être considéré comme une "entrée utilisateur", dont ViewModel doit alors en tenir compte. Dans ce cas, l'état de l'UI peut être modélisé comme suit :

// 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 met à jour l'état de l'UI comme suit lorsque la logique métier nécessite l'affichage d'un nouveau message transitoire à l'utilisateur :

Vues

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

Compose

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 n'a pas besoin de savoir comment l'interface utilisateur affiche le message à l'écran, car il sait qu'un message doit être affiché. Une fois le message transitoire affiché, l'UI doit en informer le ViewModel, ce qui entraîne la mise à jour de son état afin d'effacer la propriété userMessage :

Vues

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

Compose

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

Même si le message est temporaire, l'état de l'interface utilisateur est une représentation fidèle de ce qui est affiché à l'écran à chaque instant. Soit le message utilisateur s'affiche, soit il ne s'affiche pas.

La section L'utilisation d'événements peut déclencher des mises à jour de l'état présente en détail comment vous pouvez utiliser l'état de l'UI pour afficher à l'écran des messages destinés aux utilisateurs. Les événements de navigation sont également des événements courants dans une application Android.

Si l'événement est déclenché dans l'UI, car l'utilisateur a appuyé sur un bouton, l'UI s'en charge en appelant le contrôleur de navigation ou en exposant l'événement à l'appelant de composable, selon le cas.

Vues

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

Compose

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

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

Si la saisie de données nécessite une validation de la logique métier avant la navigation, le ViewModel doit exposer cet état à l'UI. L'UI réagira à ce changement d'état et naviguera en conséquence. La section Gérer les événements ViewModel présente ce cas d'utilisation. Voici un code similaire :

Views

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

Compose

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

Dans l'exemple ci-dessus, l'application fonctionne comme prévu, car la destination actuelle (Login) ne serait pas conservée dans la pile "Retour". Les utilisateurs ne peuvent pas y revenir s'ils appuient dessus. Toutefois, si cela se produit, la solution nécessite une logique supplémentaire.

Lorsqu'un ViewModel définit un état qui génère un événement de navigation de l'écran A vers l'écran B et que l'écran A est conservé dans la pile "Retour" de Navigation, vous avez peut-être besoin d'une logique supplémentaire pour ne pas passer automatiquement à l'écran B. Pour cela, vous devez disposer d'un état supplémentaire indiquant si l'interface utilisateur doit déterminer s'il faut passer à l'autre écran. Normalement, cet état est conservé dans l'UI, car la logique de navigation concerne l'UI et non le ViewModel. Pour illustrer cela, prenons le cas d'utilisation suivant.

Supposons qu'un utilisateur se trouve dans le flux d'inscription de votre application. Sur l'écran de validation de la date de naissance, lorsqu'il saisit une date, celle-ci est validée par le ViewModel lorsqu'il appuie sur le bouton "Continuer". ViewModel délègue la logique de validation à la couche de données. Si la date est valide, l'utilisateur passe à l'écran suivant. En tant que fonctionnalité supplémentaire, les utilisateurs peuvent passer d'un écran d'inscription à l'autre s'ils souhaitent modifier certaines données. Par conséquent, toutes les destinations du flux d'inscription sont conservées dans la même pile "Retour". Compte tenu de ces exigences, vous pouvez implémenter cet écran comme suit :

Vues

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

Compose

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 validation de la date de naissance est une logique métier qui relève du ViewModel. La plupart du temps, le ViewModel délègue cette logique à la couche de données. La logique de navigation déterminant si l'utilisateur passe à l'écran suivant est une logique d'UI, car ces exigences peuvent varier en fonction de la configuration de l'interface utilisateur. Par exemple, vous ne souhaitez peut-être pas passer automatiquement à un autre écran sur une tablette si vous affichez plusieurs étapes d'inscription en même temps. La variable validationInProgress du code ci-dessus implémente cette fonctionnalité et détermine si l'interface utilisateur passer automatiquement à l'écran suivant lorsque la date de naissance est valide et que l'utilisateur souhaite passer à l'étape d'inscription suivante.

Autres cas d'utilisation

Si vous pensez que le cas d'utilisation d'un événement d'UI ne peut pas être résolu par les mises à jour de son état, réévaluez la circulation des données dans votre application en tenant compte des principes suivants :

  • Chaque classe doit uniquement exécuter ce dont elle est responsable. L'UI est responsable de la logique de comportement spécifique à l'écran, comme les appels de navigation, les événements de clics et l'obtention des demandes d'autorisation. ViewModel contient une logique métier et convertit les résultats des couches inférieures de la hiérarchie en état d'UI.
  • Pensez à l'origine de l'événement. Suivez l'arbre de décision présenté au début de ce guide et faites en sorte que chaque classe gère ce dont elle est responsable. Par exemple, si l'événement provient de l'UI et qu'il entraîne un événement de navigation, il doit être géré dans l'interface. Une logique peut être déléguée au ViewModel, mais la gestion de l'événement ne peut être entièrement déléguée au ViewModel.
  • Si vous avez plusieurs clients et que vous craignez qu'un événement soit "consommé" plusieurs fois, envisagez de repenser l'architecture de votre application. Si plusieurs consommateurs sont présents simultanément, il est extrêmement difficile de s'assurer que le contrat n'est livré qu'une seule fois, ce qui décuple la complexité et la subtilité du comportement. Si vous rencontrez ce problème, envisagez de le faire remonter dans l'arborescence de l'UI. Vous aurez peut-être besoin d'une autre entité située plus haut dans la hiérarchie.
  • Pensez au moment où l'état doit être utilisé. Dans certains cas, il se peut que vous ne souhaitiez pas continuer à consommer l'état lorsque l'application est exécutée en arrière-plan (par exemple, avec Toast). Envisagez alors de consommer l'état lorsque l'UI s'exécute au premier plan.

Exemples

Les exemples Google suivants illustrent la génération des événements de l'UI dans la couche d'interface utilisateur. Parcourez-les pour voir ces conseils en pratique :