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 typeTextView
pour contenir la question ;answer1
,answer2
,answer3
etanswer4
, 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 fonctiononChanged()
qui sera appelée lorsqu’une nouvelle valeur sera émise dans leLiveData
.
À 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.
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 boutonnext
: “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.
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 classeAlertDialog.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.
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 deDialog
avec un style par défaut, nous pouvons utiliserAlertDialog.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.