Développer un quiz en Silverlight et WPF en partageant le même code.

Florian Casabianca

Cet article pour but d'illustrer au travers de la création d'un quiz comment développer une application compatible Silverlight 2 et WPF en utilisant notamment le pattern MV-VM.

Commentez cet article : Commentez

- Titre : Développer un quiz en Silverlight et WPF en partageant le même code.

- Auteur : Florian Casabianca

- Parution : 17 avril 2009

- Mise à jour : 17 avril 2009

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2009 Florian Casabianca. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts. Droits de diffusion permanents accordés à Developpez LLC.

Sommaire

- Introduction

- I. Présentation de l'application

- II. Développement Silverlight

- II-A. La classe Config

- II-B. Le model

- II-C. L'accès aux données

- II-D. Les services

- II-D-1. Le service de navigation

- II-D-2. Le service de gestion des quiz

- II-E. Views et ViewModels

- II-E-1. ViewModelBase

- II-E-2. Les commandes

- II-E-3. L'écran de connexion

- II-E-4. L'écran de sélection d'un quiz

- II-E-5. L'écran de jeu

- II-E-6. L'écran d'affichage du score

- III. Développement WPF

- III-A. Préparation du projet

- III-B. Les vues

- Conclusion

- Sources

- Liens

- Remerciements

- Contact

Introduction

Le développement d'applications sur les plateformes Silverlight et WPF possède de nombreux avantages :

- Nous pouvons garder notre langage favori (C#, VB.NET)

- Nous continuons à utiliser les mêmes outils (Visual Studio, Expression Blend, Expression Design)

- Nous utilisons les mêmes librairies (allégées dans le cas de Silverlight) et les mêmes patterns de développement.

Nous pouvons ainsi facilement mettre en pratique sur Silverlight nos connaissances acquises sur WPF, et vice versa. Le but étant de développer une fois du code XAML ou .NET et de le réutiliser entre les différentes plateformes.

Microsoft s'efforce de garder une compatibilité entre Silverlight et WPF. Par exemple le Visual State Manager est une fonctionnalité apparue en Silverlight 2 mais non disponible en WPF. Il a donc été entrepris de la porter sur WPF : dans un premier temps via le WPF Toolkit puis directement intégrée à la plateforme dans le Framework .NET 4. Inversement, un certains nombre de contrôles WPF ne sont pas embarqués de base dans Silverlight : WrapPanel, Viewbox, etc. Ces derniers ont donc été intégrés au Silverlight Toolkit.

Nous allons voir dans cet article comment développer une application ayant un maximum de code .NET et XAML compatibles entre Silverlight et WPF. L'application sera développée selon le pattern MV-VM et utilisera des technologies et concepts divers tels que LINQ, la sérialisation XML, le pattern commande, l'utilisation d'interfaces. Nous développerons tout d'abord notre application sur la plateforme Silverlight puis nous verrons ensuite comment en faire une implémentation WPF.

I - Présentation de l'application

Pour illustrer les différents principes que nous verrons au cours de cet article, nous allons développer un petit quiz. Le principe que vous connaissez sans doute est fort simple : une série de questions, plusieurs propositions de réponses. Le but étant de trouver la bonne.

L'application sera composée de quatre écrans. Le premier permettra à l'utilisateur de s'authentifier en saisissant son login (pour simplifier, nous ne gérerons pas ici la notion de mot de passe).

Le second présentera la liste des quiz disponibles afin que l'utilisateur puisse choisir lequel il souhaite démarrer.

Le troisième permettra le déroulement du quiz précédemment sélectionné. Les questions défileront les unes après les autres ; l'utilisateur devant répondre à la question en cours avant de passer à la suivante.

Arrivé à la dernière question il sera proposé à l'utilisateur d'afficher le dernier écran lui permettant de visualiser le score obtenu à l'issue du quiz. Un bouton permettra de revenir à l'écran de sélection d'un quiz pour rejouer encore une fois.

II - Développement Silverlight

Nous allons commencer par créer un nouveau projet de type Silverlight que nous appellerons SilverlightQuizDvp.Client. Visual Studio va aussi créer automatiquement un projet Web permettant d'héberger l'application Silverlight. Affichez l'écran des propriétés de ce projet afin de spécifier un numéro de port fixe :

Cela permettra d'avoir une adresse fixe lorsque l'on souhaitera accéder aux ressources qui s'y trouvent.

Comme vous le savez probablement, Visual Studio utilise le nom que vous avez donné à votre projet pour le nom de l'assembly générée et celui de l'espace de nom par défaut. Dans notre cas, il s'agit de Silverlight.Quiz.Dvp.Client. Notre but étant de réutiliser un maximum de code entre le client Silverlight et le client WPF, nous allons supprimer la référence à Silverlight dans ces noms. Cela nous évitera notamment de devoir renommer les espaces de nom lors de l'intégration du code dans l'application WPF.

Cette opération s'effectue dans les propriétés du projet Silverlight :

Le projet sera organisé de la façon suivante :

Comme vous le voyez, tout le code des différentes « couches » de l'application sera regroupé au sein du même projet. Pour ne pas complexifier l'exemple (et aussi parce que l'application est relativement simple) nous ne créerons pas de bibliothèques DAL, BL, etc.

II-A - La classe Config

Afin de stocker les différentes URL utilisées dans l'application, nous utiliserons une classe static appelée Config :

La classe Config

public static class Config
{
public static string URL_QUIZ_LIST = string.Format("{0}/../../Data/quizlist.xml", Application.Current.Host.Source);

public static string URL_REP_QUIZ = string.Format("{0}/../../Data", Application.Current.Host.Source);

public static string URL_REP_IMAGES = string.Format("{0}/../../Images", Application.Current.Host.Source);
}

La propriété Application.Current.Host.Source est propre à Silverlight et permet de récupérer l'URI du package XAP de l'application. Voici un exemple de ce que pourrait être cette adresse : http://localhost:1147/ClientBin/SilverlightQuizDvp.Client.xap. Nous remontons ensuite de deux niveaux pour nous retrouver à la racine du site et construire les différentes URI utilisées.

Pour plus de souplesse, peut-être que certains préférerez construire ces URL dynamiquement en fonction de paramètres passés à l'application Silverlight.

Ces paramètres peuvent par exemple être spécifiés dans le code d'initialisation du plugin dans la page HTML :

<param name="initParams"   value="param1=value1" />

II-B - Le model

Afin de représenter les quiz au sein de notre application nous créons les classes Quiz, Question et Proposition dont les relations qui les lient sont modélisées sur le schéma suivant :

Le model est assez simple. Un quiz possède une liste de questions. Chaque question étant liée à une liste de propositions. La classe Question possède une liste de propositions et a une référence sur la proposition sélectionnée par l'utilisateur.

La propriété IsTrue de la classe Proposition indique si la proposition correspond à la bonne réponse de la question associée.

Nous allons de plus créer une classe QuizInfo permettant de récupérer les informations sur un quiz sans avoir à le charger complètement (notamment la liste des questions).

Enfin, nous créons une classe permettant de représenter l'utilisateur actuellement en train de jouer. Il s'agit d'une classe implémentant le pattern singleton. La propriété Current retourne l'utilisateur courant.

II-C - L'accès aux données

Les données concernant les quiz seront stockées dans des fichiers XML situés dans un répertoire Data à la racine du site Web hébergeant l'application Silverlight. Le nom du fichier XML d'un quiz est constitué du code du quiz suivi de l'extension « .xml ». Un fichier quizlist.xml listera quant à lui les quiz disponibles.

Nous utilisons ici des fichiers XML afin de simplifier l'application, mais vous pourriez tout à fait récupérer vos données via un Web service SOAP ou REST. Vous auriez alors le choix d'utiliser WCF ou ADO.NET Data Services. Pour plus d'information sur l'utilisation d'un Web service WCF avec Silverlight vous pouvez consulter les tutoriaux Appeler un service WCF depuis Silverlight ou Silverlight : Se connecter à une base de données grâce à Linq et WCF de Ludovic Lefort.

Bien que très puissantes et faciles d'utilisation, ces technologies ont l'inconvénient de nécessiter un environnement Windows coté serveur, pas franchement nécessaire pour notre simple quiz. Vous pouvez donc utiliser des fichiers XML que l'application Silverlight viendra charger (ce que nous allons faire dans notre cas) ou bien générer dynamiquement ce XML via une autre technologie serveur (PHP, Servlets Java, etc.). Il vous serez ainsi possible par exemple d'utiliser l'espace que propose votre fournisseur d'accès (en général un serveur apache avec PHP et MySQL) afin de stocker en base les différents quiz et de générer via PHP le XML correspondant. Pour en savoir plus sur l'utilisation de Silverlight avec PHP et MySQL vous pouvez lire le tutorial de Nico-pyright(c) sur le sujet : Tutoriel : Utiliser Silverlight 2 avec MySQL en C#.

Voici à quoi ressemble le XML représentant la liste des quiz disponibles :

XML de la liste des quiz disponibles

<?xml version="1.0" encoding="utf-8" ?>
<QuizList>
<QuizInfo>
<Code>quizcsharp</Code>
<Name>Quiz C#</Name>
<Description>Un quiz sur le langage C#</Description>
<Image>csharp.jpg</Image>
<NbQuestions>3</NbQuestions>
</QuizInfo>
<QuizInfo>
<Code>quizsilverlight</Code>
<Name>Quiz Silverlight</Name>
<Description>Un quiz sur Silverlight</Description>
<Image>microsoft_silverlight.jpg</Image>
<NbQuestions>3</NbQuestions>
</QuizInfo>
</QuizList>

Et voici le XML d'un quiz :

XML d'un quiz

<?xml version="1.0" encoding="utf-8" ?>
<Quiz>
<Code>quizcsharp</Code>
<Name>Quiz C#</Name>
<Description>Un quiz sur le langage C#</Description>
<Image>AutumnLeaves.jpg</Image>
<Questions>
<Question Id="1">
<Text>Qu'est ce qu'une classe partielle ?</Text>
<Propositions>
<Proposition Id="1" IsTrue="false">
<Text>C'est une classe que l'on n'a pas fini de coder.</Text>
</Proposition>
<Proposition Id="2" IsTrue="true">
<Text>Une classe partielle est tout simplement une classe dont la définition est séparée dans plusieurs fichiers sources distincts.</Text>
</Proposition>
<Proposition Id="3" IsTrue="false">
<Text>C'est l'autre nom donné aux structures.</Text>
</Proposition>
</Propositions>
</Question>
</Questions>
</Quiz>

Pour récupérer ces données depuis l'application Silverlight, nous utiliserons deux classes : LoadListQuizInfosRequest et LoadQuizRequest. Celles-ci seront chargées d'effectuer une requête sur le serveur afin d'y récupérer les données et de renvoyer un objet QuizInfo pour LoadListQuizInfosRequest et Quiz pour LoadQuizRequest.

Enfin, n'oublions pas que toute requête sur un serveur depuis une application Silverlight doit se faire en asynchrone. C'est pourquoi ces deux classes passent par un évènement pour renvoyer le résultat de la requête.

La classe LoadListQuizInfosRequestprend en paramètre l'URL du fichier XML contenant la liste des quiz disponibles ainsi que l'URL du répertoire des images associées à chaque quiz. La méthode Execute permet d'exécuter la requête sur le serveur. Une fois les données rapatriées et l'objet QuizInfos construit, l'évènement LoadListQuizInfosRequestCompleted est déclenché.

La méthode Execute utilise la fameuse classe WebClient et sa méthode asynchrone OpenReadAsync pour ouvrir un flux en lecture sur le fichier XML contenant la liste des quiz disponibles.

public void Execute()
{
WebClient quizLoader = new WebClient();
quizLoader.OpenReadCompleted += new OpenReadCompletedEventHandler(quizLoader_OpenReadCompleted);
quizLoader.OpenReadAsync(new Uri(_urlRequest, UriKind.Absolute));
}

Une fois le flux ouvert nous chargeons les données dans un XElement afin de les manipuler avec Linq to XML.

XElement listQuizXml = XElement.Load(reader);

var listQuiz = from quiznode in listQuizXml.Descendants("QuizInfo")
let url = string.Format("{0}/{1}", _urlImagesFolder, quiznode.Element("Image").Value)
select new QuizInfo
{
Code = quiznode.Element("Code").Value,
Name = quiznode.Element("Name").Value,
Description = quiznode.Element("Description").Value,
ImageUrl = url,
NbQuestions = Convert.ToInt32(quiznode.Element("NbQuestions").Value)
};

La classe LoadQuizRequest permet quant à elle de récupérer les données concernant un quiz particulier. Son constructeur prend en paramètre l'URL vers le fichier du quiz.

La méthode Execute est la même que celle de la classe LoadListQuizInfosRequest. Pour le traitement des données nous utilisons cette fois-ci la dé-sérialisation XML, histoire de changer un peu :

System.Xml.XmlReader reader = System.Xml.XmlReader.Create(e.Result);

XmlSerializer serializer = new XmlSerializer(typeof(Quiz));

Quiz response = serializer.Deserialize(reader) as Quiz;

Ces différentes classes ne sont en rien spécifiques à Silverlight et pourront donc aisément être réutilisée dans la version WPF du quiz.

II-D - Les services

II-D-1 - Le service de navigation

Comme nous l'avons vu en début d'article, l'interface graphique du quiz sera constituée de plusieurs écrans. Pour faciliter la navigation d'un écran à un autre nous allons créer un service de navigation. Comme pour tout service, nous définissons en premier lieu une interface. C'est via cette interface que sera référencé le service dans tout le code de l'application. Cela nous permettra d'en faire une implémentation spécifique à Silverlight et une à WPF sans changer quoi que ce soit dans le code l'utilisant.

L' interface INavigationService

public interface INavigationService
{
void Navigate(UIElement newPage);
}

L'interface ne possède qu'une seule méthode Navigate prenant en paramètre un UIElement et permettant d'afficher ce dernier à l'écran en lieu et place de l'actuel écran.

L'implémentation de cette interface dans Silverlight, la classe NavigationService, prend en paramètre une référence sur l'objet Grid servant de conteneur parent à l'application Silverlight.

La classe NavigationService

public class NavigationService : INavigationService
{
private readonly Grid _root;

public NavigationService(Grid root)
{
if (root == null) throw new ArgumentNullException("root");

_root = root;
}

public void Navigate(UIElement newPage)
{
_root.Children.Add(newPage);
if (_root.Children[0] != null && _root.Children[0] != newPage)
{
UserControl oldPage = _root.Children[0];
_root.Children.Remove(oldPage);
}
}
}

La méthode Navigate se contente ici de remplacer le contenu de la Grid avec l'UIElement passé en paramètre.

II-D-2 - Le service de gestion des quiz

Pour gérer les différents quiz nous utiliserons un deuxième service nommé sans surprise QuizService implémentant l'interface IQuizService :

L' interface IQuizService

public interface IQuizService
{
event EventHandler<LoadListQuizInfosCompletedEventArgs> QuizListInfoLoaded;
event EventHandler<LoadQuizCompletedEventArgs> QuizLoaded;

IList<QuizInfo> QuizInfosList { get; }

bool IsQuizListLoaded { get; }

void LoadQuizList();

void LoadQuiz(string codeQuiz);

Quiz GetQuiz(string codeQuiz);

int GetScoreQuiz(Quiz quiz);
}

La propriété QuizInfosList renvoie une liste de QuizInfos (une fois que les données ont été chargées). IsQuizListLoaded indique si les données sur les quiz disponibles ont déjà été chargées depuis le serveur. Pour les charger, il est nécessaire d'appeler la méthode LoadQuizList qui lancera l'évènement QuizListInfoLoaded une fois le chargement effectué.

Chargement de la liste des quiz

public void LoadQuizList()
{
LoadListQuizInfosRequest request = new LoadListQuizInfosRequest(Config.URL_QUIZ_LIST, Config.URL_REP_IMAGES);
request.LoadListQuizInfosRequestCompleted += request_LoadQuizListRequestCompleted;
request.Execute();
}

void request_LoadQuizListRequestCompleted(object sender, LoadListQuizInfosRequestCompletedEventArgs e)
{
_quizList = e.Response;
IsQuizListLoaded = true;
OnLoadQuizRequestCompleted(e.Response, e.Error);
}

La méthode LoadQuiz prend en paramètre le code d'un quiz et charge les données correspondantes depuis le serveur. Une fois le quiz chargé, celui-ci est stocké dans un Dictionnaire et l'évènement QuizLoaded est lancé. Voici l'implémentation de cette méthode :

Chargement d'un quiz

public void LoadQuiz(string codeQuiz)
{
if (codeQuiz == null) throw new ArgumentNullException("codeQuiz");

//si le quiz a déjà été chargé
if(_quizDico.ContainsKey(codeQuiz)) return;

string quizUrl = string.Format("{0}/{1}.xml", Config.URL_REP_QUIZ, codeQuiz);

LoadQuizRequest request = new LoadQuizRequest(quizUrl);
request.LoadQuizRequestCompleted += request_LoadQuizRequestCompleted;
request.Execute();
}

void request_LoadQuizRequestCompleted(object sender, LoadQuizRequestCompletedEventArgs e)
{
_quizDico.Add(e.Response.Code, e.Response);
OnQuizLoadedCompleted(e.Response, e.Error);
}

Si un quiz a déjà été chargé, la méthode GetQuiz permet de le récupérer via son code :

Méthode GetQuiz

public Quiz GetQuiz(string codequiz)
{
if (_quizDico.ContainsKey(codequiz))
{
return _quizDico[codequiz];
}
return null;
}

Enfin, la fonction GetScoreQuiz permet de calculer le score obtenu par l'utilisateur sur un quiz donné. Dans son implémentation nous utilisons une expression lambda afin d'itérer sur chaque question du quiz et vérifier si l'utilisateur a choisi la bonne réponse.

Correction d'un quiz

public int GetScoreQuiz(Quiz quiz)
{
int score = 0;
quiz.Questions.ForEach((question) =>
{
if (question.SelectedProposition != null && question.SelectedProposition.IsTrue)
score++;
});
return score;
}

Afin d'accéder aux différents services et de les enregistrer lors de leur création, nous allons utiliser une classe ApplicationService (via une interface IApplicationService).

L'interface IApplicationService

public interface IApplicationService : IServiceProvider
{
void AddService(Type serviceType, object serviceInstance);
void RemoveService(Type serviceType);

INavigationService NavigationService { get; }
IQuizService QuizService { get; }
}

La méthode AddService permet d'enregistrer une instance d'un service et la méthode RemoveService permet de supprimer une instance déjà référencée. Nous rajoutons de plus deux propriétés permettant d'accéder plus facilement (et de manière typée) aux deux services de notre application. Vous remarquerez que l'interface IApplicationService impose l'implémentation de l'interface IServiceProvider. Cette dernière est une interface du Framework qui est utilisée lorsque l'on crée un objet fournissant d'autres services. Elle ne contient qu'une méthode GetService prenant un type en paramètre et renvoyant l'instance correspondante, mais typée en Objet.

Dans l'implémentation de cette interface, i.e. la classe ApplicationService, nous utilisons un dictionnaire afin de stocker les instances des services. Voici le code complet de cette classe :

La classe ApplicationService

internal class ApplicationService : IApplicationService
{
private readonly Dictionary<Type, object> _servicesTable;

public ApplicationService()
{
_servicesTable = new Dictionary<Type, object>();
}

public object GetService(Type serviceType)
{
if (serviceType == null)
{
throw new ArgumentNullException("serviceType");
}
return _servicesTable[serviceType];
}

public void AddService(Type serviceType, object serviceInstance)
{
if (serviceType == null) throw new ArgumentNullException("serviceType");
if (serviceInstance == null) throw new ArgumentNullException("serviceInstance");

if (!serviceType.IsInstanceOfType(serviceInstance))
{
throw new ArgumentException(string.Format("L'objet de service n'est pas de type {0}", serviceType.Name), "serviceInstance");
}
_servicesTable.Add(serviceType, serviceInstance);

}

public void RemoveService(Type serviceType)
{
if (serviceType == null)
{
throw new ArgumentNullException("serviceType");
}
_servicesTable.Remove(serviceType);
}

public INavigationService NavigationService
{
get
{
return (INavigationService)GetService(typeof(INavigationService));
}
}

public IQuizService QuizService
{
get
{
return (IQuizService)GetService(typeof(IQuizService));
}
}
}

Enfin, l'instanciation de la classe ApplicationService se fera via la classe static ApplicationServiceProvider. La propriété ApplicationService permettra d'accéder à cette instance unique (singleton).

La classe ApplicationServiceProvider

public static class ApplicationServiceProvider
{
private static readonly IApplicationService applicationService = new ApplicationService();

public static IApplicationService ApplicationService
{
get { return applicationService; }
}
}

La création et l'enregistrement des services se feront au démarrage de l'application Silverlight, dans la méthode Application_Startup :

Création et enregistrement des services

private void Application_Startup(object sender, StartupEventArgs e)
{
// Load the main control
Grid root = new Grid();
this.RootVisual = root;

//création des services
ApplicationServiceProvider.ApplicationService.AddService(typeof(INavigationService), new NavigationService(root));
ApplicationServiceProvider.ApplicationService.AddService(typeof(IQuizService), new QuizService());

//affichage de la page de login
LoginViewModel loginViewModel = new LoginViewModel();
LoginView loginView = new LoginView(loginViewModel);

ApplicationServiceProvider.ApplicationService.NavigationService.Navigate(loginView);
}

Une solution plus élégante eût été d'utiliser un Framework d'injection de dépendances, tel qu'Unity (disponible sur le site Codeplex). Ce dernier est disponible pour les applications desktop mais aussi Silverlight depuis la version 1.2, ce qui permettait de garder une compatibilité de code entre les deux plateformes. Son utilisation dépasse le cadre de cet article, mais vous pouvez retrouver plus d'informations sur le sujet via les liens disponibles en fin d'article.

II-E - Views et ViewModels

Après avoir vu le modèle de l'application nous allons nous intéresser aux deux autres composants du pattern MV-VM : les vues (views) et les Vue-Modèles (viewModels).

II-E-1 - ViewModelBase

Les différents ViewModels du pattern MV-VM ont en général un certain nombre de fonctionnalités en commun (implémentation de INotifyPropertyChanged par exemple). Pour ne pas avoir à réécrire ce même code plusieurs fois il est possible de le placer dans une classe de base (ViewModelBase) dont hériteront tous (ou une partie) les ViewModels. Dans le cas de notre application nous n'aurons en commun que l'implémentation de l'interface INotifyPropertyChanged ainsi qu'une propriété DisplayName contenant le titre de l'écran associé.

La classe ViewModelBase

public abstract class ViewModelBase : INotifyPropertyChanged
{
public abstract string DisplayName { get; }

public event PropertyChangedEventHandler PropertyChanged;

protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChangedEventHandler handler = this.PropertyChanged;
if (handler != null)
{
var e = new PropertyChangedEventArgs(propertyName);
handler(this, e);
}
}
}

Dans cette classe, nous déclarons une méthode OnPropertyChanged prenant en paramètre le nom d'une propriété en levant l'évènement PropertyChanged correspondant.

II-E-2 - Les commandes

En plus des propriétés, les ViewModels peuvent exposer des commandes (instance d'un type implémentant l'interface ICommand).

Cependant, contrairement à WPF, Silverlight 2 n'offre pas de support natif pour les commandes (bien qu'il contienne l'interface ICommand). Vous ne pouvez en effet pas faire de binding entre une commande et la propriété Command d'un bouton, cette dernière n'existant pas.

La Composite Application Library 2 (qui cible les plateformes Silverlight et WPF) sortie en février 2009 contient une implémentation du pattern Command pour Silverlight qui ressemble à celle de WPF et permet ainsi de partager les ViewsModels entre des applications Silverlight et WPF. Elle offre de plus la possibilité de « binder » des commandes à des contrôles grâce à l'utilisation des AttachedProperties.

Cette librairie introduit ainsi la classe générique DelegateCommand<T>, une implémentation de la l'interface ICommand, qui permet de déclarer les méthodes Execute et CanExecute de la commande au sein du ViewModel et des les injecter via des délégués au travers de son constructeur. Il s'agit en fait du principe de la classe RelayCommand introduite par Josh Smith dans son projet Crack .NET. Nous utiliserons cette classe pour les commandes Silverlight, mais aussi WPF. En effet, le système de commandes (RoutedCommands) inclus dans WPF n'est pas adapté au pattern MV-VM. Les RoutedCommands nécessitent notamment un handler dans le code behind de la vue.

Après avoir téléchargée et compilée la Composite Application Library vous devrez référencer les assemblies Microsoft.Practices.Composite et Microsoft.Practices.Composite.Presentation sur le projet Silverlight et sur le projet WPF.

Vous pouvez aussi retrouver le principe de la classe DelegateCommand dans la librairie Silverlight Extensions disponible sur le site Codeplex.

II-E-3 - L'écran de connexion

Le premier écran de l'application est constitué d'une zone de texte (afin que l'utilisateur puisse saisir son nom) et d'un bouton pour passer à l'écran suivant de sélection d'un quiz. Tant que l'utilisateur n'a pas saisi son nom, le bouton de connexion doit rester désactivé.

Le ViewModel (classe LoginViewModel) associé à cette View exposera donc une propriété UserName(de type String), ainsi qu'une commande ConnectCommand qui sera appelée lorsque l'utilisateur cliquera sur le bouton de connexion. De plus nous redéfinissons la propriété DisplayName provenant de la classe de base ViewModelBase :

public class LoginViewModel : ViewModelBase
{
private string _userName;
public string UserName
{
get { return _userName; }
set
{
_userName = value;
OnPropertyChanged("UserName");
_connectCommand.RaiseCanExecuteChanged();
}
}

public override string DisplayName
{
get { return "Le quiz DVP"; }
}
..
}

Dans le setter de la propriété UserName nous appelons la méthode RaiseCanExecuteChanged de la classe DelegateCommand. Cela a pour effet de réévaluer la valeur retournée par la méthode CanConnect afin de mettre à jour l'état du bouton.

Concernant la commande nous allons utiliser la classe DelegateCommand vue précédemment.

public LoginViewModel()
{
_connectCommand = new DelegateCommand<string>(
Connect,
CanConnect);
}

private readonly DelegateCommand<string> _connectCommand;

public ICommand ConnectCommand
{
get
{
return _connectCommand;
}
}

void Connect(string username)
{
if (!CanConnect(username))
{
return;
}
User.Current.UserName = username;
User.Current.IsLoggedIn = true;

//on affiche l'écran de sélection des quiz
ChooseQuizCommand showSelectQuizViewCommand = new ChooseQuizCommand();
showSelectQuizViewCommand.Execute(null);
}

bool CanConnect(string username)
{
if (string.IsNullOrEmpty(username))
{
return false;
}
return true;
}

Dans le constructeur nous créons une instance de la classe DelegateCommand<string> (string car le paramètre des méthodes Connect et CanConnect sera de type string) en lui passant en paramètre des délégués vers les méthodes Connect et CanConnect définies au sein de la classe LoginViewModel. Ces deux méthodes prennent en paramètre le nom que l'utilisateur aura saisi dans la zone de texte.

La méthode Connect renseigne les informations sur l'utilisateur courant et affiche l'écran de sélection d'un quiz.

La méthode CanConnect est plus simple et vérifie simplement si l'utilisateur a bien saisi son nom. Si ce n'est pas le cas, elle renverra la valeur false ce qui aura comme conséquence la désactivation du bouton de connexion.

Passons maintenant à la vue qui n'est rien d'autre qu'un UserControl appelé LoginView.

Le titre de l'écran (propriété DisplayName du ViewModel) sera affiché via un TextBlock :

<TextBlock Text="{Binding DisplayName}" Style="{StaticResource DisplayNameStyle}" />

Le bouton de connexion sera « bindé » à la commande ConnectCommand :

<Button Commands:Click.Command="{Binding Path=ConnectCommand}"
Commands:Click.CommandParameter="{Binding Path=UserName}"
Content="Connexion" x:Name="btnConnect"/>

Ce binding est possible grâce à la Composite Application Library qui apporte les propriétés attachées adéquates. N'oubliez pas déclarer son espace de nom dans le code XAML de la vue :

xmlns:Commands="clr-namespace:Microsoft.Practices.Composite.Presentation.Commands;
assembly=Microsoft.Practices.Composite.Presentation"

La zone de texte permettant de saisir le nom de l'utilisateur sera représentée par un contrôle TextBox « bindée » à la propriété UserName du ViewModel :

<TextBox Text="{Binding UserName, Mode=TwoWay}" x:Name="txtName" TextChanged="TextBox_TextChanged" />

En voyant ce code, vous devez surement vous demander pourquoi nous abonnons à l'évènement TextChanged. Pour mieux comprendre, revenons un moment sur le comportement souhaité sur la page. Nous voulons que le bouton de connexion soit désactivé tant que l'utilisateur n'a pas saisi son nom. Autrement dit, le bouton doit être activé lorsque la moindre lettre est entrée dans la zone de texte (ce qui aura pour effet de réévaluer la méthode CanExecute de la commande associée et d'agir sur la propriété IsEnable du bouton). Il nous faut donc mettre à jour la propriété UserName à chaque changement dans la zone de texte. En WPF cela se fait facilement en mettant la propriété UpdatSourceTrigger du binding à la valeur PropertyChanged. Cependant, cette fonctionnalité n'est pas présente en Silverlight. Toutes les mises à jour en mode TwoWay se font immédiatement (équivalent à un UpdatSourceTrigger à la valeur PropertyChanged) sauf dans le cas de la TexBox où les modifications se font lorsque le focus est perdu. La propriété UserName ne sera donc mise à jour avec le contenu de la zone de texte que lorsque celle-ci aura perdu le focus.

Pour pallier ce problème, nous donnerons manuellement le focus à un autre contrôle de l'écran puis le réattribuerons à la zone de texte, et ce, à chaque déclenchement de l'évènement TextChanged. L'écran de login étant très simple, le seul autre contrôle pouvant recevoir le focus est le bouton de connexion. Celui-ci devant de plus être actif afin de recevoir le focus :

private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
bool btnIsEnable = btnConnect.IsEnabled;
btnConnect.IsEnabled = true;
btnConnect.Focus();
txtName.Focus();
btnConnect.IsEnabled = btnIsEnable;
}

Il s'agit clairement d'un hack, mais nous y sommes ici forcés. Cependant, cela ne remet pas en cause la compatibilité avec la plateforme WPF. Le ViewModel reste indépendant de ce problème graphique et concernant la vue, il suffira de retirer le traitement de l'évènement TextChanged.

Grâce au binding du ViewModel sur la vue, le code-behind de celle-ci est pratiquement vide (mis à part le hack vu précédemment):

Code behind de la vue LoginView

public partial class LoginView : UserControl
{
public LoginView(LoginViewModel loginViewModel)
{
if (loginViewModel == null) throw new ArgumentNullException("loginViewModel");
InitializeComponent();
DataContext = _loginViewModel;
}
}

Il faut simplement affecter le DataContext de la vue avec le ViewModel passé en paramètre de son constructeur.

II-E-4 - L'écran de sélection d'un quiz

Une fois passé l'écran de connexion nous arrivons à l'écran de sélection d'un quiz. Celui-ci nous permet de choisir le quiz auquel nous souhaitons jouer parmi l'ensemble des quiz disponibles. L'écran sera représenté par le UserControl SelectQuizView auquel nous associerons le ViewModel SelectQuizViewModel.

Dans le constructeur du ViewModel nous interrogeons le service gérant les quiz afin de vérifier si ceux-ci ont déjà été chargés. Si ce n'est pas le cas alors nous demandons à le faire en appelant la méthode LoadQuizList. Nous devons alors nous abonner à l'évènement QuizListInfoLoaded afin d'être informés de la fin du chargement. Enfin, nous initialisons la commande PlaySelectedQuizCommand qui sera « bindée » au bouton de l'interface graphique.

private readonly IQuizService _quizService;

public SelectQuizViewModel(IQuizService quizService)
{
if (quizService == null) throw new ArgumentNullException("quizService");

_quizService = quizService;

QuizList = new ObservableCollection<QuizInfo>();

//si la liste n'est pas déjà chargée
if (!_quizService.IsQuizListLoaded)
{
_quizService.QuizListInfoLoaded += OnQuizListLoaded;
_quizService.LoadQuizList();
}
else
{
SetQuizList();
}

_playSelectedQuizCommand = new DelegateCommand<QuizInfo>(
PlaySelectedQuiz,
CanPlaySelectedQuiz);
}

private void SetQuizList()
{
QuizList.Clear();
foreach (QuizInfo quizInfo in _quizService.QuizInfosList)
{
QuizList.Add(quizInfo);
}
}

void OnQuizListLoaded(object sender, LoadListQuizInfosCompletedEventArgs e)
{
if (e.Error == null)
{
SetQuizList();
}
else
{
Error = string.Format("Erreur du chargement de la liste des quiz: {0}", e.Error);
}
}

public ObservableCollection<QuizInfo> QuizList { get; private set; }
public override string DisplayName
{
get { return "Selection d'un quiz"; }
}

Une fois la liste chargée nous l'utilisons afin de remplir la collection QuizList. Cette dernière étant de type ObservableCollection, l'interface graphique sera automatiquement mise à jour afin d'afficher la liste des quiz.

Si une erreur survenait lors du chargement, sa description serait affectée à la propriété Error et affichée à l'écran grâce au binding sur cette propriété.

Nous aurons aussi besoin d'une propriété SelectedQuiz afin de récupérer le quiz sélectionné par l'utilisateur. Dans le setter de la propriété, nous appelons la méthode RaiseCanExecuteChanged de la commande PlaySelectedQuizCommand afin de réévaluer la méthode CanPlaySelectedQuiz (voir plus bas) et mettre à jour l'état du bouton.

private QuizInfo _selectedQuiz;
public QuizInfo SelectedQuiz
{
get { return _selectedQuiz; }
set
{
_selectedQuiz = value;
OnPropertyChanged("SelectedQuiz");
_playSelectedQuizCommand.RaiseCanExecuteChanged();
}
}

Voyons maintenant la commande PlaySelectedQuizCommand :

private readonly DelegateCommand<QuizInfo> _playSelectedQuizCommand;

public ICommand PlaySelectedQuizCommand
{
get
{
return _playSelectedQuizCommand;
}
}

void PlaySelectedQuiz(QuizInfo quiz)
{
if (!CanPlaySelectedQuiz(quiz))
{
return;
}
//on charge le quiz
_quizService.QuizLoaded += OnQuizLoaded;
_quizService.LoadQuiz(quiz.Code);
}

void OnQuizLoaded(object sender, LoadQuizCompletedEventArgs e)
{
if (e.Error == null)
{
//on affiche l'écran du quiz
PlayQuizCommand playQuizCommand = new PlayQuizCommand(e.Response);
playQuizCommand.Execute(null);
}
else
{
Error = string.Format("Erreur lors du chargement du quiz: {0}", e.Error.Message);
}
_quizService.QuizListInfoLoaded -= OnQuizListLoaded;
}

bool CanPlaySelectedQuiz(QuizInfo quiz)
{
if (quiz == null)
{
return false;
}
return true;
}

La commande prend en paramètre un objet de type QuizInfo (l'élément sélectionné dans la liste des quiz disponibles). Une fois la commande lancée nous lançons le chargement du quiz sélectionné via la méthode LoadQuiz du service de gestion des quiz. Ce chargement est asynchrone et nous devons nous abonner à l'évènement QuizLoaded afin d'être notifié lorsqu'il prendra fin.

Une fois le quiz chargé, et s'il n'y a pas d'erreur, nous exécutons la commande PlayQuizCommand qui va affichera l'écran permettant d'y jouer.

Analysons maintenant le code de la vue associée.

Pour l'affichage de la liste des quiz disponibles, nous utiliserons un composant Listbox « bindé » à la propriété QuizList du ViewModel. La propriété SelectedItem de la ListBox sera liée à la propriété SelectedQuiz.

Nous créons ensuite un simple DataTemplate permettant d'afficher l'image, le nom et le nombre de questions de chaque quiz.

Code XAML de la liste des quiz.

<ListBox ItemsSource="{Binding QuizList}"
SelectedItem="{Binding SelectedQuiz, Mode=TwoWay}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Margin="10" Orientation="Horizontal" HorizontalAlignment="Center"  />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<Border>
<StackPanel>
<Image Source="{Binding ImageUrl}" Height="50"/>
<TextBlock Text="{Binding Name}"/>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding NbQuestions}"/>
<TextBlock Text=" questions"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

Le code XAML du bouton doit maintenant vous être familier. Le paramètre de la commande est « bindé » sur la propriété SelectedQuiz :

<Button
Commands:Click.Command="{Binding Path=PlaySelectedQuizCommand}"
Commands:Click.CommandParameter="{Binding Path=SelectedQuiz}"
Content="Jouer au quiz !"  />

Enfin, le TextBlock permettant d'afficher les erreurs survenues lors du chargement d'un quiz est « bindé » sur la propriété Error :

<TextBlock Text="{Binding Error}"/>

II-E-5 - L'écran de jeu

Cet écran permet de jouer au quiz sélectionné dans l'écran précédent. Il affiche le nom du quiz, la question courante (ainsi que son numéro) et la liste des propositions pour cette question sous forme de radioBoutons. Un bouton « Suivant » (lié à une commande) permet de faire défiler les questions un à une (le bouton est désactivé tant que l'utilisateur n'a pas répondu à la question courante). Le texte de ce bouton devra indiquer « Terminer » une fois arrivé à la dernière question.

Le ViewModel reçoit donc en paramètre de son constructeur le quiz que l'utilisateur a sélectionné dans l'écran précédent. Nous y créons la commande NextQuestionCommand et nous positionnons sur la première question du quiz :

Constructeur de la classe PlayQuizViewModel

private readonly Quiz _quiz;
private readonly DelegateCommand<object> _nextQuestionCommand;

public PlayQuizViewModel(Quiz quiz)
{
if (quiz == null) throw new ArgumentNullException("quiz");

_quiz = quiz;

_nextQuestionCommand = new DelegateCommand<object>(
NextQuestion,
CanNextQuestion);

CurrentQuestion = _quiz.Questions[0];
}

Nous aurons donc besoin d'une propriété CurrentQuestion référençant la question courante, d'une propriété IsOnLastQuestion indiquant si la question actuelle est la dernière ou non, d'une propriété CurrentQuestionIndex indiquant le numéro de la question courante et d'une propriété SelectedProposition indiquant le choix de réponse de l'utilisateur pour la question en cours :

Propriété de la classe PlayQuizViewModel

public override string DisplayName
{
get { return _quiz.Name; }
}

private Question _currentQuestion;
public Question CurrentQuestion
{
get { return _currentQuestion; }
private set
{
_currentQuestion = value;
OnPropertyChanged("CurrentQuestion");
OnPropertyChanged("IsOnLastQuestion");
OnPropertyChanged("CurrentQuestionIndex");
}
}

public bool IsOnLastQuestion
{
get { return CurrentQuestionIndex == _quiz.Questions.Count; }
}

public int CurrentQuestionIndex
{
get
{
if (CurrentQuestion == null)
{
return -1;
}
return _quiz.Questions.IndexOf(CurrentQuestion) + 1;
}
}

public Proposition SelectedProposition
{
get { return CurrentQuestion.SelectedProposition; }
set
{
CurrentQuestion.SelectedProposition = value;
OnPropertyChanged("SelectedProposition");
_nextQuestionCommand.RaiseCanExecuteChanged();
}
}

Dans le setter de la propriété SelectedProposition nous appelons la méthode RaiseCanExecuteChanged de la commande NextQuestionCommand afin de réévaluer la méthode CanNextQuestion permettant ainsi d'activer le bouton lorsqu'une proposition a été sélectionnée.

Passons au code la commande NextQuestionCommand :

La commande NextQuestionCommand

public ICommand NextQuestionCommand
{
get
{
return _nextQuestionCommand;
}
}

void NextQuestion(object o)
{
if (CanNextQuestion(o))
{
if (!IsOnLastQuestion) //s'il y a encore des questions
{
CurrentQuestion = _quiz.Questions[CurrentQuestionIndex];
}
else //on est arrivé à la dernière question
{
//on corrige le quiz
CorrectQuizCommand correctQuizCommand = new CorrectQuizCommand(_quiz);
correctQuizCommand.Execute(null);
}
}
}

bool CanNextQuestion(object o)
{
return CurrentQuestion != null && CurrentQuestion.SelectedProposition != null;
}

La méthode NextQuestion vérifie si la question en cours est la dernière ou non. Si ce n'est pas le cas alors on passe à la question suivante en modifiant la valeur de la propriété CurrentQuestion. S'il s'agit de la dernière question (l'utilisateur a cliqué sur le bouton affichant le texte « Terminer ») alors, on lance la correction du quiz.

La méthode CanNextQuestion vérifie si la question en cours et définie et si une proposition a été sélectionnée.

Voyons maintenant le code XAML de la vue associée.

L'affichage du numéro de la question courante ainsi que de son texte se fait par simple binding sur les propriétés correspondantes dans le ViewModel :

<StackPanel Orientation="Horizontal">
<TextBlock Text="Question n° "/>
<TextBlock Text="{Binding Path=CurrentQuestionIndex}" />
<TextBlock Text="{Binding Path=CurrentQuestion.Text}"/>
</StackPanel>

Concernant l'affichage des propositions nous allons utiliser une ListeBox « bindée » à la liste des propositions de la question en cours (CurrentQuestion). La propriété SelectedItem de la liste sera « bindée » à la propriété SelectedProposition du ViewModel.

Pour que la ListeBox affiche une liste de radioBoutons nous allons devoir modifier le Template de l'ItemContainerStyle. Pour que l'élément sélectionné dans la liste ait son radioBouton coché, nous devons « binder » sa propriété IsSelected avec la propriété IsChecked du radioBouton :

Code XAML de la liste des propositions

<ListBox ItemsSource="{Binding CurrentQuestion.Propositions}"
SelectedItem="{Binding SelectedProposition, Mode=TwoWay}">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<StackPanel Background="Transparent" HorizontalAlignment="Left">
<RadioButton IsChecked="{TemplateBinding IsSelected}" IsHitTestVisible="False">
<ContentPresenter/>
</RadioButton>
</StackPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path=Text}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

Le bouton « Suivant » sera lié à la commande NextQuestionCommand :

<Button Content="Suivant" Commands:Click.Command="{Binding Path=NextQuestionCommand}"
x:Name="btnNext"/>

Afin d'obtenir le comportement souhaité sur ce bouton (affichage du texte « Terminer » lorsque l'on arrive à la dernière question) nous serions tentés d'utiliser un DataTrigger « bindé » sur la propriété IsOnLastQuestion. Cependant cette solution n'est possible qu'en WPF, les Trigger n'étant pas supportés dans les styles en Silverlight. Nous allons donc utiliser un converter qui reverra « Suivant » ou « Terminer » en fonction de la valeur de la propriété IsOnLastQuestion.

Le code du convertisseur est très simple :

Classe BoolToTextConverter

public class BoolToTextConverter : IValueConverter
{
public object Convert(
object value,
Type targetType,
object parameter,
CultureInfo culture)
{
bool isOnLastQuestion = (bool)value;
return isOnLastQuestion ? "Terminer" : "Suivant";
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new System.NotImplementedException();
}
}

Pour l'utiliser dans la vue nous le déclarons en tant que ressource:

<UserControl.Resources>
<Helpers:BoolToTextConverter x:Key="BoolToTextConverter" />
</UserControl.Resources>

Puis nous lions la propriété Content du bouton avec la propriété IsOnLastQuestion du ViewModel en utilisant le converter :

<Button Content="{Binding IsOnLastQuestion, Converter={StaticResource BoolToTextConverter}}"
Command="{Binding Path=NextQuestionCommand}" />

Le code behind de la vue est lui toujours pratiquement vierge :

public partial class PlayQuizView : UserControl
{
private readonly PlayQuizViewModel _playQuizViewModel;

public PlayQuizView(PlayQuizViewModel playQuizViewModel)
{
if (playQuizViewModel == null) throw new ArgumentNullException("playQuizViewModel");

_playQuizViewModel = playQuizViewModel;

InitializeComponent();

DataContext = _playQuizViewModel;
}
}

II-E-6 - L'écran d'affichage du score

Une fois le quiz fini, il est temps de le corriger et d'afficher le résultat tant attend par l'utilisateur.

Le constructeur du ViewModel ScoreViewModel recevra en paramètre le quiz joué ainsi que le score obtenu.

Constructeur de la classe ScoreViewModel

private readonly Quiz _quiz;
private readonly int _score;
private readonly DelegateCommand<object> _playAgainCommand;

public ScoreViewModel(Quiz quiz, int score)
{
if (quiz == null) throw new ArgumentNullException("quiz");
_quiz = quiz;
_score = score;

_playAgainCommand = new DelegateCommand<object>(PlayAgain);
}

Lors de la construction de la commande PlayAgainCommand nous ne passons en paramètre qu'un délégué vers la méthode exécutant la commande. Nous n'utiliserons en effet pas de méthode « CanPlayAgainCommand» car celle-ci renverrait toujours vrai (rien ne peut bloquer l'exécution de la commande).

Le ViewModel exposera les propriétés UserName (nom de l'utilisateur), NbQuestions (nombre de questions du quiz) et WinCup indiquant si l'utilisateur a gagné le super prix (toutes les réponses bonnes).

Les propriétés de la classe ScoreViewModel

public string UserName
{
get  { return User.Current.UserName; }
}

public int Score
{
get { return _score; }
}

public int NbQuestions
{
get { return _quiz.Questions.Count; }
}

public bool WinCup
{
get { return Score == NbQuestions; }
}

public override string DisplayName
{
get { return "Résultat du Quiz"; }
}

Enfin, le code concernant la commande PlayAgainCommand :

Commande PlayAgainCommand

public ICommand PlayAgainCommand:
{
get { return _playAgainCommand; }
}

void PlayAgain(object o)
{
//on retourne à l'écran de sélection d'un quiz
ChooseQuizCommand showSelectQuizViewCommand = new ChooseQuizCommand();
showSelectQuizViewCommand.Execute(null);
}

La vue affichera un texte du type « Toto, vous avez répondu correctement à X questions sur Y ». Pour cela nous utilisons de simples TextBlocks « bindés » aux propriétés du ViewModel :

<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="{Binding UserName}"/>
<TextBlock Text=", vous avez répondu correctement à "/>
<TextBlock Text="{Binding Score}" />
<TextBlock Text=" question(s) sur "/>
<TextBlock Text="{Binding NbQuestions}" />
</StackPanel>

Si l'utilisateur gagne le super prix alors nous affichons une image représentant une personne tenant un trophée (voir capture d'écran). La propriété Visibility des contrôles Silverlight (et WPF) n'étant pas un booléen nous devons donc utiliser un convertisseur afin de la « binder » à la propriété WinCup du ViewModel.

Converter booléen/Visibility

public class VisibilityConverter : IValueConverter
{
public object Convert(
object value,
Type targetType,
object parameter,
CultureInfo culture)
{
bool visibility = (bool)value;
return visibility ? Visibility.Visible : Visibility.Collapsed;
}

public object ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture)
{
Visibility visibility = (Visibility)value;
return (visibility == Visibility.Visible);
}
}

Pour l'utiliser nous le déclarons tout d'abord dans les ressources du UserControl:

Déclaration du VisibilityConverter

<UserControl.Resources>
<Helpers:VisibilityConverter x:Key="VisibilityConverter" />
</UserControl.Resources>

Puis l'utilisons dans le binding de la propriété Visibility de l'image :

<Image Source="../Images/win.png"
Visibility="{Binding WinCup, Converter={StaticResource VisibilityConverter}}"/>

Enfin, le bouton permettant de retourner à l'écran de sélection d'un quiz est « bindé » à la commande PlayAgainCommand :

<Button Content="Jouer à un autre quiz"
Commands:Click.Command="{Binding Path=PlayAgainCommand}" />

Notre petite application Silverlight étant terminée nous allons maintenant la porter sur la plateforme WPF.

III - Développement WPF

III-A - Préparation du projet

Dans notre solution Visual Studio, nous commençons par créer un nouveau projet de type Application WPF. Nous allons ensuite modifier les propriétés du projet afin d'avoir le même espace de nom que dans le projet Silverlight :

La structure du projet WPF sera exactement la même que celle du projet Silverlight :

Il nous faut maintenant importer les fichiers de code du projet Silverlight dans le projet WPF. Rien de plus simple, il suffit de faire un clic droit et de choisir d'ajouter un élément existant. La fenêtre suivante devrait apparaitre :

Toute l'astuce consiste à ne pas ajouter le fichier via le bouton Add mais via Add as Link. La première option fait une copie du fichier sélectionné. Nous aurions donc deux fichiers (physiquement présent sur le disque) à gérer. Une modification dans l'un devrait être répercutée manuellement dans l'autre. Pas franchement idéal pour partager des sources entre nos projets Silverlight et WPF. La deuxième option va au contraire ajouter le fichier sélectionné en tant que lien. Nous n'aurons toujours qu'un seul fichier (physiquement présent sur le disque) mais référencé dans plusieurs projets. La modification de ce fichier sera ainsi automatiquement reflétée dans les différents projets où il est référencé.

Créer et maintenir ce genre de liens au sein de plusieurs projets risque de vite devenir un enfer lorsque vous avez plusieurs dizaines (centaines) de classes à gérer. C'est pourquoi l'équipe Patterns & Practices du projet Composite Application Guidance propose un outil de synchronisation : Project Linker. Ainsi lorsqu'un nouveau fichier est ajouté dans un projet A, un lien est automatiquement ajouté dans le projet B si les deux projets sont gérés par l'outil. Il en va de même pour la suppression, changement de nom, déplacement d'un fichier. Nous n'utiliserons pas ici cet outil, mais sachez qu'il existe et qu'il plus qu'intéressant de se servir.

Nous allons créer des liens dans le projet WPF pour tous les fichiers du projet Silverlight à l'exception des vues et des classes Config et NavigationService.

Concernant la classe Config nous devons la récréer afin de redéfinir les différentes URL :

public static class Config
{
private const string URL_SITE = "http://localhost:1147/";

public static string URL_QUIZ_LIST = string.Format("{0}Data/quizlist.xml", URL_SITE);

public static string URL_REP_QUIZ = string.Format("{0}Data", URL_SITE);

public static string URL_REP_IMAGES = string.Format("{0}Images", URL_SITE);
}

Nous allons aussi devoir ré-implémenter le service de navigation, car nous allons utiliser un contrôle Frame (non disponible en Silverlight) au niveau de l'interface graphique afin d'afficher les différents écrans. Cela permettra d'utiliser le Framework de navigation intégré.

Classe NavigationService dans le projet WPF

public class NavigationService : INavigationService
{
private readonly Frame _frame;

public NavigationService(Frame frame)
{
if (frame == null) throw new ArgumentNullException("frame");
_frame = frame;
}

public void Navigate(UIElement newPage)
{
_frame.Navigate(newPage);
}
}

Étant donné que le service de navigation est référencé via son interface INavigationService dans le code de l'application, la redéfinition de son implémentation ne change strictement rien dans le code appelant. L'utilisation d'interfaces permet d'améliorer grandement le partage de code entre les deux plateformes. Le principe étant de ne référencer que des interfaces dans le code et d'éventuellement avoir une implémentation différente sur chaque plateforme si cela est nécessaire.

Bref, programmez des interfaces, pas des implémentations !

Reprenons la classe ViewModelBase présentée en début d'article et qui concentre de fonctionnalités communes aux différents ViewModels. Dans l'article WPF Apps With The Model-View-ViewModel Design Pattern de MSDN Magazine du mois de février 2009 il est présenté une implémentation de cette classe possédant une fonctionnalité intéressante : la vérification que le nom de la propriété passée en paramètre de la méthode OnPropertyChanged correspond bien à une propriété existante sur le ViewModel. Ainsi, l'instruction OnPropertyChanged("Toto") produira une erreur lors de l'exécution si la propriété Toto n'existe pas sur le ViewModel. Il est en effet facile de faire une faute de frappe dans le nom de la propriété, mais beaucoup plus compliqué de s'en apercevoir si ce genre de vérification n'est pas mise en place.

Cette vérification est effectuée par la méthode VerifyPropertyName qui utilise la classe TypeDescriptor. Cette dernière n'est cependant pas disponible en Silverlight. Comment alors bénéficier de cette fonctionnalité au sein de la classe ViewModelBase tout en la gardant compatible Silverlight et WPF ? Pour cela nous allons utiliser les classes partielles et les directives de pré-processing permettant de réaliser de la compilation conditionnelle.

La première chose à faire est de passer la classe ViewModelBase en classe partielle :

partial class ViewModelBase

Ensuite, dans notre le projet WPF nous allons créer un fichier de classe partielle que nous appellerons ViewModelBase.Wpf et contenant la méthode VerifyPropertyName :

partial class ViewModelBase
{
[Conditional("DEBUG")]
[DebuggerStepThrough]
public void VerifyPropertyName(string propertyName)
{
// Verify that the property name matches a real,
// public, instance property on this object.
if (TypeDescriptor.GetProperties(this)[propertyName] == null)
{
string msg = "Invalid property name: " + propertyName;

if (this.ThrowOnInvalidPropertyName)
throw new Exception(msg);
else
Debug.Fail(msg);
}
}

private bool ThrowOnInvalidPropertyName
{
get { return false; }
}
}

Cette méthode est décorée de l'attribut Conditional indiquant au compilateur que tout appel à cette fonction sera ignoré sauf si le symbole de compilation conditionnelle (DEBUG dans notre cas) est spécifié. Cela permet par exemple d'activer cette vérification lors de la phase de développement et de la désactiver lors de la mise en production.

Reste maintenant à gérer l'appel à cette méthode dans la méthode OnPropertyChanged. Pour cela nous allons utiliser un symbole de compilation conditionnelle permettant d'indiquer si l'on se trouve dans une application WPF ou Silverlight :

protected virtual void OnPropertyChanged(string propertyName)
{
#if (WPF)
VerifyPropertyName(propertyName);
#endif

PropertyChangedEventHandler handler = this.PropertyChanged;
if (handler != null)
{
var e = new PropertyChangedEventArgs(propertyName);
handler(this, e);
}
}

Si le symbole WPF est défini alors nous aurons accès à la méthode VerifyPropertyName et nous pourrons l'utiliser. Dans le cas contraire, l'instruction sera ignorée par le compilateur.

Reste maintenant à définir les symboles de compilation conditionnelle utilisés (DEBUG et WPF). Cela peut s'effectuer dans les propriétés du projet, onglet Build :

La variable DEBUG est automatiquement définie lorsque le projet est compilé en mode Debug et automatiquement retirée lors de la compilation en mode Release. La zone de texte Conditional compilation symbols vous permet de définir vos propres variables.

L'utilisation de classes partielles et des directives de pré-processing nous sont donc d'une grande aide afin d'améliorer la compatibilité du code entre les deux plateformes. Utilisez-les cependant avec parcimonie : si les différences entre les plateformes deviennent compliquées à gérer, pensez plutôt à externaliser le code et à créer des classes propres à chaque plateforme.

III-B - Les vues

La réutilisation du code se complique lorsque l'on arrive au niveau de l'interface graphique. Dans des interfaces simples telles que celle de notre quiz, nous pouvons compter sur une compatibilité assez élevée, mais pas cependant pas totale. Pensez par exemple à la gestion des commandes en Silverlight pour laquelle nous avons dû utiliser l'astuce des propriétés attachées. L'utilisation de bibliothèques tierces de composants graphiques ne fera que baisser le taux de réutilisation. Cependant, il nous est tout à fait possible de faire du copier/coller pour chaque écran et de réajuster au cas par cas.

La première différence que nous allons rencontrer se situe au niveau du conteneur principal de l'application. En WPF nous devons utiliser le composant Window. Au sein de celle-ci nous allons placer un élément Frame qui nous permettra de naviguer entre les différents écrans :

Fenêtre principale de l'application

<Window x:Class="QuizDvp.Client.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Quiz Dvp" Height="600" Width="800">
<Grid>
<Frame x:Name="NavigationFrame"
NavigationUIVisibility="Hidden"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
</Frame>
</Grid>
</Window>

Au chargement de la fenêtre, nous construisons et enregistrons les services de l'application (de la même façon que pour la version Silverlight). La seule différence se situe dans le service de navigation où nous passons en paramètre une référence sur la Frame utilisée pour contenir les différentes vues.

void Window1_Loaded(object sender, RoutedEventArgs e)
{
//création des services
ApplicationServiceProvider.ApplicationService.AddService(typeof(IQuizService), new QuizService());

NavigationService navigationService = new NavigationService(NavigationFrame);
ApplicationServiceProvider.ApplicationService.AddService(typeof(INavigationService), navigationService);

//affichage de la page de login
LoginViewModel loginViewModel = new LoginViewModel();
LoginView loginView = new LoginView(loginViewModel);

ApplicationServiceProvider.ApplicationService.NavigationService.Navigate(loginView);

}

Concernant l'écran de login nous allons pouvoir utiliser les possibilités de WPF pour le binding de la zone de texte :

<TextBox Text="{Binding UserName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>

Ici pas de hack comme pour la version Silverlight. Grâce à l'utilisation de la propriété UpdateSourceTrigger positionnée à PropertyChanged, la propriété UserName du ViewModel est mise à jour immédiatement et l'état du bouton de connexion également.

Contrairement à la version Silverlight où nous avions dû utiliser les propriétés attachées, pour le bouton de connexion nous pouvons simplement utiliser les propriétés Command et CommandParameter disponibles en WPF pour nous « binder » à une commande. Il en sera de même pour l'ensemble de boutons « bindés » à des commandes.

<Button Content="Connexion"
Command="{Binding ConnectCommand}"
CommandParameter="{Binding UserName}"/>

Concernant l'écran de sélection d'un quiz, le code XAML reste identique, mis à part le binding du bouton lançant le quiz sélectionné :

<Button  Command="{Binding PlaySelectedQuizCommand}"
CommandParameter="{Binding SelectedQuiz}"
Content="Jouer au quiz !"/>

Il en va de même pour l'écran affichant les questions d'un quiz :

<Button Content="{Binding IsOnLastQuestion, Converter={StaticResource BoolToTextConverter}}"
Command="{Binding Path=NextQuestionCommand}" />

Enfin, l'écran d'affichage du score est lui aussi identique entre les deux versions. Seul change le binding de la commande associée au bouton permettant de rejouer :

<Button
Content="Jouer à un autre quiz"
Command="{Binding PlayAgainCommand}"/>

Conclusion

Comme vous avez pu le découvrir tout au long de cet article, Silverlight et WPF offre un modèle de développement assez proche l'un de l'autre permettant ainsi de réutiliser ses connaissances d'une plateforme à l'autre. Un certain nombre de techniques et d'outils permettent d'améliorer la réutilisation de code : emploi du même namespace, utilisation du pattern MV-VM, usage de librairies tierces (Composite Application Library, Silverlight extentions) pour compenser certains manques en Silverlight, liaison des fichiers sources entre projets pour éviter les duplications, possibilité offerte par le langage (symboles de compilation conditionnelle, classes partielles, méthodes d'extension, interfaces).

Nous avons pu voir au travers de la création d'un quiz quelques-unes de ces techniques. Certains aspects n'ont cependant pas été abordés comme par exemple la création de custom controls. Vous avez cependant de quoi faire en attendant Silverlight 3 et WPF 4 qui permettront d'améliorer encore davantage la compatibilité entre les deux plateformes.

Sources

Téléchargez les sources de la solution donnée en exemple.

Liens

Comment tirer efficacement parti des avantages de WPF tout en gardant une application maintenable et testable ?

WPF Apps With The Model-View-ViewModel Design Pattern.

Creating an Internationalized Wizard in WPF.

Différences entre WPF et Silverlight.

Unity Application Block.

Remerciements

J'adresse ici tous mes remerciements à l'équipe de rédaction de "developpez.com" pour le temps qu'ils ont bien voulu passer à la correction et à l'amélioration de cet article.

Contact

Si vous constatez une erreur dans le tutorial, dans les sources, dans la programmation ou pour toutes informations, n'hésitez pas à me contacter par le forum.