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 :
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 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 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.
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.
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 :
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é.
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 :
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 :
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.
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).
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 :
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).
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 :
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é.
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):
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.
<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 :
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 :
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 :
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 :
<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 :
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.
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).
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 :
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.
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:
<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é.
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 :
<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▲
WPF Apps With The Model-View-ViewModel Design Pattern.
Creating an Internationalized Wizard in WPF.
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.