Le langage XAML
Dans une application WPF, le langage XAML permet d'implémenter l’interface utilisateur et le langage C# permet d'implémenter les traitements. Pour utiliser ce langage, il est nécessaire de connaître les spécifications du langage XML, la correspondance entre les balises et les objets .NET, ainsi que les mécanismes de propriétés de dépendance et d’événements de WPF.
Les exemples de ce chapitre utilisent l'espace de nom C# BookSamples et les fragments de code XAML sont à implémenter dans un élément XAML déclarant les espaces de noms nécessaires.
Présentation
Signification et rôle
XAML signifie Extensible Application Markup Language, autrement dit « langage de balisage extensible pour les applications ». Il s’agit d’un langage déclaratif basé sur le langage XML. Dans WPF, il permet de décrire un graphe d’objets représentant des interfaces graphiques fenêtres, contrôles, panneaux, ressources ou autres objets utilisés par l’interface.
Une balise représente un objet. Ses attributs correspondent aux caractéristiques ou peuvent être valorisés avec des gestionnaires d’événements, et son contenu décrit d’autres objets ou une valeur. Lors de la compilation, le processeur XAML interprète cette description pour construire et configurer les instances correspondantes.
Un premier exemple de code
<Window x:Class="BookSamples.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Découvrir XAML"
Width="420"
Height="240">
<StackPanel Margin="20">
<TextBlock Text="Bienvenue dans WPF" FontSize="22" />
<Button x:Name="SaveButton"
Content="Enregistrer"
Margin="0,16,0,0"
Click="SaveButton_Click" />
</StackPanel>
</Window>
Tout composant XAML ne possède qu'un seul élément racine. Il s'agit d'une Window (fenêtre) qui contient un StackPanel, un panneau qui organise ses éléments enfants les uns à la suite des autres (comme dans une pile) de manière verticale ou horizontale. Sa propriété Orientation n'étant pas définie, les éléments sont affichés de manière horizontale. Ce panneau contient un texte et un bouton. L'attribut x:Class associe le fichier XAML à une classe partielle C#. L'attribut x:Name attribue un nom au bouton. Dans le modèle de compilation WPF habituel, ce nom permet aussi d’accéder au bouton depuis le code-behind (aussi appelée classe d'arrière-plan).
Le fichier MainWindow.xaml.cs est la classe code-behind de ce composant et contient les comportements qui lui sont propres. Voici un exemple d'implémentation :
using System.Windows;
namespace BookSamples;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("Enregistrement demandé.");
}
}
La méthode InitializeComponent() initialise l’interface graphique décrite dans le XAML compilé et lie les gestionnaires d'évènement déclarés. Un modèle de développement tel que MVVM permet de gérer les données alimentant le composant et ses actions vers un ViewModel (modèle de vue), notamment via des liaisons de données et des commandes.
Attributs, éléments de propriété et contenu
Une propriété simple peut recevoir une valeur sous forme d’attribut :
Les valeurs textuelles sont converties de manière implicite vers les types de données attendus. Par exemple, la propriétéWidth est alimentée avec la valeur 12 et est convertie en Thickness, type de cette propriété.
Pour une valeur composée d’objets, la syntaxe d’élément de propriété est souvent plus adaptée :
<Button Width="160">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="✓" Margin="0,0,8,0" />
<TextBlock Text="Enregistrer" />
</StackPanel>
</Button.Content>
</Button>
La propriété Button.Content désigne la propriété Content du bouton. Cette écriture ne représente pas une propriété attachée.
Lorsqu’un type définit une propriété de contenu, XAML autorise à omettre cet élément intermédiaire. Le StackPanel peut donc être placé directement dans le bouton. Cette simplification dépend du type : un Button accepte un seul objet de contenu, tandis qu’un StackPanel possède une collection d’enfants.
Flux XML bien formé / Flux valide
Un flux XML bien formé
Un flux XML est une suite de caractères contenant une description XML, qu’elle provienne d’un fichier, d’une chaîne ou d’un autre support. Un document XML est bien formé lorsqu’il respecte les règles syntaxiques de XML.
Il possède notamment un seul élément racine ; ses balises sont correctement fermées et imbriquées ; les valeurs des attributs sont entre guillemets ; un attribut ne figure pas deux fois sur le même élément. XML distingue les majuscules des minuscules.
Cet exemple est bien formé :
Celui-ci ne l’est pas, car les balises se croisent :
Les caractères réservés doivent être échappés lorsque leur usage serait ambigu :
Ce contrôle syntaxique ne prouve pas que les noms correspondent à des types WPF.
Un flux XML valide
Au sens strict de la spécification XML, un document valide est bien formé et respecte une DTD (Document Type Definition). On parle également de validation par rapport à un schéma XSD (XML Schema Definition), qui décrit les structures et les types de données autorisés.
La validation examine alors des contraintes supplémentaires : noms d’éléments, attributs permis, ordre des enfants ou valeurs acceptables. Il faut donc toujours préciser le référentiel de validation utilisé.
Le cas particulier de XAML
Les fichiers XAML WPF ne reposent généralement pas sur une DTD ou un XSD pour garantir leur fonctionnement. Le processeur XAML résout les types et les membres à partir des espaces de noms, des assemblies et des règles XAML.
Un document peut donc être bien formé en XML et provoquer une erreur de compilation ou de chargement XAML :
<Button xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
UnknownProperty="Valeur" />
La syntaxe XML est correcte, mais Button ne possède pas de propriété UnknownProperty.
Ce second document est également bien formé, mais la valeur ne peut pas être convertie vers le type attendu.
Trois questions doivent ainsi être distinguées : le document respecte-t-il XML ? Peut-il être interprété comme du XAML WPF ? L’interface obtenue se comporte-t-elle comme prévu ? Une liaison de données incorrecte peut, par exemple, n’apparaître qu’à l’exécution.
Les espaces de noms XML (nommé et par défaut)
Identifier les noms sans ambiguïté
Un espace de noms XML associe des noms à un identifiant, généralement une URI. Il évite les collisions entre des vocabulaires utilisant les mêmes noms. En XAML, cette association participe également à la résolution des types .NET.
Une URI d’espace de noms est un identifiant. Son apparence d’adresse Web ne signifie pas que WPF doit télécharger une définition à cette adresse.
L’espace de noms par défaut
Cette déclaration permet d’utiliser les types WPF sans préfixe :
L’espace de noms par défaut s’applique aux noms d’éléments sans préfixe. En XML, il ne s’applique pas automatiquement aux attributs sans préfixe. XAML interprète cependant Content comme un membre du type de l’objet Button.
Les espaces de noms avec préfixe
Un espace de noms dit « nommé » est déclaré avec un préfixe :
Le préfixe x, utilisé par convention, donne accès aux fonctionnalités du langage XAML :
| Écriture | Rôle |
|---|---|
x:Class |
Associer le XAML à une classe partielle. |
x:Name |
Nommer un objet dans la portée de noms XAML. |
x:Key |
Définir la clé d’une ressource. |
x:Type |
Fournir un objet System.Type. |
x:Static |
Référencer un membre statique. |
x:Null |
Fournir une valeur null. |
Le préfixe peut changer sans modifier la signification des noms, à condition de conserver la même association. La portée d’une déclaration couvre l’élément qui la porte et ses descendants, sauf redéclaration.
Référencer ses propres classes
Pour un contrôle déclaré dans le namespace C# BookSamples.Controls :
Il devient possible d’écrire :
Si le type appartient à une autre assembly référencée par le projet :
Le nom de l’assembly n’inclut pas l’extension .dll. Un namespace XML et un namespace C# restent deux notions différentes : le premier peut être associé à plusieurs namespaces .NET.
Les extensions de balisage (markup extensions)
Fournir une valeur autrement
Une extension de balisage fournit une valeur ou une expression pour une propriété. Sa syntaxe compacte commence généralement par une accolade ouvrante :
Binding établit une liaison de données. CustomerName est ici le chemin recherché, généralement sur l’objet défini par DataContext. Pour répercuter ses changements, la source doit fournir des notifications adaptées, souvent avec INotifyPropertyChanged.
Une extension peut recevoir des arguments positionnels et des propriétés nommées :
Dans cet exemple, les modifications du texte mettent à jour la source à chaque changement.
Les principales extensions
| Extension | Utilisation |
|---|---|
Binding |
Lier une propriété à une source de données. |
StaticResource |
Résoudre une référence à une ressource. |
DynamicResource |
Installer une référence à une ressource réévaluée par WPF. |
x:Type |
Obtenir un objet représentant un type. |
x:Static |
Lire un membre statique. |
x:Null |
Affecter null. |
RelativeSource |
Décrire une source relative pour une liaison. |
TemplateBinding |
Relier une propriété dans un template à celle du contrôle auquel il s’applique. |
Voici une ressource partagée :
StaticResource ne signifie pas que l’objet retourné est immuable. DynamicResource maintient une expression permettant notamment de réagir au remplacement d’une ressource ; son utilisation nécessite une propriété de dépendance cible.
Pour écrire une chaîne commençant réellement par une accolade, l’échappement XAML {} évite son interprétation comme extension :
Créer une extension
Une extension personnalisée dérive de MarkupExtension et implémente ProvideValue :
using System;
using System.Windows.Markup;
namespace BookSamples.Markup;
public class GreetingExtension : MarkupExtension
{
public string Name { get; set; } = "lecteur";
public override object ProvideValue(IServiceProvider serviceProvider)
{
return $"Bonjour, {Name} !";
}
}
Après déclaration de xmlns:markup="clr-namespace:BookSamples.Markup" :
Le suffixe Extension peut être omis dans cette syntaxe. Cet exemple produit une chaîne au moment où l’extension est évaluée ; il ne met pas en place une liaison réactive.
Les propriétés de dépendance
Un système de propriétés propre à WPF
Une propriété .NET classique accède souvent à un champ. Une propriété de dépendance est enregistrée auprès du système de propriétés WPF, qui gère sa valeur sur un objet dérivant de DependencyObject.
Ce mécanisme permet notamment les styles, les déclencheurs, les animations, les valeurs par défaut et, pour certaines propriétés, l’héritage de valeur dans l’arbre d’éléments. Une cible de liaison WPF doit être une propriété de dépendance ; une propriété source peut être une propriété .NET classique.
Le mot « dépendance » ne désigne pas ici l’injection de dépendances. Il renvoie au fait que la valeur effective peut dépendre de plusieurs mécanismes de WPF.
Déclarer une propriété de dépendance
Créons un contrôle affichant un statut :
using System.Windows;
using System.Windows.Controls;
namespace BookSamples.Controls;
public class StatusBadge : Control
{
public static readonly DependencyProperty TextProperty =
DependencyProperty.Register(
nameof(Text),
typeof(string),
typeof(StatusBadge),
new FrameworkPropertyMetadata(
"Indisponible",
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsRender));
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
}
TextProperty est l’identifiant partagé de la propriété ; il ne contient pas le texte de chaque instance. Register indique le nom, le type de valeur, le type propriétaire et les métadonnées. Les accesseurs utilisent le stockage de WPF avec GetValue et SetValue.
Les métadonnées choisies signalent que la modification peut affecter la mesure et le rendu. Elles ne dessinent pas le contrôle. Pour le rendre visible, on peut lui appliquer ce template :
<controls:StatusBadge Text="Disponible">
<controls:StatusBadge.Template>
<ControlTemplate TargetType="{x:Type controls:StatusBadge}">
<Border Background="LightGreen" Padding="8,4">
<TextBlock Text="{TemplateBinding Text}" />
</Border>
</ControlTemplate>
</controls:StatusBadge.Template>
</controls:StatusBadge>
Une logique appelée à chaque changement doit être placée dans une callback de métadonnées plutôt que seulement dans le setter CLR : WPF peut modifier la valeur sans passer par ce setter. Les métadonnées peuvent aussi fournir une callback de coercition ; l’enregistrement peut recevoir une callback de validation.
La priorité des valeurs
Plusieurs sources peuvent proposer une valeur pour une même propriété. WPF applique un ordre de priorité. Par exemple, une valeur locale définie par un attribut XAML prime sur un setter de style :
<Button Background="Orange">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="LightBlue" />
</Style>
</Button.Style>
</Button>
La valeur effective de Background est Orange. Ce seul exemple ne résume pas toute la priorité : la coercition et les animations actives peuvent intervenir à des niveaux supérieurs.
L’héritage de valeur est également sélectif. DataContext se transmet généralement aux descendants, mais toutes les propriétés de dépendance n’héritent pas de leur valeur.
Enfin, affecter une valeur locale en code peut remplacer une liaison existante. SetCurrentValue permet, dans les scénarios adaptés, de changer la valeur tout en préservant sa source. ClearValue retire une valeur locale pour laisser les autres sources déterminer la valeur effective.
Les propriétés attachées
Porter une information définie par un autre type
Une propriété attachée permet d’affecter à un objet une information définie par un autre type. Elle ne nécessite pas d’ajouter un membre CLR au type de cet objet.
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Nom du client" />
<TextBox Grid.Row="1" />
</Grid>
Grid.Row est définie par Grid, mais sa valeur est portée par les enfants. Le panneau consulte cette information pour les placer. Un TextBox ne possède donc pas une propriété CLR Row ajoutée par ce balisage.
Une propriété attachée peut être affectée même si aucun composant ne l’exploite. Mettre Grid.Row="1" sur un enfant d’un StackPanel ne le place pas dans une ligne de grille.
Dans WPF, les propriétés attachées de l’interface sont généralement implémentées comme des propriétés de dépendance. Les deux notions se complètent : « attachée » décrit la manière de porter l’information ; « de dépendance » décrit son intégration au système de propriétés WPF.
Lire et écrire en C
Ces méthodes statiques constituent les accesseurs de la propriété attachée.
Déclarer une propriété attachée
Une application peut, par exemple, associer un identifiant d’aide aux éléments :
using System.Windows;
namespace BookSamples.Behaviors;
public static class Help
{
public static readonly DependencyProperty TopicProperty =
DependencyProperty.RegisterAttached(
"Topic",
typeof(string),
typeof(Help),
new PropertyMetadata(string.Empty));
public static string GetTopic(DependencyObject element)
=> (string)element.GetValue(TopicProperty);
public static void SetTopic(DependencyObject element, string value)
=> element.SetValue(TopicProperty, value);
}
Après déclaration de xmlns:behaviors="clr-namespace:BookSamples.Behaviors" :
Cette déclaration mémorise une information. Pour afficher une aide, il faut encore un traitement qui lise cette valeur, par exemple à la réception d’une demande d’aide. Une propriété attachée ne crée pas automatiquement un comportement.
Les événements
Les événements classiques
Les événements CLR permettent à un objet d’annoncer qu’une action ou un changement a eu lieu. Ils utilisent des délégués et des abonnements avec +=, comme dans les applications Windows Forms.
WPF conserve ce mécanisme. L’événement Closed d’une fenêtre est, par exemple, un événement classique :
<Window x:Class="BookSamples.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Événement classique"
Closed="Window_Closed">
<TextBlock Text="Fermez cette fenêtre." />
</Window>
private void Window_Closed(object? sender, System.EventArgs e)
{
// Libérer les ressources propres à cette fenêtre.
}
On peut aussi s’abonner en C# avec Closed += Window_Closed;. Un événement classique appelle les gestionnaires abonnés à l’objet qui le publie ; il ne parcourt pas automatiquement les parents de l’interface.
Les événements routés
Un événement routé est enregistré auprès du système d’événements WPF. Selon sa stratégie, il peut invoquer des gestionnaires sur plusieurs éléments d’un itinéraire dans l’arbre de l’interface. L’arbre réel peut comporter des éléments internes provenant des templates.
La syntaxe d’abonnement reste souvent identique à celle d’un événement classique. Le Click d’un bouton WPF est pourtant un événement routé. Une notation Click="..." ne permet donc pas, à elle seule, de reconnaître la nature de l’événement.
| Stratégie | Parcours | Exemple |
|---|---|---|
Tunnel |
De la racine de l’itinéraire vers la source. | PreviewKeyDown |
Bubble |
De la source vers les parents. | KeyDown, Button.Click |
Direct |
Sur la source, sans parcours des parents. | MouseEnter |
Un événement routé direct conserve les fonctionnalités du système d’événements WPF ; il n’est donc pas équivalent en tous points à un événement CLR classique.
La stratégie Bubble
Le bouillonnement permet à un conteneur de recevoir les événements de ses descendants :
<StackPanel Button.Click="OnActionClick">
<Button Content="Enregistrer" Tag="Save" />
<Button Content="Supprimer" Tag="Delete" />
</StackPanel>
private void OnActionClick(object sender, RoutedEventArgs e)
{
if (e.Source is System.Windows.Controls.Button button)
{
MessageBox.Show($"Action demandée : {button.Tag}");
}
}
Le gestionnaire est attaché au StackPanel, même si ce type ne possède pas de membre CLR Click. La notation qualifiée Button.Click identifie l’événement.
sender désigne l’élément auquel le gestionnaire est attaché. Source désigne la source rapportée par l’événement. OriginalSource conserve la source initiale, qui peut être un élément interne du contrôle. Ces valeurs ne sont donc pas toujours identiques.
La stratégie Tunnel
Le tunneling permet à un ancêtre d’observer ou de traiter une entrée avant qu’elle atteigne la source. Pour une arborescence simplifiée Window, Grid, TextBox, PreviewKeyDown parcourt ces éléments dans cet ordre. L’événement associé KeyDown parcourt ensuite l’itinéraire inverse, si ses gestionnaires ordinaires ne sont pas inhibés par le traitement effectué.
Voici un exemple complet :
<Window x:Class="BookSamples.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Observer le routage"
Width="440" Height="220"
PreviewKeyDown="OnPreviewKeyDown"
KeyDown="OnKeyDown">
<Grid PreviewKeyDown="OnPreviewKeyDown"
KeyDown="OnKeyDown">
<TextBox Margin="20"
VerticalAlignment="Center"
PreviewKeyDown="OnPreviewKeyDown"
KeyDown="OnKeyDown" />
</Grid>
</Window>
using System.Diagnostics;
using System.Windows;
using System.Windows.Input;
namespace BookSamples;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
Debug.WriteLine($"Tunnel : {sender.GetType().Name}, {e.Key}");
}
private void OnKeyDown(object sender, KeyEventArgs e)
{
Debug.WriteLine($"Bubble : {sender.GetType().Name}, {e.Key}");
}
}
En donnant le focus à la zone de texte puis en appuyant sur une touche telle que F6, on peut observer le parcours dans la sortie de débogage. Certains contrôles traitent eux-mêmes certaines touches, ce qui influence les gestionnaires ordinaires invoqués.
Le préfixe Preview est courant pour les événements d’aperçu, mais il ne garantit pas une stratégie Tunnel pour tous les événements. PreviewMouseLeftButtonDown, par exemple, utilise une stratégie directe avec un mécanisme de relèvement sur les éléments de l’itinéraire. Il faut vérifier la stratégie de l’événement considéré.
Marquer un événement comme traité
Un gestionnaire peut valoriser la propriété Handled de l'évènement à true :
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.F2)
{
MessageBox.Show("Renommage demandé.");
e.Handled = true;
}
}
Cette affectation n’efface pas l’itinéraire. Elle bloque l’appel des gestionnaires ordinaires suivants. Pour les paires d’événements d’entrée qui partagent leurs données, le traitement pendant l’aperçu influence aussi le traitement de l’événement associé.
Un gestionnaire peut explicitement recevoir les événements déjà traités :
private void OnObservedKeyDown(object sender, KeyEventArgs e)
{
Debug.WriteLine($"Touche : {e.Key}, traitée : {e.Handled}");
}
Ce mécanisme est utile pour l’observation ou certains contrôles composés. Son utilisation doit tenir compte du traitement déjà réalisé afin d’éviter de déclencher deux fois la même action. Pour une action applicative telle qu’enregistrer ou supprimer, une commande WPF peut compléter la gestion des événements en exprimant aussi si l’action est disponible.