WPF : personnalisation d’ItemsControls

Lors du mercredi du développement « Développement d’applications métier avec WPF », j’avais parlé en fin de session de la possibilité de profiter du binding sur collection sur n’importe quel conteneur WPF et pas uniquement sur les contrôles de type « Liste ». Le temps ayant manqué, voici le complément sous forme d’article, ainsi que le code source.

Pour rappel, WPF propose deux contrôles de contenus de base :

  • le ContentControl pour les données unitaires.
  • l’ItemsControl pour les collections de données.

Tous deux apportent la notion de contenu (Content, ItemsSource) sans aucune notion de rendu.
Les DataTemplate associés (ContentTemplate, ItemTemplate) permettent de personnaliser l’affichage du contenu.

WPF fournit quelques ItemsControls de plus haut niveau tels que le ListBox, le ListView ou encore le DataGrid.

Cependant, d’autres contrôles affichent des collections de visuels avec des règles de positionnement plus évoluées, les panels :

  • Grid
  • DockPanel
  • StackPanel
  • WrapPanel
  • Canvas
  • Etc

Ces contrôles ne supportent pas le binding de collections. Ils sont orientés Visuels !
Ce sont en effet des contrôles purement graphiques qui n’apportent que le positionnement.

Ainsi, il est impossible d’ajouter des données logiques (non visuelles) à un panel :

            grid1.Children.Add(1);

n’est pas autorisé.

WPF distinguant au maximum le fonctionnel du visuel, nous avons donc deux types de contrôles de collections (acceptant des enfants) :

  • les descendants de Panel qui ont une propriété UIElement[] Children
  • les descendants de ItemsControl qui ont une propriété ItemsSource/Items.

Cependant, même si cette séparation est très propre, il est évident que nous voulions associer les deux : avoir un positionnement de type DockPanel bindé à une collection de Customers par exemple.

Première étape, on raisonne fonctionnel afin de choisir le bon conteneur logique. Ne pas penser au visuel est assez peu habituel et est même parfois assez déroutant.
Ainsi, la seule question à se poser quasiment est : veut-on afficher une collection simple ou une collection avec élément courant ?
Sans élément courant, un ItemsControl suffit, sinon c’est une ListBox.

Ensuite, il convient de définir le bon visuel. Nous allons donc modifier le Template du contrôle pour cela. Jusque là c’est assez classique, sauf que WPF permet d’utiliser un Panel afin de templater un ItemsControl et si sa propriété IsItemsHost est à True, le Panel va automatiquement créer autant d’enfants visuels qu’il y a d’éléments dans la collection.

<ItemsControl ItemsSource="{Binding}" x:Name="itemsControl">
    <ItemsControl.Template>
        <ControlTemplate>
            <Canvas IsItemsHost="True" />
        </ControlTemplate>
    </ItemsControl.Template>                    
</ItemsControl>

Nous obtenons donc ici l’équivalent d’un Canvas auquel on peut associer une collection d’objets.

Enrichissons un peu notre scénario : imaginons que les objects de la collection aient des propriétés Top et Left. Nous aimerions les placer sur le Canvas en associant Canvas.Top et Canvas.Left à nos objets métiers.

public class Customer : INotifyPropertyChanged
{
    public string Name { get; set; }
    private double top;
    public double Top 
    { 
        get { return top; } 
        set
        { 
            top = value;
            if (PropertyChanged != null) PropertyChanged(this, new
                PropertyChangedEventArgs("Top"));
        }
    }
    private double left;
    public double Left
    {
        get { return left; }
        set
        {
            left = value;
            if (PropertyChanged != null) PropertyChanged(this, new
                PropertyChangedEventArgs("Left"));
        }
    }
...

Pour cela, rappelons que les ItemsControls ont un mécanisme de création automatique de containeurs visuels pour afficher les objets qui sont bindés. Ainsi un ListBox créera à la volée des ListBoxItem pour afficher tous les éléments de sont ItemsSource. Lorsque l’ItemsControl est templaté comme nous venons de le faire, ce mécanisme est préservé ainsi que le mécanisme de template d’item (ItemTemplate).

C’est donc sur ce conteneur visuel créé automatiquement que nous aimerions placer les propriétés Canvas.Top et Canvas .Left. Le problème est, comment avoir la main sur ces containeurs alors qu’ils sont créés automatiquement ?

Heureusement la classe ItemsControl expose le style de ces containeurs, ce qui nous permettra d’ajouter notre fonctionnalité directement en Xaml :

<ItemsControl ItemsSource="{Binding}" x:Name="itemsControl">

    <ItemsControl.ItemContainerStyle>
        <Style TargetType="ContentPresenter">
            <Setter Property="Canvas.Top" Value="{Binding Top}"/>
            <Setter Property="Canvas.Left" Value="{Binding Left}"/>
        </Style>
    </ItemsControl.ItemContainerStyle>

    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Ellipse 
                     Stroke="Black" StrokeThickness="1" Width="20"
                     Height="20" Fill="LightGray"
                     ToolTip="{Binding Name}" />
        </DataTemplate>
    </ItemsControl.ItemTemplate>

    <ItemsControl.Template>
        <ControlTemplate>
            <Canvas IsItemsHost="True" />
        </ControlTemplate>
    </ItemsControl.Template>                    
</ItemsControl>

Vous remarquerez que j’ai également ajouté un classique ItemTemplate afin de définir l’apparence de mes objets dans mon Canvas. On obtient ainsi un Canvas qui place automatiquement une ellipse pour chacun des objets bindés !

Les objects bindés (Customer) implémentant INotifyPropertyChanged, toute modification des propriétés Top et Left directement dans la grille, impactera instantanément le positionnement des ellipses du Canvas. Notons un point important : il est très peu probable que notre objet métier expose directement les coordonnées pixel en tant que propriétés ! Si la logique associant un objet métier à des coordonnées est plus complexe, il suffit juste de l’embarquer dans un Converter et le tour est joué.

Il serait pratique de pouvoir faire l’inverse : déplacer visuellement les ellipses et impacter les objets métier.

Pour ceci, nous allons attacher des évènements de souris à nos ItemTemplate afin de créer le déplacement.

<ItemsControl ItemsSource="{Binding}" x:Name="itemsControl">

    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Ellipse Style="{StaticResource CenteredElement}"
                     Stroke="Black" StrokeThickness="1" Width="20"
                     Height="20" Fill="LightGray"
                     ToolTip="{Binding Name}"
                     MouseDown="Ellipse_MouseDown"
                     MouseUp="Ellipse_MouseUp"
                     MouseMove="Ellipse_MouseMove" />
        </DataTemplate>
    </ItemsControl.ItemTemplate>
…
</ItemsControl>

private void Ellipse_MouseMove(object sender, MouseEventArgs e)
{
    var fe = (sender as FrameworkElement);
    if (fe.IsMouseCaptured)
    {
        var pos = e.GetPosition(itemsControl);
        var customer = fe.DataContext as Customer;
        customer.Top = Convert.ToInt32(pos.Y);
        customer.Left = Convert.ToInt32(pos.X);
    }
}

Vous remarquerez que le code ne déplace pas l’ellipse ! En effet le positionnement des ellipses est bindé à nos objets métier. Il suffit donc juste de modifier les valeurs directement dans les objets métiers eux-mêmes afin d’impacter l’affichage. Cqfd…

Petit perfectionnement : en jouant sur Canvas.Top et Canvas.Left, on manipule la coordonnées supérieur gauche de notre visuel, ce qui n’est pas forcément le meilleur choix, surtout lorsque l’on déplace l’ellipse à la souris. On préférerait en effet manipuler le centre de l’ellipse.
Ce n’est pas si facile à faire de manière complètement générique. J’ai utilisé ici un choix astucieux : l’utilisation des transformations.
J’aurais pu bien évidemment intercepter le binding des coordonnées Top et Left dans un converter pour les décaler afin de centrer le contrôle. Cette technique est un peu complexe car il est complexe de retrouver le contrôle depuis le binding sur Canvas.Top et Canvas.Left. Le contrôle est pourtant nécessaire puisque nous avons besoin de sa taille (largeur et hauteur) afin de calculer le centrage.

Aussi, l’idée d’utiliser une transformation est bien plus séduisante. Déjà, la transformation porte sur le contrôle lui-même, nous aurons donc toutes ses propriétés accessibles sous la main. De plus, une transformation de type RenderTransform nous permettra de décaler la position de notre visuel sans toucher à la logique des Canvas.Top et Canvas.Left. Nous allons voir que nous pouvons tout isoler dans un style afin de simplifier l’utilisation de notre fonctionnalité de centrage.
Vous remarquerez dans le code Xaml que le binding démarrant dans l’objet transformation lui-même (propriété X et Y), je suis obligé de remonter jusqu’au premier visuel (l’ellipse dans notre cas) pour récupérer la largeur et la hauteur. L’usage d’un RelativeSource est nécessaire à cette opération.

<ItemsControl.ItemTemplate>
    <DataTemplate>
        <Ellipse Style="{StaticResource CenteredElement}"
…

<Window.Resources>
    <local:CenterPositionConverter x:Key="CenterPositionConverter" />
    <Style x:Key="CenteredElement" TargetType="{x:Type FrameworkElement}">
        <Setter Property="RenderTransform">
            <Setter.Value>
                <TransformGroup>
                    <TranslateTransform 
                        X="{Binding RelativeSource={RelativeSource
                          Mode=FindAncestor,
                          AncestorType={x:Type UIElement}},
                        Path=ActualWidth,
                        Converter={StaticResource
                            CenterPositionConverter}}" />
                    <TranslateTransform
                        Y="{Binding RelativeSource={RelativeSource
                          Mode=FindAncestor,
                          AncestorType={x:Type UIElement}},
                        Path=ActualHeight,
                        Converter={StaticResource
                            CenterPositionConverter}}" />
                </TransformGroup>
            </Setter.Value>
        </Setter>
    </Style>
</Window.Resources>

Comme dernier exemple, j’ai ajouté une seconde illustration de cette technique avec l’utilisation d’un composant Grid. Scénario quasiment identique, les propriétés Grid.Column et Grid.Row sont automatiquement associées aux objets métier.

<ItemsControl ItemsSource="{Binding}">
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Button Content="{Binding Name}" />
        </DataTemplate>
    </ItemsControl.ItemTemplate>
    <ItemsControl.ItemContainerStyle>
        <Style TargetType="ContentPresenter">
            <Setter Property="Grid.Column" Value="{Binding Column}"/>
            <Setter Property="Grid.Row" Value="{Binding Row}"/>
        </Style>
    </ItemsControl.ItemContainerStyle>
    <ItemsControl.Template>
        <ControlTemplate>
            <Grid ShowGridLines="True" IsItemsHost="True"
                Loaded="Grid_Loaded">
                <Grid.RowDefinitions>
                    <RowDefinition Height="62*" />
                    <RowDefinition Height="53*" />
                    <RowDefinition Height="63.04*" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="101*" />
                    <ColumnDefinition Width="111*" />
                    <ColumnDefinition Width="108*" />
                    <ColumnDefinition Width="98*" />
                    <ColumnDefinition Width="65*" />
                </Grid.ColumnDefinitions>
            </Grid>
        </ControlTemplate>
    </ItemsControl.Template>                    
</ItemsControl>

Vous pourrez trouver le code source de cette solution associé à cet article.

Haut de pageHaut de page