• 8 hours
  • Easy

Free online content available in this course.

course.header.alt.is_certifying

Got it!

Last updated on 9/4/23

Dynamisez l’interface grâce aux données exposées par le ViewModel

Dans le chapitre précédent, nous avons préparé tous les états et événements permettant à l’interface d’être dynamisée. En particulier les états suivants :

  • currentQuestion  permettant de savoir quelle question afficher ;

  • isLastQuestion  permettant de savoir quand nous devons afficher le bouton “Finish” à la place du bouton “Next” ;

  • score  permettant de récupérer le score de l’utilisateur, en particulier à la fin du quiz pour afficher le score total.

Ainsi que les événements suivants :

  • startQuiz   pour démarrer le quiz et récupérer en particulier les questions du quiz ;

  • nextQuestion  , pour passer à la question suivante ;

  • isAnswerValid  , pour vérifier la validité de la réponse de l’utilisateur.

Pour pouvoir utiliser ces états et ces événements, il nous faut d’abord créer une variable de type QuizViewModel  au sein du fragment QuizFragment  .

Référencez QuizViewModel  depuis QuizFragment

Pour créer un objet de type QuizViewModel  au sein du fragment QuizFragment  , nous ne pouvons pas tout simplement écrire ceci :

private QuizViewModel viewModel = new QuizViewModel(
        new QuestionRepository(
                new QuestionBank()
        )
);

Pourquoi ?

Premièrement parce que dans une classe d’une certaine couche d’architecture, c’est une mauvaise pratique d’instancier via le mot-clé new  une classe provenant d’une autre couche. Cela réduit la testabilité de chaque couche en isolation. C’est pour cela que nous utilisons souvent ce qui s’appelle de l’injection de dépendance.

Deuxièmement, parce nous voulons que le ViewModel ait conscience du cycle de vie de la vue qui l’utilise, en particulier pour survivre aux changements de configuration de celle-ci. Or, en faisant comme dans le code ci-dessus, le ViewModel  sera recréé à chaque changement de configuration de la vue.

C’est pour cela qu’il convient d’utiliser la classe ViewModelProvider    fournie par Google avec les ViewModel  .

Si le ViewModel  que l’on souhaite instancier n’a pas de paramètre, alors il suffit d’écrire :

private viewModel = new ViewModelProvider(this).get(QuizViewModel.class);

Mais, malheureusement ça n’est pas notre cas, et ça l’est rarement. En effet, notre classe QuizViewModel  prend en paramètre QuestionRepository  . Nous allons donc devoir fournir une factory au ViewModelProvider  . Une factory est simplement un pattern permettant de déléguer la création d'une classe à une autre.

Créez une Factory

Dans un package injection  , au même niveau que les packages data  et  ui  , créez une classe ViewModelFactory  , et copiez-collez le code ci-dessous.

public class ViewModelFactory implements ViewModelProvider.Factory {
    private final QuestionRepository questionRepository;
    private static ViewModelFactory factory;

    public static ViewModelFactory getInstance() {
        if (factory == null) {
            synchronized (ViewModelFactory.class) {
                if (factory == null) {
                    factory = new ViewModelFactory();
                }
            }
        }
        return factory;
    }

    private ViewModelFactory() {
        QuestionBank questionBank = QuestionBank.getInstance();
        this.questionRepository = new QuestionRepository(questionBank);
    }

    @Override
    @NotNull
    public <T extends ViewModel>  T create(Class<T> modelClass) {
        if (modelClass.isAssignableFrom(QuizViewModel.class)) {
            return (T) new QuizViewModel(questionRepository);
        }
        throw new IllegalArgumentException("Unknown ViewModel class");
    }
}

Je vous explique tout ça ! Nous avons créé une classe  ViewModelFactory   implémentant l'interface ViewModelProvider.Factory  créée par Google, et qui sera utilisée par la suite pour déclarer notre ViewModel  dans notre fragment. Nous lui définissons ici un constructeur contenant les objets dont nous avons besoin pour instancier correctement notre classe QuizViewModel  . Cette classe nous permet de regrouper le processus de création de nos ViewModel  dans une Factory  dédiée, ViewModelFactory 

Ainsi, si jamais par la suite nous souhaitons créer un autre ViewModel  , par exemple   UserViewModel  , nous le déclarerons ici, dans cette même Factory  , à l'intérieur de la méthode create()  .

À vous de jouer !

Au sein de QuizFragment  , vous  pouvez maintenant créer une variable globale de type QuizViewModel  . Ensuite, instanciez-la au sein de la fonction onCreate()  , et attention, pas ailleurs.

En effet, c’est ce qui va permettre de créer le ViewModel  lors de la première création du fragment, ensuite de le lier à son cycle de vie via l’implémentation du ViewModelProvider  de Google, et de ne pas le recréer si ça n’est pas nécessaire.

Vous devriez au final avoir ajouté ces lignes de code :

private QuizViewModel viewModel;

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    viewModel = new ViewModelProvider(this, ViewModelFactory.getInstance()).get(QuizViewModel.class);
}

Il est maintenant temps de dynamiser les composants de notre interface.

Affichez des questions et des propositions

Rappelez-vous, au sein du layout quiz_fragment.xml  , nous avons dans le chapitre 2 créé les composants avec les identifiants suivants :

  • question  pour le composant de type TextView  pour contenir la question ;

  • answer1  , answer2  , answer3  et answer4  , les boutons pour afficher les réponses possibles.

Nous avions également configuré la fonctionnalité de ViewBinding  , pour pouvoir accéder à ces composants depuis le code Java en faisant simplement binding.question  , binding.answer1  , etc.

Il ne vous reste plus qu’à dynamiser question  , answer1  , answer2  , answer3  et answer4  pour afficher la question en cours accessible via l’état de type LiveData currentQuestion  .

Pour écouter les modifications d’un LiveData  , il faut appeler la fonction observe  du LiveData  en question, comme ceci :

viewModel.currentQuestion.observe(getViewLifecycleOwner(), new Observer<Question>() {
    @Override
    public void onChanged(Question question) {
        // TODO à développer
    }
});

Cette fonction prend deux paramètres en entrée :

  • le premier permet au LiveData  d’avoir connaissance du cycle de vie de la vue, pour que celui-ci notifie la vue de ces changements, uniquement quand la vue est active ; 

  • le second paramètre permet de déclarer un objet de type Observer  , qui contient en particulier une fonction onChanged()  qui sera appelée lorsqu’une nouvelle valeur sera émise dans le LiveData  .

À vous de jouer !

Vous savez maintenant tout ce qu’il faut pour dynamiser le contenu des composants permettant d’afficher une question et ses propositions. Essayez par vous-même avant de jeter un coup d'œil à la correction ci-dessous.

@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
    viewModel.startQuiz();
    viewModel.currentQuestion.observe(getViewLifecycleOwner(), new Observer<Question>() {
        @Override
        public void onChanged(Question question) {
        updateQuestion(question);
        }
    });
}

private void updateQuestion(Question question) {
    binding.question.setText(question.getQuestion());
    binding.answer1.setText(question.getChoiceList().get(0));
    binding.answer2.setText(question.getChoiceList().get(1));
    binding.answer3.setText(question.getChoiceList().get(2));
    binding.answer4.setText(question.getChoiceList().get(3));
}

À ce stade, vous pouvez lancer l’application sur un appareil, et normalement après avoir cliqué sur le bouton “Let’s play” de l’écran d’accueil, vous devriez voir apparaître la première question avec ces propositions. Voilà qui est plutôt satisfaisant, non ?

Récupérez la réponse de l’utilisateur

Maintenant, pour gérer les réponses de l’utilisateur aux questions du quiz, il va falloir :

  • récupérer l’action de clic de l’utilisateur sur les quatre réponses possibles grâce à la fonction setOnClickListener  (souvenez-vous, nous l’avons déjà utilisée dans la partie 2 – chapitre 4) ;

  • vérifier si la réponse est correcte ou non. Mettre à jour en conséquence l’interface, c’est-à-dire : 

    • changer la couleur du bouton en vert ou en rouge,

    • afficher le composant de type TextView, validityText  , présent dans notre layout avec le texte “Good Answer” ou “Bad Answer” ;

  • puis afficher le bouton Next  qui permet soit de passer à la question suivante, soit de terminer le quiz s’il s’agit de la dernière question.

Allez-y, essayez de produire le code correspondant à ces spécifications. On se retrouve ci-dessous pour voir mon implémentation.

Dans la fonction onViewCreated  , ajoutez le code suivant :

binding.answer1.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        updateAnswer(binding.answer1, 0);
    }
});

binding.answer2.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        updateAnswer(binding.answer2, 1);
    }
});

binding.answer3.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        updateAnswer(binding.answer3, 2);
    }
});

binding.answer4.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        updateAnswer(binding.answer4, 3);
    }
});

Comme vous pouvez le voir, ce code permet de détecter le clic sur chaque bouton. Pour que ce code fonctionne, il manque la fonction updateAnswer  , que vous pouvez ajouter au sein de la classe QuizFragment  :

private void updateAnswer(Button button, Integer index){
    showAnswerValidity(button, index);
    enableAllAnswers(false);
    binding.next.setVisibility(View.VISIBLE);
}

private void showAnswerValidity(Button button, Integer index){
    Boolean isValid = viewModel.isAnswerValid(index);
    if (isValid) {
        button.setBackgroundColor(Color.parseColor("#388e3c")); // dark green
         binding.validityText.setText("Good Answer ! 💪");
    } else {
        button.setBackgroundColor(Color.RED);
        binding.validityText.setText("Bad answer 😢");
    }
    binding.validityText.setVisibility(View.VISIBLE);
}

private void enableAllAnswers(Boolean enable){
    List<Button> allAnswers = Arrays.asList(binding.answer1, binding.answer2, binding.answer3, binding.answer4);
    allAnswers.forEach( answer -> {
        answer.setEnabled(enable);
    });
}

Démarrez l’application sur un appareil, et là, magie, lorsque vous cliquez sur une proposition, l’interface se met à jour comme il faut.

Capture d’écran montrant un écran de téléphone avec une question, 4 propositions. La première proposition a été sélectionnée par l’utilisateur, c’est la bonne alors elle s’affiche en vert, et un texte “Good Answer” en bas de l’écra
Capture d’écran de l’application en cours de construction, lorsque l’utilisateur clique sur une bonne réponse.

Un point sur l’accessibilité de cette fonctionnalité

Même si d’apparence à ce stade du développement du quiz, la fonctionnalité semble marcher comme il le faut, prenons le temps d’analyser si celle-ci est bien accessible. Les contrastes sont corrects sur cet écran, les boutons suffisamment gros, et l’information de succès ou d’échec n’est véhiculée que par de la couleur grâce au texte validityText  . Cependant, nous avons oublié de considérer les personnes aveugles qui utilisent un lecteur d’écran pour naviguer sur l’interface.

Dans le cas de notre quiz, lorsque le lecteur d’écran est positionné sur une réponse, et que l’utilisateur effectue un clic sur cette réponse, si nous ne faisons rien et que l’utilisateur ne déplace pas le focus jusqu’à l’élément textValidity  en bas de l’écran, il ne sait pas s’il a bien répondu ou non.  Pour régler ce souci, il convient d’utiliser la fonction announceForAccessibility  , accessible au sein de la classe View  . Elle permet en effet de forcer l’annonce d’un message par le lecteur d’écran. Bien sûr, ce message est vocalisé uniquement si l’utilisateur utilise Talkback. Cela donne dans notre cas :

private void showAnswerValidity(Button button, Integer index){
    Boolean isValid = viewModel.isAnswerValid(index);
    if (isValid) {
            button.setBackgroundColor(Color.parseColor("#388e3c")); // dark green
        binding.validityText.setText("Good Answer ! 💪");
        button.announceForAccessibility("Good Answer !");
    } else {
        button.setBackgroundColor(Color.RED);
        binding.validityText.setText("Bad answer 😢");
        button.announceForAccessibility("Bad answer");
    }
    binding.validityText.setVisibility(View.VISIBLE);
}

N’hésitez pas à vous lancer dans un tutoriel pour apprendre à utiliser Talkback, puis lancez SuperQuiz sur un appareil physique, afin de valider que votre code fonctionne bien avec Talkback.

Passez à la suite

Maintenant, nous devons permettre à l’utilisateur de passer à la suite une fois qu’il a répondu à une question. Cela revient à dynamiser le bouton avec l’identifiant next  . Il va falloir en particulier :

  • observer l’état isLastQuestion  afin de conditionner le texte contenu dans le bouton next  : “Finish” ou “Next” ;

  • passer à la question suivante lorsque l’utilisateur clique sur “Next” ;

  • afficher son score final dans une fenêtre de type Dialog si l’utilisateur clique sur “Finish”.

À nouveau, essayez par vous-même avant de regarder ma version du code ci-dessous.

Voici la correction.

Pour observer l’état isLastQuestion  , j’ai ajouté le code suivant au sein de la fonction onViewCreated  du fragment du quiz :

viewModel.isLastQuestion.observe(getViewLifecycleOwner(), new Observer<Boolean>() {
    @Override
    public void onChanged(Boolean isLastQuestion) {
        if (isLastQuestion){
            binding.next.setText("Finish");
        } else {
            binding.next.setText("Next");
        }
    }
});

Pour gérer l’action de clic sur le bouton next  , j’ai également ajouté le code suivant dans la fonction onViewCreated  :

binding.next.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        Boolean isLastQuestion = viewModel.isLastQuestion.getValue();
        if(isLastQuestion != null && isLastQuestion){
            displayResultDialog();
        } else {
            viewModel.nextQuestion();
            resetQuestion();
        }
    }
});

Le code précédent utilise les deux fonctions suivantes, pour réinitialiser l’état de l’interface lorsque l’on passe à une autre question :

private void resetQuestion(){
    List<Button> allAnswers = Arrays.asList(binding.answer1, binding.answer2, binding.answer3, binding.answer4);
    allAnswers.forEach( answer -> {
        answer.setBackgroundColor(Color.parse(“#6200EE”));
    });
    binding.validityText.setVisibility(View.INVISIBLE);
    enableAllAnswers(true);
}
private void enableAllAnswers(Boolean enable){
    List<Button> allAnswers = Arrays.asList(binding.answer1, binding.answer2, binding.answer3, binding.answer4);
    allAnswers.forEach( answer -> {
        answer.setEnabled(enable);
    });
}

Quelques remarques concernant ces quelques lignes de code :

La fonction resetQuestion  est utilisée pour réinitialiser l’état des boutons, pour les préparer pour la prochaine question. Dans le code de cette fonction, nous créons une liste avec tous les boutons de l’interface. Puis via la lambda forEach()  , nous changeons la couleur de fond. Nous masquons le texte validityText  et nous réactivons le clic sur tous les boutons via la fonction enableAllAnswers()  .

Pour créer et afficher une fenêtre de dialogue lorsque l’utilisateur clique sur Finish  , nous allons utiliser la classe Dialog  d’Android. En particulier via le builder AlertDialog.Builder  . Il permet de créer une popup avec un style par défaut, sans avoir besoin de définir de design via un layout XML.

Capture d’écran d’une fenêtre de dialogue, contenant le titre “Title”, le message “Message”, un bouton nommé “Negative Button”, un bouton nommé “Positive Button”.
Exemple de fenêtre de dialogue construite avec AlertDialog.Builder

C’est ce que nous faisons dans la fonction displayResultDialog  :

private void displayResultDialog() {
    AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());

    builder.setTitle("Finished !");
    Integer score = viewModel.score.getValue();
    builder.setMessage("Your final score is "+ score);
    builder.setPositiveButton("Quit", new DialogInterface.OnClickListener() {
        public void onClick(DialogInterface dialog, int id) {
            goToWelcomeFragment();
        }
    });
    AlertDialog dialog = builder.create();
    dialog.show();
}

Dans ce code, après avoir instancié un objet de type AlertDialog.Builder  , nous configurons :

  • le titre de la fenêtre via la fonction setTitle  de la classe AlertDialog.Builder  ;

  • un message via la fonction setMessage  qui sera contenu au centre de cette fenêtre ;

  • un bouton avec une allure “positive” et l’action associée au clic sur ce bouton. Ici nous appelons la fonction goToWelcomeFragment  que nous détaillerons ensuite.

Puis, nous créons un objet de type Dialog  grâce au builder précédent en appelant sa fonction create  . Enfin pour afficher la fenêtre, il faut appeler la fonction show  issue de la classe Dialog  .

Pour naviguer vers l’écran d’accueil de notre application une fois que l’utilisateur a décidé de fermer la fenêtre de dialogue précédente, nous allons à nouveau utiliser la classe FragmentManager  de la même manière que nous l’avions fait pour afficher le fragment QuizFragment  . Cela donne :

private void goToWelcomeFragment(){
    WelcomeFragment welcomeFragment = WelcomeFragment.newInstance();
    FragmentManager fragmentManager = getParentFragmentManager();
    FragmentTransaction fragmentTransaction = fragmentManager
.beginTransaction();
    fragmentTransaction.replace(R.id.container, welcomeFragment).commit();
}

Votre quiz est maintenant opérationnel. Lancez l’application sur un appareil et amusez-vous.

Récapitulons en vidéo

Retrouvez ces différentes étapes dans la vidéo ci-dessous :

En résumé

  • Pour utiliser un ViewModel depuis une vue, il faut l’instancier via la classe ViewModelProvider  fournie par Google. 

  • Pour observer un LiveData et réagir à ses changements de valeur, il faut appeler la fonction observe()  .

  • L’accessibilité ne s’arrête pas à veiller aux contrastes et à la taille des boutons. Les personnes aveugles utilisent par exemple Talkback pour lire les éléments à l’écran.

  • La fonction announceForAccessibility  permet d’annoncer un message uniquement pour un lecteur d’écran. Il est utile de l’utiliser pour annoncer un changement visuel qui a lieu sur l’interface.

  • La classe Dialog  permet d’afficher une popup. Pour créer une instance de Dialog  avec un style par défaut, nous pouvons utiliser AlertDialog.Builder  .

Vous avez parcouru un sacré chemin en construisant votre première application Android. Bien joué. Mais ne partez pas tout de suite ; dans la prochaine partie, je vous donne des pistes pour faire évoluer votre application.

Example of certificate of achievement
Example of certificate of achievement