Les markup extensions en WPF

Thomas Levesque

Ce tutoriel présente le concept des markup extensions, qui permettent d'enrichir la syntaxe du langage XAML. Plusieurs exemples commentés vous permettront de comprendre comment créer vos propres markup extensions pour réaliser toutes sortes de fonctionnalités. Pour finir, le dernier chapitre expliquera comment gérer les problèmes liés au fonctionnement des markup extensions dans le designer WPF.

Commentez cet article : Commentez ♪

- Titre : Les markup extensions en WPF

- Auteur : Thomas Levesque

- Parution : 8 janvier 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 ® 2008 Thomas Levesque. 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. Définition

- II. Créer une markup extension personnalisée

- II-A. Une extension simple

- II-B. Paramètres du constructeur

- II-C. Rendre une markup extension disponible dans le namespace global

- III. Autres exemples d'application

- III-A. Binding sur les paramètres de l'application

- III-B. Afficher les valeurs d'une énumération dans un ItemsControl

- III-C. Localisation d'une application avec des ressources incorporées

- III-C-1. Récupération des ressources

- III-C-2. Cas des images

- III-C-3. Mise à jour des propriétés cibles

- IV. Markup extensions en mode design

- IV-A. Règles générales

- IV-B. Déterminer si on est en mode design

- IV-C. Problèmes fréquents

- IV-D. Déboguer une markup extension en mode design

- Conclusion

- Sources

- Remerciements

Introduction

Le langage XAML permet de coder beaucoup de choses de manière déclarative (sans écrire de « code-behind » en C# ou VB.NET). Par exemple, on peut affecter un style à un contrôle en référençant la ressource correspondante avec StaticResource, ou encore lier une propriété d'un contrôle à des données via un binding. Cependant, il arrive encore souvent qu'il n'existe pas de syntaxe XAML adaptée à ce que l'on veut faire, ou que la syntaxe existante soit lourde et complexe à utiliser. On est alors parfois obligé de faire de façon procédurale ce qu'on aurait voulu écrire de façon déclarative.

Cet article présente une manière d'étendre la syntaxe du langage XAML pour répondre à ces besoins spécifiques, en créant des « markup extensions » personnalisées.

Prérequis : cet article s'adresse à des personnes ayant au moins une connaissance basique du langage XAML et du développement d'applications WPF.

I - Définition

Une « markup extension » (dont la traduction officielle dans la documentation MSDN est « extension de balisage »), est une entité de programmation qui permet au processeur XAML de déléguer à une classe l'évaluation d'une propriété. Une markup extension est identifiée dans le code XAML par l'utilisation d'accolades (« { . } ») dans un attribut de propriété. En WPF, on en utilise un peu partout, parfois sans le savoir : en effet, les expressions comme Binding, StaticResource, ou encore x:Type ou x:Static sont des markup extensions.

La classe de base pour toutes les markup extensions est, sans surprise, MarkupExtension. Toutes les classes comme Binding, StaticResourceExtension, TypeExtension, ou StaticExtension en héritent, directement ou indirectement, comme indiqué sur le diagramme suivant.

La classe abstraite MarkupExtension ne définit qu'une seule méthode, ProvideValue, qui doit être implémentée par les classes dérivées pour renvoyer une valeur quand la markup extension est évaluée.

Par convention, les noms de la plupart des classes héritant de MarkupExtension ont le suffixe « Extension », mais on peut omettre ce suffixe lorsqu'on utilise l'extension en XAML (on écrit par exemple StaticResource, et non StaticResourceExtension). C'est un peu le même principe que pour les attributs, dont on peut omettre le suffixe « Attribute ».

II - Créer une markup extension personnalisée

II-A - Une extension simple

Pour créer notre première markup extension, nous allons prendre un cas simple : une extension qui renvoie la valeur d'une variable d'environnement. L'utilité est certes limitée, mais c'est juste pour la démonstration. On verra plus tard des exemples plus complexes et plus utiles.

Nous allons donc créer une classe EnvironmentExtension, qui héritera de MarkupExtension. La propriété VariableName indiquera le nom de la variable d'environnement à renvoyer. Dans la méthode ProvideValue, on récupèrera la valeur de la variable d'environnement voulue, et on renverra cette valeur.

using System;
using System.Windows.Markup;

namespace EnvironmentMarkupExtension
{
[MarkupExtensionReturnType(typeof(string))]
public class EnvironmentExtension : MarkupExtension
{

public string VariableName { get; set; }

public override object ProvideValue(IServiceProvider serviceProvider)
{
// On vérifie que VariableName est bien défini
if (string.IsNullOrEmpty(VariableName))
{
throw new ArgumentException("The variable name can't be null or empty");
}
else
{
// On récupère la valeur
string value = Environment.GetEnvironmentVariable(VariableName);
return value;
}
}
}
}

Notez l'attribut MarkupExtensionReturnType appliqué à la classe : il permet d'indiquer le type de la valeur renvoyée. En l'occurrence c'est une chaîne de caractères, mais ça aurait pu être une image ou un objet quelconque. Quant au paramètre serviceProvider de la méthode ProvideValue, on n'en a pas besoin pour l'instant : sachez simplement qu'il permet d'obtenir des informations sur le contexte où la markup extension est utilisée. Je reviendrai plus tard sur ce point.

Voilà comment utiliser notre markup extension :

<Window x:Class="EnvironmentMarkupExtension.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:my="clr-namespace:EnvironmentMarkupExtension"
Title="Window1" Height="300" Width="300">
<StackPanel>
<TextBlock Text="Répertoires dans le PATH :"/>
<TextBox Text="{my:Environment VariableName=PATH}"
IsReadOnly="True" TextWrapping="Wrap" />
</StackPanel>
</Window>

Notez qu'il a fallu déclarer dans le XAML le namespace dans lequel l'extension est définie : on verra un peu plus loin comment éviter de devoir faire ça.

II-B - Paramètres du constructeur

Pour utiliser notre extension, nous avons spécifié explicitement le nom de la propriété VariableName pour définir sa valeur. Dans ce cas, le constructeur par défaut (sans paramètre) est appelé, et la propriété VariableName est initialisée à la valeur fournie. Mais il est aussi possible d'utiliser un constructeur spécifique, ce qui permet d'alléger un peu le code XAML. Pour cela, il faut indiquer immédiatement après le nom de l'extension les valeurs des paramètres du constructeur. Ajoutons à notre classe un constructeur qui prend en paramètre le nom de la variable d'environnement :

public EnvironmentExtension()
{
}

public EnvironmentExtension(string variableName)
{
this.VariableName = variableName;
}

[ConstructorArgument("variableName")]
public string VariableName { get; set; }

Notez qu'on a aussi ajouté deux choses :

- un constructeur par défaut, pour pouvoir continuer à utiliser l'extension en renseignant explicitement la propriété VariableName, comme dans la version précédente (le constructeur par défaut n'est généré que si aucun autre constructeur n'est défini, donc comme on en a défini un autre il faut aussi définir le constructeur par défaut) ;

- un attribut ConstructorArgument sur la propriété VariableName, pour indiquer que cette propriété peut être initialisée via un paramètre du constructeur et n'a pas besoin d'être sérialisée explicitement. Ce n'est pas absolument nécessaire (ça fonctionnera aussi sans cet attribut), mais c'est utile si l'extension est manipulée par un designer, par exemple.

On peut maintenant utiliser l'extension de la manière suivante :

<TextBox Text="{my:Environment PATH}"
IsReadOnly="True" TextWrapping="Wrap" />

On peut combiner les deux syntaxes (constructeur et affectation explicite des propriétés), c'est d'ailleurs souvent utilisé pour des bindings par exemple :

<TextBlock Text="{Binding Text, ElementName=textBox1}"/>

Ici, on a défini la propriété Path en paramètre du constructeur, et on a explicitement défini la propriété ElementName.

II-C - Rendre une markup extension disponible dans le namespace global

Vous aurez sans doute remarqué que pour utiliser les markup extensions « standard » de WPF, il est inutile de spécifier le namespace dans lequel elles se trouvent, alors que pour utiliser celles qu'on crée, on doit ajouter un attribut xmlns dans le code XAML. Il est possible d'éviter cela, en enregistrant dans les namespaces par défaut de WPF l'assembly dans lequel est défini la markup extension. Pour cela, on utilise l'attribut XmlnsDefinition :

[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "EnvironmentMarkupExtension")]
[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2007/xaml/presentation", "EnvironmentMarkupExtension")]
[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2008/xaml/presentation", "EnvironmentMarkupExtension")]

(Les différents namespaces XML correspondent aux différentes versions de WPF.)

Cela permettrait d'utiliser l'extension sans préciser le namespace :

<TextBox Text="{Environment PATH}"
IsReadOnly="True" TextWrapping="Wrap" />

- Notez que cela ne fonctionne que si l'extension en question est définie dans un assembly différent de celui où elle est utilisée.

- L'attribut XmlnsDefinition n'est pas spécifique aux markup extensions, il s'applique à l'assembly. On pourrait donc aussi bien utiliser les autres types définis dans l'assembly sans préciser le namespace. Il faut donc utiliser cet attribut avec parcimonie, car cela peut créer des conflits entre des types que vous avez définis et des types standards qui existent déjà dans les namespaces WPF.

III - Autres exemples d'application

Maintenant que nous avons vu comment créer une markup extension « basique », penchons nous sur quelques exemples plus concrets, et surtout plus utiles.

III-A - Binding sur les paramètres de l'application

Depuis Visual Studio 2005, une fonctionnalité très utile est à la disposition des développeurs : les paramètres d'application, qui permettent de persister les préférences de l'utilisateur dans un fichier qui sera automatiquement chargé au démarrage du programme. Visual Studio génère une classe nommée NomDeLApplication.Properties.Settings, qui permet d'accéder aux paramètres de façon typée, via des propriétés.

Une façon « naïve » d'utiliser ces paramètres est d'affecter manuellement leur valeur aux propriétés correspondantes lors de l'initialisation. Par exemple, si on a la position, la taille et l'état de la fenêtre comme paramètres d'application :

public Window1()
{
InitializeComponent();
this.Left = Properties.Settings.Default.Left;
this.Top = Properties.Settings.Default.Top;
this.Width = Properties.Settings.Default.Width;
this.Height = Properties.Settings.Default.Height;
this.WindowState = Properties.Settings.Default.WindowState;
}

Cette façon de faire présente plusieurs inconvénients : d'une part, ça fait pas mal de code à écrire, surtout si on a beaucoup de paramètres ; d'autre part, il faut en écrire autant pour sauvegarder les valeurs lorsqu'on quitte l'application.

Pour « automatiser » ces tâches d'initialisation/sauvegarde, il suffit d'utiliser un Binding. Pour cela, on définit comme Path le nom du paramètre, et comme Source l'objet Properties.Settings.Default. Il faut aussi indiquer que le binding est en mode TwoWay, pour que les changements effectués par l'utilisateur soient répercutés dans les paramètres. Finalement, ça donne quelque chose comme ça :

<Window x:Class="SettingMarkupExtension.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:p="clr-namespace:SettingMarkupExtension.Properties"
Title="Window1"
Height="{Binding Source={x:Static p:Settings.Default}, Path=Height, Mode=TwoWay}"
Width="{Binding Source={x:Static p:Settings.Default}, Path=Width, Mode=TwoWay}"
Left="{Binding Source={x:Static p:Settings.Default}, Path=Left, Mode=TwoWay}"
Top="{Binding Source={x:Static p:Settings.Default}, Path=Top, Mode=TwoWay}"
WindowState="{Binding Source={x:Static p:Settings.Default}, Path=WindowState, Mode=TwoWay}">

Seulement voilà : vu la lourdeur de la syntaxe, on n'a pas vraiment envie d'écrire ça pour chaque propriété qui est liée à un paramètre. C'est long à écrire, peu intuitif, et ça rend le code peu lisible. C'est typiquement dans ce genre de cas qu'on a intérêt à créer une markup extension pour nous simplifier la tâche.

Nous allons donc créer une extension nommée SettingBindingExtension, qui sera chargée de lier une propriété à un paramètre. Première différence par rapport à l'exemple du chapitre précédent : au lieu d'hériter directement de MarkupExtension, nous allons profiter des fonctionnalités déjà implémentées par Binding en héritant de cette classe. En effet, ce qu'on souhaite faire est finalement un binding un peu plus spécialisé. Tout ce qui nous reste à faire est d'initialiser les propriétés Source et Mode :

using System.Windows.Data;

namespace SettingMarkupExtension
{
public class SettingBindingExtension : Binding
{
public SettingBindingExtension()
{
Initialize();
}

public SettingBindingExtension(string path)
: base(path)
{
Initialize();
}

private void Initialize()
{
this.Source = Properties.Settings.Default;
this.Mode = BindingMode.TwoWay;
}
}
}

Notez qu'on a implémenté deux constructeurs, qui correspondent à ceux de la classe Binding. De cette façon on peut choisir d'initialiser Path par le constructeur, ou explicitement. Voyons maintenant ce que donne notre extension à l'usage, pour la même fonctionnalité que le code précédent :

<Window x:Class="SettingMarkupExtension.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:my="clr-namespace:SettingMarkupExtension"
Title="Window1"
Height="{my:SettingBinding Height}"
Width="{my:SettingBinding Width}"
Left="{my:SettingBinding Left}"
Top="{my:SettingBinding Top}"
WindowState="{my:SettingBinding WindowState}">

C'est tout de même nettement plus pratique. Si on regarde le temps qu'il a fallu pour écrire l'extension par rapport au temps qu'elle va nous faire gagner, c'est largement rentable… Et, cerise sur le gâteau : ça fonctionne sans problème dans le designer WPF, qui utilise les valeurs par défaut des paramètres.

Notez aussi que, comme on hérite de la classe Binding, on peut toujours utiliser les propriétés héritées de Binding : on peut par exemple définir une valeur par défaut avec FallbackValue, au cas où, pour une raison ou une autre, on ne peut pas récupérer la valeur du paramètre :

Top="{my:SettingBinding Top, FallbackValue=50}"

Et enfin, petit détail à ne pas oublier pour que ça fonctionne : enregistrer les paramètres avant de quitter. on peut faire ça dans l'évènement Exit de l'application :

private void Application_Exit(object sender, ExitEventArgs e)
{
SettingMarkupExtension.Properties.Settings.Default.Save();
}

Il serait intéressant d'externaliser cette markup extension dans une librairie réutilisable, ce qui permettrait notamment de l'enregistrer dans le namespace par défaut de WPF, et donc de se passer du préfixe de namespace. En l'état actuel, ce n'est pas possible, à cause de la référence explicite à la classe Settings qui est spécifique à notre application. On peut bien sûr contourner cet inconvénient en utilisant la réflexion, mais c'est un peu plus complexe, surtout si on veut que ça fonctionne aussi dans le designer. Mais là on s'éloigne du cadre de cet article…

III-B - Afficher les valeurs d'une énumération dans un ItemsControl

Il est courant, dans un formulaire, de proposer à l'utilisateur de choisir entre plusieurs valeurs qui correspondent aux valeurs d'une énumération. Dans le code-behind, il est assez facile d'afficher la liste de ces valeurs dans une ComboBox (ou autre contrôle hérité de ItemsControl) :

comboDayOfWeek.ItemsSource = Enum.GetValues(typeof(DayOfWeek));

Même si cette instruction est très simple, il serait plus naturel de définir la source de données de la ComboBox de façon déclarative, en XAML, puisque ce sont des données purement statiques. Comme c'est une question assez récurrente, on trouve souvent la solution suivante sur le net, qui utilise un ObjectDataProvider :

<Window x:Class="EnumValuesMarkupExtension.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
Title="Window1" Height="300" Width="300">
<Window.Resources>
<ObjectDataProvider x:Key="odp" ObjectType="{x:Type sys:Enum}" MethodName="GetValues">
<ObjectDataProvider.MethodParameters>
<x:Type TypeName="sys:DayOfWeek"/>
</ObjectDataProvider.MethodParameters>
</ObjectDataProvider>
</Window.Resources>
<Grid>
<ComboBox ItemsSource="{Binding Source={StaticResource odp}}" SelectedIndex="0"/>
</Grid>
</Window>

Le problème, avec cette méthode, est qu'il faut déclarer un ObjectDataProvider à chaque fois qu'on veut se binder sur les valeurs d'une énumération, ce qui est un peu lourd pour une tâche aussi simple.

Vous commencez à me voir venir : pour simplifier tout ça, on va créer une markup extension. À ce stade, vous avez probablement compris le principe, mais voici quand même le code de cette extension :

using System;
using System.Windows.Markup;

namespace EnumValuesMarkupExtensions
{
[MarkupExtensionReturnType(typeof(Array))]
public class EnumValuesExtension : MarkupExtension
{
public EnumValuesExtension()
{
}

public EnumValuesExtension(Type enumType)
{
this.EnumType = enumType;
}

[ConstructorArgument("enumType")]
public Type EnumType { get; set; }

public override object ProvideValue(IServiceProvider serviceProvider)
{
return Enum.GetValues(EnumType);
}
}
}

On a donc défini une propriété EnumType, qui correspond au type de l'énumération dont on veut obtenir les valeurs. Puisque Enum.GetValues renvoie un Array, on l'indique dans l'attribut MarkupExtensionReturnType. Et comme d'habitude, on définit un constructeur qui prend en paramètre la valeur de la propriété EnumType.

Pour obtenir le même résultat que précédemment en utilisant cette extension, il suffit d'écrire :

<Window x:Class="EnumValuesMarkupExtension.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:my="clr-namespace:EnumValuesMarkupExtension"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
Title="Window1" Height="300" Width="300">
<Grid>
<ComboBox ItemsSource="{my:EnumValues sys:DayOfWeek}" SelectedIndex="0" />
</Grid>
</Window>

Ce qui est tout de même nettement plus rapide et intuitif que de déclarer un ObjectDataProvider…

III-C - Localisation d'une application avec des ressources incorporées

Lorsqu'une application a vocation à être largement diffusée dans différents pays, il est souvent nécessaire de la « localiser », c'est-à-dire la traduire dans différentes langues (en .NET, on parle de « cultures »). Malheureusement, l'infrastructure de localisation de WPF est d'une extrême lourdeur (du moins à l'heure où j'écris cet article ; espérons qu'une prochaine version améliorera tout ça) : la localisation d'une application requiert de nombreuses étapes manuelles, dans lesquelles le développeur est peu ou pas assisté (édition à la main du fichier de projet, utilisation de divers outils en ligne de commande, et j'en passe…). Si vous voulez vous y aventurer, je vous conseille de prévoir un tube d'aspirine ! Pourtant, le système de localisation dans Windows Forms était assez simple d'utilisation, malgré quelques imperfections. J'ai du mal à m'expliquer cette « régression ».

Ce dernier exemple, largement inspiré de l'article de Jecho Jekov, présente une alternative simple à la méthode de localisation prônée par Microsoft. La solution est basée sur des markup extensions, et des ressources incorporées (les « classiques » fichiers .resx). On verra d'abord comment réaliser des extensions simples pour récupérer en XAML des ressources de type texte, puis des images. Pour finir, on s'attaquera à une fonctionnalité plus complexe : la mise à jour « à chaud » des valeurs des propriétés lorsque la culture courante change.

III-C-1 - Récupération des ressources

Le principe qu'on va appliquer pour réaliser la localisation consiste à créer des markup extensions qui pourront être utilisées de la façon suivante :

<TextBlock Text="{my:LocString HelloWorld}"/>
<Image Source="{my:LocImage Flag}"/>

Dans cet exemple, HelloWorld et Flag sont les clés de ressources de type chaîne et image, créées avec le designer de ressources :

Ces extensions renverront donc la ressource incorporée associée à la clé, et il suffira de créer des assemblies de ressources localisées (ou assemblies satellites) pour traduire l'application. Pour créer les ressources localisées, il suffit de faire une copie du fichier Resources.resx, en ajoutant la culture dans le nom de fichier. Par exemple, pour la culture « Français (France) » : Resources.fr-FR.resx. Dans ce fichier, on met la version française des ressources :

Puisque les markup extensions LocStringExtension et LocImageExtension ont des fonctionnalités très similaires, on va créer une classe abstraite dont elles hériteront toutes les deux et qui gèrera toutes les fonctionnalités communes :

using System;
using System.Reflection;
using System.Windows;
using System.Windows.Markup;

namespace LocalizationMarkupExtension
{
[MarkupExtensionReturnType(typeof(object))]
Public abstract class LocResourceExtension : MarkupExtension
{
protected LocResourceExtension() { }

protected LocResourceExtension(string resourceKey)
{
this.ResourceKey = resourceKey;
}

[ConstructorArgument("resourceKey")]
public string ResourceKey { get; set; }

#region Implémentation de MarkupExtension

public sealed override object ProvideValue(IServiceProvider serviceProvider)
{
if (string.IsNullOrEmpty(ResourceKey))
throw new InvalidOperationException("The ResourceKey property cannot be null or empty");

return ProvideValueInternal(serviceProvider);
}

#endregion

protected abstract object ProvideValueInternal(IServiceProvider serviceProvider);
}
}

Comme d'habitude, on a hérité de MarkupExtension et défini un attribut MarkupExtensionReturnType. On a ajouté une propriété ResourceKey, correspondant à la clé de la ressource à renvoyer, et on a créé un constructeur qui permet de l'initialiser. Vous remarquerez que la méthode ProvideValue est marquée sealed, et qu'on délègue sa fonctionnalité à une méthode abstraite ProvideValueInternal : de cette façon, les classes dérivées ne pourront pas redéfinir l'implémentation de ProvideValue, et devront implémenter ProvideValueInternal à la place. La raison pour ceci est qu'on va implémenter dans ProvideValue des fonctionnalités communes, qui seront détaillées plus loin.

On va maintenant créer une classe LocStringExtension, héritée de LocResourceExtension, qui sera chargée de récupérer une ressource de type chaîne :

using System;
using System.Resources;
using System.Windows.Markup;

namespace LocalizationMarkupExtension
{
[MarkupExtensionReturnType(typeof(string))]
public class LocStringExtension : LocResourceExtension
{
public LocStringExtension() { }

public LocStringExtension(string resourceKey) : base(resourceKey) { }

protected override object ProvideValueInternal(IServiceProvider serviceProvider)
{
if (LocManager.ResourceManager != null)
return LocManager.ResourceManager.GetString(ResourceKey);
else
return "(LocString)";
}
}
}

On a remplacé l'attribut MarkupExtensionReturnType pour indiquer que l'extension renvoie une valeur de type string, et défini des constructeurs qui appellent ceux de la classe de base. L'implémentation de ProvideValueInternal récupère la ressource demandée en tant que chaîne, à l'aide d'un ResourceManager. Si le ResourceManager n'est pas défini, on renvoie une valeur par défaut. La classe LocManager qui fournit le ResourceManager est une classe « utilitaire » dont voici le code :

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Resources;
using System.Threading;

namespace LocalizationMarkupExtension
{
public class LocManager
{
private static ResourceManager _resourceManager = null;
public static ResourceManager ResourceManager
{
get
{
if (_resourceManager == null)
{
_resourceManager = GetResourceManager();
}
return _resourceManager;
}
set { _resourceManager = value; }
}

private static ResourceManager GetResourceManager()
{
Assembly asm = GetAssembly();

if (asm != null)
{
// On utilise le jeu de ressources nommé <nom de l'assembly>.Properties.Resources
string baseName = asm.GetName().Name + ".Properties.Resources";
ResourceManager rm = new ResourceManager(baseName, asm);
return rm;
}
else
{
return null;
}
}

private static Assembly GetAssembly()
{
Assembly asm = Assembly.GetEntryAssembly();

if (asm == null)
{
// En mode design, Assembly.GetEntryAssembly ne fonctionne pas
// On cherche donc un assembly avec un point d'entrée qui contient
// une classe héritée de System.Windows.Application
asm = (
from a in AppDomain.CurrentDomain.GetAssemblies()
where a.EntryPoint != null
&& a.GetTypes().Any(t => t.IsSubclassOf(typeof(System.Windows.Application)))
select a
).FirstOrDefault();
}

return asm;
}
}
}

Cette classe mérite quelques explications. Elle récupère un ResourceManager qui va permettre d'accéder aux ressources définies dans le fichier Resources.resx de l'application. Comme ce fichier est placé dans le sous-répertoire Properties, le nom de base pour y accéder est « NomDeLAssembly.Properties.Resources ». Le constructeur de ResourceManager prend en paramètre ce nom, ainsi que l'assembly dans lequel se trouvent les ressources : on a donc besoin d'identifier cet assembly. En général, il s'agit de l'assembly de l'application, qui est le point d'entrée du programme. On peut habituellement l'obtenir avec la méthode Assembly.GetEntryAssembly, mais vous remarquerez dans le code ci-dessus qu'on gère le cas où GetEntryAssembly renvoie null : ce cas correspond en fait à l'exécution dans le designer WPF. Dans ce cas, on va chercher à identifier l'assembly autrement parmi les assemblies chargés dans le domaine de l'application (AppDomain), de façon à ce que la markup extension puisse fonctionner en mode design. Le chapitre IV parlera plus en détail des problématiques liées au mode design.

On peut maintenant utiliser cette extension pour afficher un texte localisé :

<TextBlock Text="{my:LocString HelloWorld}"/>

Selon la culture courante, le TextBlock affichera « Hello World ! » ou « Bonjour le monde ! »

III-C-2 - Cas des images

Pour utiliser des ressources de type image, on peut utiliser le même principe que pour les chaînes, à une différence près : on ne peut pas utiliser directement l'image renvoyée par le ResourceManager, il faut effectuer une transformation dessus au préalable. En effet, l'image récupérée par la méthode GetObject du ResourceManager est de type System.Drawing.Bitmap ; or, les images utilisées en WPF sont de type System.Windows.Media.ImageSource. Il faut donc ajouter une étape de conversion avant de renvoyer la valeur. Voici donc l'implémentation de cette classe :

using System;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace LocalizationMarkupExtension
{
[MarkupExtensionReturnType(typeof(ImageSource))]
public class LocImageExtension : LocResourceExtension
{
public LocImageExtension() { }

public LocImageExtension(string resourceKey) : base(resourceKey) { }

protected override object ProvideValueInternal(IServiceProvider serviceProvider)
{
ImageSource img = null;
if (LocManager.ResourceManager != null)
{
System.Drawing.Bitmap bmp =
LocManager.ResourceManager.GetObject(ResourceKey) as System.Drawing.Bitmap;
if (bmp != null)
img = GetImageSource(bmp);
}
return img;
}

private ImageSource GetImageSource(System.Drawing.Bitmap bmp)
{
BitmapSource src =
Imaging.CreateBitmapSourceFromHBitmap(
bmp.GetHbitmap(),
IntPtr.Zero,
Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
return src;
}
}
}

III-C-3 - Mise à jour des propriétés cibles

Nous allons maintenant ajouter à nos extensions de localisation la possibilité de changer de culture en cours d'exécution. Il faut pour cela que toutes les propriétés dont la valeur provient d'une de nos markup extensions soient « rafraîchies », afin de prendre en compte la nouvelle culture. Or, dans les exemples qu'on a vus jusqu'ici, on ne savait pas, dans la méthode ProvideValue, à quelle propriété de quel objet était affectée la valeur renvoyée. En fait, on peut obtenir cette information : c'est une des fonctionnalités du paramètre serviceProvider qu'on traîne depuis le début sans savoir quoi en faire. Ce paramètre, de type IServiceProvider, peut fournir différents « services », qu'on manipulera via leur interface :

- IProvideValueTarget : fournit des informations sur la cible de la markup extension : objet cible et propriété cible. C'est ce service qui nous intéresse dans le cas présent ;

- IUriContext : permet de savoir dans quelle ressource XAML on se situe, via une URI de type « pack:// ». Par exemple, pour une fenêtre, ce sera l'URI du fichier XAML qui définit la fenêtre ;

- IXamlTypeResolver : permet d'obtenir un type à partir de sa représentation XAML.

On va donc utiliser IProvideValueTarget pour récupérer la cible de notre markup extension, et la conserver en vue de la mettre à jour quand la culture changera. Reprenons la classe abstraite LocResourceExtension pour lui ajouter deux variables membres, et modifier la méthode ProvideValue :

protected WeakReference targetObjectRef;
protected object targetProperty;

public sealed override object ProvideValue(IServiceProvider serviceProvider)
{
if (string.IsNullOrEmpty(ResourceKey))
throw new InvalidOperationException("The ResourceKey property cannot be null or empty");

IProvideValueTarget target = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
if (target != null)
{
if (target.TargetObject != null && target.TargetProperty != null)
{
this.targetObjectRef = new WeakReference(target.TargetObject);
this.targetProperty = target.TargetProperty;
}
}

return ProvideValueInternal(serviceProvider);
}

Quelques remarques sur ce code

- On ne conserve pas de « vraie » référence sur l'objet cible, mais seulement une référence faible (WeakReference) : de cette façon, on n'empêche pas le garbage collector de récupérer les ressources de l'objet quand il n'est plus utilisé.

- La propriété cible est déclarée comme étant de type object ; en pratique, il s'agira soit d'une DependencyProperty, soit d'un objet PropertyInfo.

Pour pouvoir mettre à jour la cible, on a besoin de savoir que la culture courante a changé. On va donc ajouter à la classe LocManager une propriété statique UICulture, qui changera la culture du thread courant et déclenchera un évènement UICultureChanged pour notifier les extensions de localisation :

public static CultureInfo UICulture
{
get
{
return Thread.CurrentThread.CurrentUICulture;
}
set
{
Thread.CurrentThread.CurrentUICulture = value;
OnUICultureChanged();
}
}

private static HashSet<EventHandler> uiCultureChangedHandlers = new HashSet<EventHandler>();

public static event EventHandler UICultureChanged
{
add
{
uiCultureChangedHandlers.Add(value);
}
remove
{
uiCultureChangedHandlers.Remove(value);
}
}

private static void OnUICultureChanged()
{
foreach (EventHandler handler in uiCultureChangedHandlers)
{
handler(typeof(LocManager), EventArgs.Empty);
}
}

Vous noterez qu'on gère manuellement la liste des handlers de l'évènement UICultureChanged, en définissant explicitement les accesseurs add et remove. On stocke les handlers dans un HashSet, afin d'éviter les doublons.

Le changement de la culture d'interface du thread courant est capital : c'est grâce à cela que les ressources localisées correspondant à la nouvelle culture seront utilisées par le ResourceManager.

Dans la classe LocResourceExtension, on s'abonne à l'évènement UICultureChanged, de façon à mettre à jour la cible lorsque la culture change :

public sealed override object ProvideValue(IServiceProvider serviceProvider)
{
if (string.IsNullOrEmpty(ResourceKey))
throw new InvalidOperationException("The ResourceKey property cannot be null or empty");

IProvideValueTarget target = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
if (target != null)
{
if (target.TargetObject != null && target.TargetProperty != null)
{
this.targetObjectRef = new WeakReference(target.TargetObject);
this.targetProperty = target.TargetProperty;
LocManager.UICultureChanged += LocManager_UICultureChanged;
}
}

return ProvideValueInternal(serviceProvider);
}

private void LocManager_UICultureChanged(object sender, EventArgs e)
{
Refresh();
}

private void Refresh()
{
if (!targetObjectRef.IsAlive)
{
LocManager.UICultureChanged -= LocManager_UICultureChanged;
return;
}

object value = ProvideValueInternal(null);

if (targetProperty is DependencyProperty)
{
DependencyObject obj = targetObjectRef.Target as DependencyObject;
DependencyProperty prop = targetProperty as DependencyProperty;
obj.SetValue(prop, value);
}
else
{
object obj = targetObjectRef.Target;
PropertyInfo prop = targetProperty as PropertyInfo;
prop.SetValue(obj, value, null);
}
}

La méthode Refresh réalise la mise à jour de la cible. On vérifie d'abord, via la propriété WeakReference.IsAlive, si l'objet cible a été ramassé par le garbage collector : si c'est le cas, on se désabonne de l'évènement et on abandonne la mise à jour. On obtient la nouvelle valeur de la ressource grâce à ProvideValueInternal, et on modifie la valeur de la propriété, en gérant les deux types de propriété possibles : DependencyProperty et PropertyInfo.

Et voilà, notre système de localisation est entièrement fonctionnel ! Voilà un exemple simple d'utilisation :

<Window x:Class="LocalizationMarkupExtension.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:my="clr-namespace:LocalizationMarkupExtension"
Title="{my:LocString HelloWorld}" Height="300" Width="300">
<Window.Resources>
<my:LocManager x:Key="locManager"/>
</Window.Resources>
<Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Margin="3"
VerticalAlignment="Center"
Text="{my:LocString Language}"/>
<ComboBox Margin="3"
VerticalAlignment="Center"
ItemsSource="{Binding AvailableCultures, Source={StaticResource locManager}}"
SelectedItem="{Binding UICulture, Source={StaticResource locManager}}"
DisplayMemberPath="NativeName" />
<Image Margin="3"
VerticalAlignment="Center"
Source="{my:LocImage Flag}" Stretch="None" />
</StackPanel>
</Grid>
</Window>

Ce code définit une fenêtre avec une ComboBox pour choisir la langue. Le titre de la fenêtre, le label de la ComboBox, et le drapeau affiché à côté sont localisés, et changent donc en fonction de la culture sélectionnée. La source des éléments de la ComboBox est la propriété LocManager.AvailableCultures, qui fournit la liste des cultures pour lesquelles une localisation est disponible (pour ne pas alourdir le code, je ne présente pas cette propriété en détail, mais vous pourrez la trouver dans les sources de l'article).

Petite astuce : comme on ne peut pas faire de binding bidirectionnel (TwoWay) sans préciser le Path, on a besoin d'une instance de LocManager pour faire le binding sur ses propriétés, bien qu'elles soient statiques. Une instance de LocManager est donc déclarée dans les ressources de la fenêtre (c'est d'ailleurs la seule raison pour laquelle la classe LocManager n'est pas déclarée comme statique.).

Le résultat est le suivant :

Vous remarquerez que tout ceci fonctionne sans qu'on ait rien ajouté au code-behind de la fenêtre : tout est défini de façon déclarative dans le XAML. Si vous voulez changer par le code la culture courante, il suffit d'affecter la culture voulue à la propriété LocManager.UICulture.

IV - Markup extensions en mode design

Comme on l'a vu plus haut, il est parfois difficile de faire fonctionner correctement une markup extension dans le designer WPF, car l'environnement du designer ne se comporte pas de la même façon que l'environnement d'exécution. Il est pourtant important de gérer correctement le comportement dans le designer, faute de quoi, au mieux la markup extension ne renverra rien (peu pratique pour le développeur qui l'utilise), et au pire cela fera planter le designer, ce qui est bien sûr encore plus gênant.

Ce chapitre présente quelques éléments pour vous aider à anticiper et/ou résoudre les problèmes qui peuvent se présenter lorsqu'une markup extension s'exécute en mode design.

IV-A - Règles générales

Voici deux règles à respecter pour qu'une markup extension se comporte correctement dans le designer :

- toute exception non interceptée dans la méthode ProvideValue empêchera le designer WPF d'afficher la fenêtre ou le contrôle en cours de conception. Il faut donc s'assurer que l'appel à cette méthode ne provoquera pas d'exception, du moins si la markup extension est correctement utilisée. On peut généralement appliquer la règle suivante : si l'extension fonctionne en exécution normale, elle ne doit pas « casser » le designer ;

- il y a des cas où il est impossible de renvoyer la valeur voulue en mode design : dans ce cas, la markup extension devrait au moins renvoyer une valeur par défaut. Cela permet au développeur d'avoir un aperçu du résultat, tout en sachant que ça ne correspond pas à ce qu'il verra à l'exécution.

Je n'ai d'ailleurs pas systématiquement appliqué ces règles dans les chapitres précédents, afin de ne pas alourdir le code.

IV-B - Déterminer si on est en mode design

Pour pouvoir gérer les cas spécifiques au mode design, encore faut-il savoir si on est en mode design. Dans un composant WPF, la méthode standard est de vérifier la valeur de la propriété attachée DesignerProperties.IsInDesignMode. Seulement, cette propriété n'a de sens que pour un DependencyObject, et une markup extension n'est pas un DependencyObject. On ne peut donc pas utiliser IsInDesignMode.

Un moyen relativement fiable de savoir si on est en mode design est de tester la valeur renvoyée par Assembly.GetEntryAssembly() : comme expliqué dans la section suivante, cette méthode renvoie null en mode design.

Attention cependant : si ce critère est valable sous Visual Studio 2008, ce ne sera pas nécessairement le cas avec tous les IDE.

IV-C - Problèmes fréquents

Voilà quelques causes possibles des problèmes qu'une markup extension peut rencontrer en mode design :

- on ne peut pas récupérer l'assembly de départ avec la méthode Assembly.GetEntryAssembly : en effet, le programme n'est pas vraiment en cours d'exécution, puisque c'est Visual Studio qui exécute votre code. Comme Visual Studio n'est pas un assembly .NET, GetEntryAssembly renvoie null. Il faut donc recourir à d'autres moyens pour récupérer l'assembly de départ, par exemple en cherchant parmi les assemblies chargés dans l'AppDomain courant. Les critères utilisés pour rechercher l'assembly dépendent bien sûr de l'extension qu'on développe ;

- le IUriContext renvoyé par serviceProvider est null : on n'a donc pas d'info sur l'URI courante ;

- dans certains cas, IProvideValueTarget n'indique pas la cible attendue : par exemple, si vous travaillez sur une fenêtre MyWindow, TargetObject ne renverra pas une instance de MyWindow, mais une instance de System.Windows.Window. C'est rarement gênant, mais il vaut mieux en tenir compte, surtout si l'extension tente d'accéder à un membre de MyWindow qui n'existe pas dans Window ;

- Application.Current renvoie une instance de System.Windows.Application, et non une instance de l'application développée (généralement nommée App).

IV-D - Déboguer une markup extension en mode design

Afin de comprendre ce qui fait qu'une markup extension ne fonctionne pas en mode design, il est parfois utile de la déboguer. Malheureusement le designer ne s'exécute pas en mode debug, et un point d'arrêt dans une markup extension ne sera donc pas pris en compte en mode design. Il est cependant possible de déboguer du code exécuté dans le designer. La méthode est la suivante :

- supposons que vous travaillez dans une instance A de Visual Studio ;

- lancez une seconde instance B de Visual Studio, et dans le menu Outils, sélectionnez Attacher au processus ;

- dans la fenêtre qui apparaît, sélectionnez devenv.exe (qui correspond à l'instance A de Visual Studio) dans la liste des processus, puis validez en cliquant sur Attacher :

- Toujours dans l'instance B, ouvrez le fichier source de la markup extension, et placez un point d'arrêt au début de la méthode ProvideValue.

- Dans l'instance A, provoquez le rechargement du designer qui utilise l'extension (par exemple en recompilant le projet), de façon à ce que la méthode ProvideValue soit exécutée.

- Le débogueur de l'instance B suspend l'exécution de A sur le point d'arrêt que vous avez défini. Vous pouvez maintenant examiner les valeurs des variables, et continuer l'exécution pas à pas :

Notez que la fonctionnalité « Modifier et continuer » ne fonctionnera pas dans ce contexte.

Conclusion

Tout au long de cet article, vous avez pu découvrir différents aspects du fonctionnement des markup extensions, ainsi que quelques exemples d'application. Vous avez pu voir la puissance de ce mécanisme qui permet d'étendre la syntaxe du langage XAML, et à quel point il peut faire gagner du temps lors du développement d'applications WPF. N'hésitez donc pas à créer vos propres markup extensions, et à les diffuser si elles en valent la peine !

Sources

Le code source des exemples est disponible ici (miroir).

Remerciements

Je tiens ici à remercier toute l'équipe .NET de Developpez.com pour son aide précieuse lors de la mise au point et de la relecture de cet article.