Introduction
Apparue avec la version 3.0 du Framework .Net, la technologie WPF (Windows Presentation Foundation) a pour vocation de changer l’approche que l’on a de la création d’une interface utilisateur, en permettant à des développeurs et à des graphistes de travailler conjointement en partageant les mêmes sources pour obtenir une application dotée d’une IHM riche graphiquement.
Cet article a pour but de présenter la création d’un contrôle utilisateur WPF d’un point de vue développeur, en introduisant les nouveaux outils à utilisés pour rendre possibles toutes les nouvelles fonctionnalités de ce Framework. Cette présentation se fera au travers une problématique d’affichage d’informations géo-localisées sur une carte. Le contrôle devra afficher les éléments d’une liste en les positionnant en X, Y et en pondérant leur représentation sur la carte en fonction de la valeur d’une de leurs propriétés.
Les sources de cet article ont été développées avec Visual Studio 2008 SP1 pour le Framework .net 3.5 SP1. Il est conseillé de maitriser des notions de bases en C# et en WPF.
Les « DependencyObject » : une base commune
Rappel sur le databinding
Le databinding permet de lier deux objets entre eux afin que le changement d’une propriété de l’un affecte une propriété de l’autre. La mise en place de cette technologie peut être assez basique : il suffit que l’objet détenant l’information (le fournisseur), lève un événement lorsque celle-ci change. Ainsi, l’autre objet (le consommateur) abonné est notifié lorsqu’il doit mettre à jour sa propriété. Cette technologie, présente bien avant le Framework 3.0, a évoluée pour s’intégrer complètement à WPF.
Cette intégration permet de ne pas se limiter à une vision monodirectionnelle du Binding, il existe en effet 4 modes en WPF :
- OneWay (le mode par défaut que nous venons de voir) : l’information du consommateur est mise à jour à chaque changement,
- OneTime : quasi identique au mode précédent, à la différence que la mise à jour ne se fait qu’une et une seule fois,
- TwoWay : la mise à jour se fait dans les 2 sens. Le fournisseur peut donc être mis à jour par le consommateur,
- OneWayToSource : la mise à jour n’agit que dans le sens consommateur vers fournisseur.
En plus de ces différents modes, il est possible d’ajouter des convertisseurs qui permettront de modifier l’information du fournisseur avant qu’elle ne soit transmise au consommateur. De manière similaire des validateurs pourront vérifier que la donnée fournie est bien propre à être consommée. Il existe également des notions d’hérédité des propriétés entre les objets. Toutes ces notions sont possibles grâce à la classe « DependencyObject » dont dérivent l’ensemble des composants graphiques de WPF. Celle-ci permet d’offrir à tous les contrôles qui en héritent, un nouveau système de gestion des propriétés : les « DependencyProperty ». C’est en y ajoutant les propriétés de notre futur contrôle que celles-ci pourront profiter de l’ensemble des fonctions de Binding que nous venons de voir.
Je ne m’essayerai pas à vous expliquer plus en avant le mécanisme des « DependencyProperty » dans le détail, d’une part car David Catuhe l’explique déjà très bien dans un précédent article et d’autre part car je m’éloignerai de mon objectif qui est de présenter une simple mise en œuvre de cette technologie au service de la création d’un contrôle utilisateur.
Ajout d’une « DependencyProperty »
Afin d’avoir matière à commenter, voyons tout de suite un exemple d’implémentation d’une nouvelle « DependencyProperty » :
public static readonly DependencyProperty TextProperty
= DependencyProperty.Register("Text", typeof(String)
, typeof(UCTest));
public String Text
{
get { return (String)GetValue(TextProperty); }
set { SetValue(TextProperty, value); }
}
Première remarque, il y a 2 propriétés déclarées, une du type qui nous intéresse avec des accesseurs et une « DependencyProperty ». Cette dernière est statique, readonly et on ne l’instancie pas, du fait qu’elle est simplement enregistrée auprès du gestionnaire des propriétés des « DependencyObjects ». Celui-ci a la charge de gérer l’ensemble des « DependencyProperty » de tous les objets. C’est aussi lui qui les stockera de manière optimisée et se chargera des liens de bindings. C’est également cette structure centralisée qui permettra d’avoir des notions d’hérédité entre les propriétés de nos différents objets. Cet enregistrement nécessite un certain nombre d’informations en paramètre :
- Le nom de la propriété auquel il fera référence,
- Le type de cette propriété, qui servira notamment au gestionnaire à optimiser l’espace mémoire réservé pour stocker cette propriété,
- Le type d’objet auquel elle pourra être rattachée, ici « UCTest » qui est le nom de classe contenant cette « DependencyProperty ».
La propriété « Text » quant à elle, utilise des accesseurs qui appellent les méthodes « GetValue » et « SetValue » (méthodes héritées de « DependencyObject » dont doit hériter la classe contenant les propriétés) qui vont simplement accéder au gestionnaire pour respectivement y lire et y écrire la propriété.
Cette portion de code nous montre qu’il est possible sans trop de difficulté de se créer une « DependencyProperty » qui sera directement exploitable en XAML. Celle-ci nous offre toutes les possibilités de Binding « OneWay » ou « TwoWay » avec ou sans « Converter » et bien d’autre possibilités, mais par contre aucune possibilité de savoir quand une valeur est modifiée. Certain pourrait imaginer se servir du setter, mais ce serait sans compter que le Binding ne passera pas par là et accédera directement au Gestionnaire des « DependencyProperty ».
Bien évidement une solution existe. Pour cela, regardons cette seconde implémentation de notre code pour en prendre connaissance :
public static readonly DependencyProperty TextProperty
= DependencyProperty.Register("Text", typeof(String)
, typeof(UCTest), New PropertyMetadata(TextUpdated));
public String Text
{
get { return (String)GetValue(TextProperty); }
set { SetValue(TextProperty, value); }
}
private static void TextUpdated(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
//Task
}
Ainsi, grâce à un nouveau paramètre de type « PropertyMetadata », il est possible d’indiquer une méthode de callback « TextUpdated » qui sera appelée à chaque modification de la propriété « Text ». Petit inconvénient, « TextUpdated » doit être statique, il ne sera donc pas possible d’agir dans notre objet courant. Il suffit juste de récupérer l’instance de l’objet courant en castant le « DependencyObject » « o » comme ceci :
private static void TextUpdated(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
String str = e.NewValue as String;
if (str != null)
((UCTest)o).TextUpdated(str);
}
private void TextUpdated(String str)
{
//Task
}
Le tour est joué, nous avons maintenant les bases suffisantes pour poursuivre la création d’un composant WPF disposant de ses propres « DependencyProperty ».
La création du contrôle
Le fonctionnel
Le composant WPF que l’on va créer est intégré à une application existante Celle-ci permet de visualiser des clients « Customer » ayant effectués des achats « Order » dans des magasins « Shop ». Les clients et les magasins ont une liste d’achats. Chaque achat a un client, un magasin et un montant « Amount ».
Notre contrôle sera une carte 2D de 100*100 pixels qui affichera les classes « Customer » et « Shop » « géo-localisées »par leur propriété « Position » de type « System.Windows.Point ». Pour la simplification de l’exemple ces positions sont comprises entre (0 ; 0) et (100 ; 100). Ces 2 classes ont également une propriété « TotalAmount » retournant le montant total des dépenses d’un client ou le chiffre d’affaire d’un magasin. Cette dernière information nous permettra de paramétrer la taille des ellipses servant à représenter nos clients et magasins sur la carte.
Il y aura 2 propriétés « DependencyProperty » :
- « CustomersSource » de type « IEnumerable<Customer> » : la liste des clients ;
- « IsCustomersVisible » de type « Boolean » : indique si les clients sont visibles sur la carte.
Voila pour les informations qui sont nécessaires à la création du contrôle. Afin de simplifier cet article et vu que l’affichage de la carte ne fait pas partie de la problématique, celle-ci ce résumera en un fond bleu comme ci-dessous :
Ici les points rouges représentent les magasinset les points bleus représentent les clients. Il est parfaitement envisageable par la suite de modifier la propriété position des clients et des magasins afin de leurs en donner des réels et ainsi afficher les points sur un contrôle LiveEarth par exemple.
Les bases du contrôle
Qui dit WPF, dit XAML (eXtensible Application Markup Language) :
<UserControl x:Class="AF_ADO.VisualMap"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Canvas Height="100" Width="100">
<Canvas Name="ShopsCanvas"/>
<Canvas Name="CustomersCanvas"/>
</Canvas>
</UserControl>
Il s’agit probablement de la version la plus basique possible de notre contrôle. Le « UserControl » est l’élément racine qui, comme en Windows Form, définit que notre classe est un contrôle utilisateur ne peut avoir qu’un seul enfant. Il contient donc un panel de type « Canvas » qui lui-même en contient 2 autres, « ShopsCanvas » pour afficher les magasins et « CustomersCanvas » pour afficher les clients.
Mais cette version étant un peu trop édulcorée, voyons-en une un peu plus riche :
<UserControl x:Class="AF_ADO.VisualMap"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Name="RootControl" SizeChanged="RootControl_SizeChanged">
<Border Name="RootBorder" Height="102" Width="102"
BorderThickness="1" BorderBrush="LightGray" Background="AliceBlue">
<Canvas Height="100" Width="100" RenderTransformOrigin="0,0"
VerticalAlignment="Top" HorizontalAlignment="Left">
<Canvas.RenderTransform>
<ScaleTransform x:Name="RootCanvasScaleTransform" ScaleX="1" ScaleY="1" />
</Canvas.RenderTransform>
<Canvas Name="ShopsCanvas"/>
<Canvas Name="CustomersCanvas"/>
</Canvas>
</Border>
</UserControl>
Peu de choses ont changé : une bordure de type « Border » vient entourer le « Canvas » principal. Celui-ci a une propriété « ScaleTransform » qui permettra d’en changer l’échelle en X et en Y via les propriétés « ScaleX » et « ScaleY ». Le « UserControl » lèvera l’événement « RootControl_SizeChanged » quand sa taille sera modifiée. Le tout permettra d’ajuster la taille de notre contrôle de manière à ce qu’il occupe toujours l’espace maximum.
Mais les panels ne vont-ils pas de base essayer de prendre toute la place disponible ? Oui c’est vrai dans beaucoup de cas. Mais ici la carte est de 100*100 par définition et les points y sont placés dans le code. Si le contrôle est agrandi, il en résultera un espace vide autour de ceux-ci. Nous allons ici voir un exemple qui en modifiant l’échelle du contenu du contrôle, permet de s’assurer qu’il est toujours le plus grand possible. Note qu’il aurait également été possible de redessiner les points via le code en prenant compte du nouvel espace occupé par la carte. Le code exécuté par l’événement de modification de la taille de l’élément racine permet de comprendre le fonctionnement :
private void RootControl_SizeChanged(object sender, SizeChangedEventArgs e)
{
double size;
if (RootControl.ActualHeight > RootControl.ActualWidth)
size = RootControl.ActualWidth;
else
size = RootControl.ActualHeight;
double scale = (size - 2) / 100;
RootCanvasScaleTransform.ScaleX = scale;
RootCanvasScaleTransform.ScaleY = scale;
RootBorder.Height = size;
RootBorder.Width = size;
}
Rien de très compliqué: notre « Canvas » va voir sa taille modifiée grâce au « ScaleTransform ». Ma bordure quant à elle prendra la largeur du « UserControl » dans les 2 axes, afin de garder une forme carrée. La bordure n’est pas incluse dans le « scale » afin qu’elle garde une épaisseur de 1 pixel qui sera plus esthétique.
Remplissage du contrôle
Regardons maintenant la partie code. 3 méthodes serviront à remplir nos « Canvas » :
private void DrawMap()
{
IEnumerable<Customer> customers
= GetValue(CustomersSourceProperty) as IEnumerable<Customer>;
if (customers == null) return;
var shops = from c in customers
from o in c.Orders
group o by o.Shop into s
select new Shop()
{
Id = s.Key.Id,
Name = s.Key.Name,
Position = s.Key.Position,
Orders = new List<Order>(s.AsEnumerable())
};
DrawShops(shops);
DrawCustomers();
}
Comme nous l’avons vu dans la partie fonctionnelle, la source de données est une liste de clients, il va donc falloir récupérer la liste des magasins à partir de celle-ci.
Pour ce faire nous commençons par récupérer la liste de clients grâce à la méthode « GetValue » (Héritée de « DependencyObject » dont descend notre « UserControl »). Puis ensuite, grâce à LINQ To Object, nous pouvons récupérer notre liste de magasins.
Pour expliquer rapidement cette requête, nous avons simplement récupéré toutes les achats « o » de l’ensemble des clients « c ». Puis nous avons groupé les achats en fonction de leur magasin dans « s ». Enfin nous retournons une nouvelle instance de « Shop » reprenant l’ensemble des paramètres de la clé de chaque groupe « s ». La liste des achats faits dans ce magasin est remplie par les éléments du groupe.
Enfin, « DrawMap » appelle les 2 méthodes d’affichages « DrawShops » pour les magasins et « DrawCustomers » pour les clients.
private void DrawShops(IEnumerable<Shop> shops)
{
ShopsCanvas.Children.Clear();
int min = shops.Min(s => s.TotalAmount);
int max = shops.Max(s => s.TotalAmount);
if (max != min) max -= min;
foreach (Shop s in shops)
{
double size = ((double)(s.TotalAmount - min) / max) * 5 + 2;
Ellipse e = new Ellipse();
e.Width = size;
e.Height = size;
e.Fill = new SolidColorBrush(Color.FromRgb(255, 0, 0));
ShopsCanvas.Children.Add(e);
Canvas.SetTop(e, s.Position.Y - size / 2);
Canvas.SetLeft(e, s.Position.X - size / 2);
}
}
La méthode « DrawShops » commence par vider les précédents magasins, puis récupère les montants minimums et maximums des achats. Enfin, pour chaque magasin, ellecrée une « Ellipse » dont la taille dépend du chiffre d’affaire. Cette ellipse est ensuite ajoutée à « ShopsCanvas », qui est le « Canvas » contenant les représentations graphiques des magasins et positionnée à l’emplacement de celui-ci.
private void DrawCustomers()
{
if (!(Boolean)GetValue(IsCustomersVisibleProperty)) return;
IEnumerable<Customer> customers
= GetValue(CustomersSourceProperty) as IEnumerable<Customer>;
if (customers == null) return;
CustomersCanvas.Children.Clear();
int min = CustomersSource.Min(c => c.TotalAmount);
int max = CustomersSource.Max(c => c.TotalAmount);
if (max != min) max -= min;
foreach (Customer c in customers)
{
double size = ((double)(c.TotalAmount - min) / max) * 1 + 1;
Ellipse e = new Ellipse();
e.Width = size;
e.Height = size;
e.Fill = new SolidColorBrush(Color.FromArgb(150, 0, 0, 255));
CustomersCanvas.Children.Add(e);
Canvas.SetTop(e, c.Position.Y - size / 2);
Canvas.SetLeft(e, c.Position.X - size / 2);
}
}
La fonction « DrawCustomers » diffère de la fonction d’affichage des magasins en 2 points :
- En premier sur le fait que l’affichage dépendra de savoir si les clients doivent être affichés ou non,
- En second sur le calcul de la taille de l’ellipse.
La mise en place du databinding
Après avoir mis en place les bases du composant vous aurez forcement remarqué que nous faisons un appel via la méthode « GetValue » aux deux propriétés « CustomersSourceProperty » et « IsCustomersVisibleProperty », mais nous ne les avons pas encore mises en place. Voici donc le code nécessaire qui s’inspire très largement de ce qui a été vu au premier chapitre.
public static readonly DependencyProperty CustomersSourceProperty
= DependencyProperty.Register(
"CustomersSource",
typeof(IEnumerable<Customer>),
typeof(VisualMap),
new PropertyMetadata(DrawMap)
);
public static readonly DependencyProperty IsCustomersVisibleProperty
= DependencyProperty.Register(
"IsCustomersVisible",
typeof(Boolean),
typeof(VisualMap),
new PropertyMetadata(DrawMap)
);
public IEnumerable<Customer> CustomersSource
{
get { return (IEnumerable<Customer>)GetValue(CustomersSourceProperty); }
set { SetValue(CustomersSourceProperty, value); }
}
public Boolean IsCustomersVisible
{
get { return (Boolean)GetValue(IsCustomersVisibleProperty); }
set { SetValue(IsCustomersVisibleProperty, value); }
}
private static void DrawMap(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
((VisualMap)o).DrawMap();
}
Rien de nouveau dans les grandes lignes : nous enregistrons les deux « DependencyProperty », puis nous créons les deux propriétés « CustomersSource » et « IsCustomersVisible » avec leurs getters et setters et enfin nous implémentons la fonction statique « DrawMap » qui convertit le « DependencyObject » fourni en paramètre en « VisualMap » qui est le type de notre « UserControl ».
Ajout d’événements
Nous n’en avons pas encore parlé jusqu’ici, mais WPF en plus d’intégrer le nouveau système de gestion des « DependencyProperty », intègre également un nouveau système de gestion d’événements. Celui-ci consiste également en un gestionnaire centralisé géré par le Framework de tous les événements qui sont du nouveau type « RoutedEvent », qui permettront notamment une circulation des événements dans l’arbre XAML comme expliqué un peut plus loin. Détailler l’ensemble des nouvelles possibilités mérite un article à part entière, je vais donc ici parler de l’implémentation basique des événements WPF. Ceux-ci seront levés sur le clic d’un magasin ou d’un client.
Dans les faits, on retrouve une partie de ce à quoi on était habitué : la classe « EventArgs » étant remplacée par « RoutedEventArgs » qui permet de prendre en charge les spécificités du nouveau système. Il faut donc commencer par écrire nos « RoutedEventArgs » et nos « EventHandler » :
public delegate void ShopSelectedEventHandler(object sender, ShopSelectedEventArgs e);
public class ShopSelectedEventArgs : RoutedEventArgs
{
public Shop Shop { get; private set; }
public ShopSelectedEventArgs(RoutedEvent e, Shop shop)
: base(e)
{
Shop = shop;
}
}
public delegate void CustomerSelectedEventHandler(object sender, CustomerSelectedEventArgs e);
public class CustomerSelectedEventArgs : RoutedEventArgs
{
public Customer Customer { get; private set; }
public CustomerSelectedEventArgs(RoutedEvent e, Customer customer)
: base(e)
{
Customer = customer;
}
}
Ici on note que la classe « RoutedEventArgs » attend un « RoutedEvent » en paramètre de son constructeur. Il permettra lors de la levée d’un événement via la méthode « RaiseEvent » (héritée de « UIElement » qui est la classe de base de tous les contrôles WPF) d’indiquer quel événement elle doit appeler. A la différence de WinForm, nous n’allons pas lever directement un événement, mais simplement appeler la méthode de levée d’événement avec en paramètre un « EventArgs ». Il doit donc nécessairement connaitre l’événement au quel il fait référence.
Ensuite nous ajoutons deux événements à notre « UserControl » et c’est là que nous allons travailler avec le gestionnaire d’événements de WPF. À l’instar de l’ajout de propriétés, l’ajout d’événements se fait en deux étapes :
- Une première consistant en la création d’un « RoutedEvent » qui sera enregistré dans le gestionnaire
- Une seconde via un événement classique permet simplement l’inscription ou la désinscription à l’événement qui a été enregistré dans le gestionnaire.
public static readonly RoutedEvent ShopSelectedEvent
= EventManager.RegisterRoutedEvent(
"ShopSelected", RoutingStrategy.Direct,
typeof(ShopSelectedEventHandler), typeof(VisualMap));
public event ShopSelectedEventHandler ShopSelected
{
add { AddHandler(ShopSelectedEvent, value); }
remove { RemoveHandler(ShopSelectedEvent, value); }
}
public static readonly RoutedEvent CustomerSelectedEvent
= EventManager.RegisterRoutedEvent(
"CustomerSelected", RoutingStrategy.Direct, typeof(CustomerSelectedEventArgs), typeof(VisualMap));
public event CustomerSelectedEventHandler CustomerSelected
{
add { AddHandler(CustomerSelectedEvent, value); }
remove { RemoveHandler(CustomerSelectedEvent, value); }
}
On peut remarquer que le « RoutedEvent » doit être statique et readonly pour ne pas être enregistré à chaque instanciation de notre « UserControl ». La fonction « RegisterRoutedEvent » demande 4 paramètres :
- le nom de l’événement auquel il fait référence,
- le type de « RoutingStrategy »,
- le type d’argument de cet événement
- enfin le type d’objet auquel il pourra être rattaché. Concernant la « RoutingStrategy », le mode « Direct » fait que l’objet reçoit directement l’événement. Deux autres modes existent : « Tunnel » et « Bubble » qui permettent de faire circuler l’événement dans l’arbre XAML. De la racine vers l’élément qui a levé l’événement dans le cas du « Tunnel » et à l’inverse de l’élément qui a levé l’événement à la racine dans l’autre cas.
Pour prendre en compte le clic sur un magasin, il faudra ajouter ces 3 lignes de code lors de la création de l’ellipse :
e.Tag = s;
e.MouseLeftButtonDown +=
delegate(object sender, MouseButtonEventArgs m)
{
((UIElement)sender).CaptureMouse();
};
e.MouseLeftButtonUp += new
MouseButtonEventHandler(Shop_MouseLeftButtonUp);
Nous commençons par définir la propriété « Tag » de notre ellipse avec le magasin qu’elle représentera. L’ellipse n’étant pas un bouton, elle n’a pas d’événement « Click ». Nous avons donc ici capturé la souris sur l’événement « MouseLeftButtonDown », puis implémenté la réponse à l’événement « MouseLeftButtonUp » comme ceci :
private void Shop_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (Mouse.Captured != sender) return;
FrameworkElement fe = sender as FrameworkElement;
fe.ReleaseMouseCapture();
RaiseEvent(
new ShopSelectedEventArgs
(VisualMap.ShopSelectedEvent, (Shop)fe.Tag));
}
Lorsque l’utilisateur relâche le bouton de sa souris et si l’élément qui l’a capturé est bien celui qui a levé l’événement, alors nous appelons « RaiseEvent » (hérite de « UIElement ») et nous lui passons en paramètre un nouveau « ShopSelectedEventArgs ». Le constructeur de ce dernier prend en paramètre le « RoutedEvent » que l’on va lever, ainsi que le magasin sur lequel l’utilisateur a cliqué, (récupère via le « Tag » de notre ellipse).
Ci-dessous la même chose, mais dans le cas d’un client :
e.Tag = c;
e.MouseLeftButtonDown +=
delegate(object sender, MouseButtonEventArgs m)
{
((UIElement)sender).CaptureMouse();
};
e.MouseLeftButtonUp += new
MouseButtonEventHandler(Customer_MouseLeftButtonUp);
Voici l’implémentation de la réponse à l’événement « MouseLeftButtonUp » :
private void Customer_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (Mouse.Captured != sender) return;
FrameworkElement fe = sender as FrameworkElement;
fe.ReleaseMouseCapture();
RaiseEvent(
new CustomerSelectedEventArgs
(VisualMap.CustomerSelectedEvent, (Customer)fe.Tag));
}
Comme dans le cas d’un clic sur un magasin, le clic sur un client va lever un événement « CustomerSelectedEvent » en passant via les arguments le client qui est enregistré dans la propriété « Tag » de l’ellipse le représentant.
Conclusion
Nous arrivons à la fin de cette première approche de la création d’un contrôle utilisateur en WPF. Comme nous l’avons vu cette technologie impose un certain nombre de changement dans l’implémentation du code. Mais heureusement des bases connues nous permettent de nous repérer, il sera juste question de créer des « DependencyProperty » en parallèle des propriétés classiques et des « RoutedEvent » en parallèle des événements.
Tous ces changements en valent-ils vraiment la peine ? Il y a fort à parier que ceux qui ont déjà des notions d’utilisation de WPF, voient les avantages et peuvent imaginer porter ce qu’ils ont déjà fait avec les contrôles de bases sur leurs propres contrôles. Pour les autres, pour qui les notions de trigger, de storyboard, de propriétés attachées ou encore de template ne veulent pas dire grand-chose dans le contexte de WPF, d’autres articles suivront. Mais il était important de faire cette mise en place technologique avant de montrer l’étendu des possibilités offertes.
Pour aller plus loin
Cet article montre une des approches possibles pour répondre à ce genre problématique, il en existe d’autre fonctionnant avec des nouveaux composants apportés par WPF. Je vous montrerais dans un prochain article que l’utilisation d’ « ItemsControl » et d’un « DataTemplate » permet d’arriver au même résultat tout en écrivant moins de lignes de code.