Tag Archives: WPF

[WPF] Empêcher l’utilisateur de coller une image dans un RichTextBox

Le contrôle RichTextBox de WPF est assez puissant, et très pratique quand on a besoin d’accepter une saisie en texte riche. Cependant, l’une de ses fonctionnalités peut devenir problématique : l’utilisateur peut coller une image. Selon ce qu’on veut faire du texte saisi par l’utilisateur, ce n’est pas forcément souhaitable.

Quand j’ai cherché sur le web un moyen d’empêcher cela, les seules solutions que j’ai trouvées suggéraient d’intercepter la frappe de touches Ctrl-V, et de bloquer l’événement si le presse-papiers contient une image. Cette approche présente plusieurs problèmes:

  • elle n’empêche pas l’utilisateur de coller via le menu contextuel
  • elle ne fonctionne pas si le raccourci de la commande a été modifié
  • elle n’empêche pas l’utilisateur d’insérer une image par glisser-déposer

Puisque cette solution ne me convenait pas, j’ai utilisé le site .NET Framework Reference Source pour chercher un moyen d’intercepter l’opération de collage elle-même. J’ai suivi le code à partir de la propriété ApplicationCommands.Paste, et j’ai finalement trouvé l’événement attaché DataObject.Pasting. Ce n’est pas un endroit où j’aurais pensé à chercher, mais quand on y réfléchit, c’est finalement assez logique. Cet événement peut être utilisé pour intercepter une opération de collage ou de glisser-déposer, et permet au gestionnaire de l’événement de faire différentes choses:

  • annuler purement et simplement l’opération
  • changer le format de données qui sera collé depuis le presse-papiers
  • remplacer le DataObject utilisé pour le collage

Dans mon cas, je voulais juste empêcher le collage ou le glisser-déposer d’une image, donc j’ai simplement annulé l’opération quand le FormatToApply était "Bitmap", comme illustré ci-dessous.

XAML:

<RichTextBox DataObject.Pasting="RichTextBox1_Pasting" ... />

Code-behind:

private void RichTextBox1_Pasting(object sender, DataObjectPastingEventArgs e)
{
    if (e.FormatToApply == "Bitmap")
    {
        e.CancelCommand();
    }
}

Bien sûr, il est également possible de gérer ça plus intelligemment. Par exemple, si le DataObject contient plusieurs formats, on pourrait créer un nouveau DataObject avec uniquement les formats acceptables. Comme ça l’utilisateur peut encore coller quelque chose, tant que ce n’est pas une image.

[WPF] Déclarer des raccourcis clavier globaux en XAML avec NHotkey

Un besoin fréquent pour les applications de bureau est de gérer des raccourcis claviers globaux, pour pouvoir réagir aux raccourcis même quand l’application n’a pas le focus. Malheureusement, il n’y aucune fonctionnalité intégrée dans le .NET Framework pour gérer ça.

Bien sûr, le problème n’est pas nouveau, et il y a un certain nombre de librairies open-source qui se proposent d’y remédier (par exemple VirtualInput). La plupart d’entre elles sont basées sur des hooks système globaux, ce qui leur permet d’intercepter toutes les frappes de touche, même celles qui ne vous intéressent pas. J’ai déjà utilisé certaines de ces librairies, mais je n’en suis pas vraiment satisfait :

  • elles sont souvent liées à un framework IHM spécifique (généralement Windows Forms), ce qui les rend peu pratiques à utiliser avec un autre framework (comme WPF)
  • je n’aime pas trop l’approche consistant à intercepter toutes les frappes de touche. Cela a généralement pour conséquence d’écrire un grosse méthode avec un paquet de if/else if pour décider quoi faire en fonction de la combinaison de touches qui a été pressée

Une meilleure option, à mon sens, est d’écouter uniquement les touches qui vous intéressent, et de spécifier de façon déclarative l’action à effectuer pour chacune d’entre elles. L’approche utilisée en WPF pour les KeyBindings est assez élégante :

<Window.InputBindings>
    <KeyBinding Gesture="Ctrl+Alt+Add" Command="{Binding IncrementCommand}" />
    <KeyBinding Gesture="Ctrl+Alt+Subtract" Command="{Binding DecrementCommand}" />
</Window.InputBindings>

Mais bien sûr, les KeyBindings ne fonctionnent pas de façon globale, ils nécessitent que votre application ait le focus… Et si on pouvait changer ça ?

NHotkey est une librairie très simple pour gérer les raccourcis clavier, qui permet entre autres d’avoir des KeyBindings globaux. Il suffit de mettre à true une propriété attachée sur le KeyBinding :

<Window.InputBindings>
    <KeyBinding Gesture="Ctrl+Alt+Add" Command="{Binding IncrementCommand}"
                HotkeyManager.RegisterGlobalHotkey="True" />
    <KeyBinding Gesture="Ctrl+Alt+Subtract" Command="{Binding DecrementCommand}"
                HotkeyManager.RegisterGlobalHotkey="True" />
</Window.InputBindings>

Et c’est tout ; les commandes définies sur les KeyBindings seront maintenant invoquées même si votre application n’a pas le focus !

Vous pouvez aussi utiliser NHotkey depuis le code :

HotkeyManager.Current.AddOrReplace("Increment", Key.Add, ModifierKeys.Control | ModifierKeys.Alt, OnIncrement);
HotkeyManager.Current.AddOrReplace("Decrement", Key.Subtract, ModifierKeys.Control | ModifierKeys.Alt, OnDecrement);

La librairie tire partie de la fonction RegisterHotkey. Parce qu’elle supporte également Windows Forms, elle est constituée de 3 parties distinctes, afin de ne pas avoir à référencer l’assembly Windows Forms depuis une appli WPF, ou vice versa :

  • La librarie “core”, qui gère l’enregistrement des hotkeys proprement dit, indépendamment d’un framework IHM spécifique. Cette librairie n’est pas directement utilisable, mais elle est utilisée par les deux autres.
  • L’API spécifique à WinForms, qui utilise l’énumération Keys de System.Windows.Forms.
  • L’API spécifique à WPF, qui utilise les énumérations Key et ModifierKeys de System.Windows.Input, et supporte les KeyBindings globaux en XAML.

Si vous installez la librairie à l’aide de Nuget, ajoutez l’un ou l’autre des packages NHotkey.Wpf ou NHotkey.WindowsForms ; le package “core” sera automatiquement ajouté.

[WPF] Utiliser Linq pour filtrer, trier et grouper les données dans une CollectionView

WPF offre un mécanisme assez simple pour la mise en forme de collections de données, via l’interface ICollectionView et ses propriétés Filter, SortDescriptions et GroupDescriptions :

// Collection à laquelle la vue est liée
public ObservableCollection People { get; private set; }
...

// Vue par défaut de la collection People
ICollectionView view = CollectionViewSource.GetDefaultView(People);

// Uniquement les adultes
view.Filter = o => ((Person)o).Age >= 18;

// Tri par nom et prénom
view.SortDescriptions.Add(new SortDescription("LastName", ListSortDirection.Ascending));
view.SortDescriptions.Add(new SortDescription("FirstName", ListSortDirection.Ascending));

// Groupement par pays
view.GroupDescriptions.Add(new PropertyGroupDescription("Country"));

Bien que cette technique ne soit pas très difficile à mettre en œuvre, elle présente certains inconvénients :

  • La syntaxe un peu lourde et pas très naturelle : le fait que le paramètre du filtre soit un object alors qu’on sait que les éléments sont de type Person réduit la lisibilité, et l’ajout des tris et descriptions comporte beaucoup de répétitions
  • Le fait de spécifier les noms des propriétés sous forme de chaine introduit des risques d’erreur, puisqu’ils ne sont pas vérifiés par le compilateur.

Depuis quelques années, on a pris l’habitude d’utiliser Linq pour faire ce genre de choses… il serait donc pratique de pouvoir le faire aussi pour définir le filtre, le tri et le groupement d’une ICollectionView.

Voyons donc quelle syntaxe on pourrait utiliser pour faire ça avec Linq… quelque chose comme ça, par exemple ?

People.Where(p => p.Age >= 18)
      .OrderBy(p => p.LastName)
      .ThenBy(p => p.FirstName)
      .GroupBy(p => p.Country);

Ou encore, en utilisant la syntaxe de requête Linq :

from p in People
where p.Age >= 18
orderby p.LastName, p.FirstName
group p by p.Country;

Bon, évidemment ça ne suffit pas : ce code ne fait rien d’autre que créer une requête Linq sur la collection, il ne modifie pas la CollectionView… mais avec un tout petit peu de travail supplémentaire on peut obtenir le résultat voulu :

var query =
    from p in People.ShapeView()
    where p.Age >= 18
    orderby p.LastName, p.FirstName
    group p by p.Country;

query.Apply();

La méthode ShapeView renvoie un wrapper qui encapsule la vue par défaut de la collection, et expose des méthodes Where, OrderBy et GroupBy avec les signatures appropriées pour définir la mise en forme. Créer la requête n’a pas d’effet direct, c’est la méthode Apply qui permet d’appliquer les changements à la vue : en effet, il vaut mieux tous les appliquer en même temps à l’aide de ICollectionView.DeferRefresh, pour ne provoquer un rafraichissement de la vue à chaque nouvelle clause de la requête. Lors de l’appel à Apply, on observe que la vue est bien mise à jour pour refléter les clauses de la requête.

Cette solution permet de conserver le typage fort pour le filtrage, le tri et le groupement, avec pour bénéfice immédiat la vérification par le compilateur. C’est également plus concis et plus lisible que le code d’origine… Attention quand même à une chose : certaines requêtes qui seront correctes du point de vue de C# ne seront en fait pas applicables à une CollectionView. Par exemple, si vous essayez de grouper par la première lettre du nom (p.LastName.Substring(0, 1)), la méthode GroupBy échouera, car seules les propriétés sont supportées par PropertyGroupDescription.

Notez que le wrapper n’écrase pas les propriétés courantes de la CollectionView si vous ne spécifiez pas la clause Linq correspondante, il est donc possible de modifier une vue existante sans devoir tout spécifier à nouveau. Si nécessaire, des méthodes ClearFilter, ClearSort et ClearGrouping permettent de réinitialiser le filtre, le tri et le regroupement :

// Suppression du regroupement et ajout d'un tri :
People.ShapeView()
      .ClearGrouping()
      .OrderBy(p => p.LastName);
      .Apply();

Notez que comme pour une requête Linq “normale”, on peut au choix utiliser la syntaxe de requête ou appeler directement les méthodes, puisqu’il s’agit simplement d’une transformation syntaxique effectuée par le compilateur.

Pour finir, voici le code complet du wrapper et les méthodes d’extension associées :

    public static class CollectionViewShaper
    {
        public static CollectionViewShaper<TSource> ShapeView<TSource>(this IEnumerable<TSource> source)
        {
            var view = CollectionViewSource.GetDefaultView(source);
            return new CollectionViewShaper<TSource>(view);
        }

        public static CollectionViewShaper<TSource> Shape<TSource>(this ICollectionView view)
        {
            return new CollectionViewShaper<TSource>(view);
        }
    }

    public class CollectionViewShaper<TSource>
    {
        private readonly ICollectionView _view;
        private Predicate<object> _filter;
        private readonly List<SortDescription> _sortDescriptions = new List<SortDescription>();
        private readonly List<GroupDescription> _groupDescriptions = new List<GroupDescription>();

        public CollectionViewShaper(ICollectionView view)
        {
            if (view == null)
                throw new ArgumentNullException("view");
            _view = view;
            _filter = view.Filter;
            _sortDescriptions = view.SortDescriptions.ToList();
            _groupDescriptions = view.GroupDescriptions.ToList();
        }

        public void Apply()
        {
            using (_view.DeferRefresh())
            {
                _view.Filter = _filter;
                _view.SortDescriptions.Clear();
                foreach (var s in _sortDescriptions)
                {
                    _view.SortDescriptions.Add(s);
                }
                _view.GroupDescriptions.Clear();
                foreach (var g in _groupDescriptions)
                {
                    _view.GroupDescriptions.Add(g);
                }
            }
        }
            
        public CollectionViewShaper<TSource> ClearGrouping()
        {
            _groupDescriptions.Clear();
            return this;
        }

        public CollectionViewShaper<TSource> ClearSort()
        {
            _sortDescriptions.Clear();
            return this;
        }

        public CollectionViewShaper<TSource> ClearFilter()
        {
            _filter = null;
            return this;
        }

        public CollectionViewShaper<TSource> ClearAll()
        {
            _filter = null;
            _sortDescriptions.Clear();
            _groupDescriptions.Clear();
            return this;
        }

        public CollectionViewShaper<TSource> Where(Func<TSource, bool> predicate)
        {
            _filter = o => predicate((TSource)o);
            return this;
        }

        public CollectionViewShaper<TSource> OrderBy<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            return OrderBy(keySelector, true, ListSortDirection.Ascending);
        }

        public CollectionViewShaper<TSource> OrderByDescending<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            return OrderBy(keySelector, true, ListSortDirection.Descending);
        }

        public CollectionViewShaper<TSource> ThenBy<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            return OrderBy(keySelector, false, ListSortDirection.Ascending);
        }

        public CollectionViewShaper<TSource> ThenByDescending<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            return OrderBy(keySelector, false, ListSortDirection.Descending);
        }

        private CollectionViewShaper<TSource> OrderBy<TKey>(Expression<Func<TSource, TKey>> keySelector, bool clear, ListSortDirection direction)
        {
            string path = GetPropertyPath(keySelector.Body);
            if (clear)
                _sortDescriptions.Clear();
            _sortDescriptions.Add(new SortDescription(path, direction));
            return this;
        }

        public CollectionViewShaper<TSource> GroupBy<TKey>(Expression<Func<TSource, TKey>> keySelector)
        {
            string path = GetPropertyPath(keySelector.Body);
            _groupDescriptions.Add(new PropertyGroupDescription(path));
            return this;
        }

        private static string GetPropertyPath(Expression expression)
        {
            var names = new Stack<string>();
            var expr = expression;
            while (expr != null && !(expr is ParameterExpression) && !(expr is ConstantExpression))
            {
                var memberExpr = expr as MemberExpression;
                if (memberExpr == null)
                    throw new ArgumentException("The selector body must contain only property or field access expressions");
                names.Push(memberExpr.Member.Name);
                expr = memberExpr.Expression;
            }
            return String.Join(".", names.ToArray());
        }
    }

[WPF] Créer des styles paramétrables à l’aide des propriétés attachées

Je voudrais aujourd’hui partager avec vous une petite astuce que j’utilise souvent depuis quelques mois. Supposons que pour améliorer l’apparence de votre application, vous ayez créé des styles personnalisés pour les contrôles standards :

Bon, je ne suis pas designer, hein… mais ça fera parfaitement l’affaire pour illustrer mon propos ;). Ces styles sont très simples, ce sont les styles par défaut des CheckBox et RadioButton dans lesquels j’ai seulement modifié les templates pour remplacer les BulletChrome par ces superbes marques bleues. Voilà le code :

        <Style x:Key="{x:Type CheckBox}" TargetType="{x:Type CheckBox}">
            <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
            <Setter Property="Background" Value="{StaticResource CheckBoxFillNormal}"/>
            <Setter Property="BorderBrush" Value="{StaticResource CheckBoxStroke}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="FocusVisualStyle" Value="{StaticResource EmptyCheckBoxFocusVisual}"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type CheckBox}">
                        <BulletDecorator Background="Transparent"
                                         SnapsToDevicePixels="true">
                            <BulletDecorator.Bullet>
                                <Border BorderBrush="{TemplateBinding BorderBrush}"
                                        Background="{TemplateBinding Background}"
                                        BorderThickness="1"
                                        Width="11" Height="11" Margin="0,1,0,0">
                                    <Grid>
                                        <Path Name="TickMark"
                                              Fill="Blue"
                                              Data="M0,4 5,9 9,0 4,5"
                                              Visibility="Hidden" />
                                        <Rectangle Name="IndeterminateMark"
                                                   Fill="Blue"
                                                   Width="7" Height="7"
                                                   HorizontalAlignment="Center"
                                                   VerticalAlignment="Center"
                                                   Visibility="Hidden" />
                                    </Grid>
                                </Border>
                            </BulletDecorator.Bullet>
                            <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                              Margin="{TemplateBinding Padding}"
                                              RecognizesAccessKey="True"
                                              SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                                              VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
                        </BulletDecorator>
                        <ControlTemplate.Triggers>
                            <Trigger Property="HasContent" Value="true">
                                <Setter Property="FocusVisualStyle" Value="{StaticResource CheckRadioFocusVisual}"/>
                                <Setter Property="Padding" Value="4,0,0,0"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="false">
                                <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}"/>
                            </Trigger>
                            <Trigger Property="IsChecked" Value="True">
                                <Setter TargetName="TickMark" Property="Visibility" Value="Visible" />
                            </Trigger>
                            <Trigger Property="IsChecked" Value="{x:Null}">
                                <Setter TargetName="IndeterminateMark" Property="Visibility" Value="Visible" />
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style x:Key="{x:Type RadioButton}" TargetType="{x:Type RadioButton}">
            <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
            <Setter Property="Background" Value="#F4F4F4"/>
            <Setter Property="BorderBrush" Value="{StaticResource CheckBoxStroke}"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type RadioButton}">
                        <BulletDecorator Background="Transparent">
                            <BulletDecorator.Bullet>
                                <Grid VerticalAlignment="Center" Margin="0,1,0,0">
                                    <Ellipse Width="11" Height="11"
                                             Stroke="{TemplateBinding BorderBrush}"
                                             StrokeThickness="1"
                                             Fill="{TemplateBinding Background}" />
                                    <Ellipse Name="TickMark"
                                             Width="7" Height="7"
                                             Fill="Blue"
                                             Visibility="Hidden" />
                                    <Ellipse Name="IndeterminateMark"
                                             Width="3" Height="3"
                                             Fill="Blue"
                                             Visibility="Hidden" />
                                </Grid>
                            </BulletDecorator.Bullet>
                            <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                              Margin="{TemplateBinding Padding}"
                                              RecognizesAccessKey="True"
                                              VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
                        </BulletDecorator>
                        <ControlTemplate.Triggers>
                            <Trigger Property="HasContent" Value="true">
                                <Setter Property="FocusVisualStyle" Value="{StaticResource CheckRadioFocusVisual}"/>
                                <Setter Property="Padding" Value="4,0,0,0"/>
                            </Trigger>
                            <Trigger Property="IsEnabled" Value="false">
                                <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}"/>
                            </Trigger>
                            <Trigger Property="IsChecked" Value="True">
                                <Setter TargetName="TickMark" Property="Visibility" Value="Visible" />
                            </Trigger>
                            <Trigger Property="IsChecked" Value="{x:Null}">
                                <Setter TargetName="IndeterminateMark" Property="Visibility" Value="Visible" />
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>

Vous avez donc maintenant de magnifiques contrôles qui vont faire de l’application un grand succès, le management est content, tout va pour le mieux dans le meilleur des mondes… Et là, vous réalisez que dans un autre écran de l’application, les contrôles doivent avoir le même style, mais en vert !

La première solution qui vient à l’esprit est de dupliquer le style en mettant du vert à la place du bleu. Mais comme vous êtes un bon développeur soucieux des bonnes pratiques, vous savez que la duplication de code, c’est mal : si vous devez un jour modifier le style de la CheckBox bleue, il faudra aussi modifier celui de la verte… et peut-être aussi la rouge, la noire, etc. Bref, ça deviendrait vite ingérable. Il faut donc refactoriser, mais comment ? Il faudrait pouvoir passer la couleur en paramètre du style, mais un style n’est pas une méthode à laquelle on peut passer des paramètres…

Il faudrait donc avoir une propriété supplémentaire pour indiquer la couleur des “ticks”, et se binder sur cette propriété dans le template. Une approche possible est de créer des contrôles personnalisés hérités de CheckBox et RadioButton, avec une propriété supplémentaire TickBrush… mais personnellement je n’aime pas beaucoup cette approche : je préfère éviter de créer de nouveaux contrôles quand on peut s’en sortir avec les contrôles standard.

En fait, il y a une solution plus simple : il suffit de créer une classe, qu’on appelle par exemple ThemeProperties, et de déclarer dedans une propriété attachée de type Brush:

    public static class ThemeProperties
    {
        public static Brush GetTickBrush(DependencyObject obj)
        {
            return (Brush)obj.GetValue(TickBrushProperty);
        }

        public static void SetTickBrush(DependencyObject obj, Brush value)
        {
            obj.SetValue(TickBrushProperty, value);
        }

        public static readonly DependencyProperty TickBrushProperty =
            DependencyProperty.RegisterAttached(
                "TickBrush",
                typeof(Brush),
                typeof(ThemeProperties),
                new FrameworkPropertyMetadata(Brushes.Black));
    }

On modifie un peu nos templates pour remplacer la couleur en dur par un binding sur cette propriété :

                                ...

                                <!-- CheckBox -->
                                        <Path Name="TickMark"
                                              Fill="{TemplateBinding my:ThemeProperties.TickBrush}"
                                              Data="M0,4 5,9 9,0 4,5"
                                              Visibility="Hidden" />
                                        <Rectangle Name="IndeterminateMark"
                                                   Fill="{TemplateBinding my:ThemeProperties.TickBrush}"
                                                   Width="7" Height="7"
                                                   HorizontalAlignment="Center"
                                                   VerticalAlignment="Center"
                                                   Visibility="Hidden" />

                                ...

                                <!-- RadioButton -->
                                    <Ellipse Name="TickMark"
                                             Width="7" Height="7"
                                             Fill="{TemplateBinding my:ThemeProperties.TickBrush}"
                                             Visibility="Hidden" />
                                    <Ellipse Name="IndeterminateMark"
                                             Width="3" Height="3"
                                             Fill="{TemplateBinding my:ThemeProperties.TickBrush}"
                                             Visibility="Hidden" />

Et quand on utilise les contrôles, on précise la couleur qu’on veut pour le tick :

<CheckBox Content="Checked" IsChecked="True" my:ThemeProperties.TickBrush="Blue" />

On peut donc maintenant avoir des contrôles qui partagent le même style, mais en changeant la couleur d’un élément du template :

On a donc effectivement rendu les styles paramétrables ! Il reste cependant un petit souci : étant donné que tous les contrôles d’un même éran utilisent tous la même couleur, il n’est pas très pratique de devoir la répéter sur chaque contrôle. L’idéal serait de pouvoir indiquer à la racine de la vue la couleur à utiliser pour tous les contrôles… et justement, les dependency properties (et donc les propriétés attachées) offrent une fonctionnalité qui permet de faire exactement ça : l’héritage de valeur. Il suffit d’indiquer le flag Inherits lors de la déclaration de la propriété TickBrush :

        public static readonly DependencyProperty TickBrushProperty =
            DependencyProperty.RegisterAttached(
                "TickBrush",
                typeof(Brush),
                typeof(ThemeProperties),
                new FrameworkPropertyMetadata(Brushes.Black, FrameworkPropertyMetadataOptions.Inherits));

Avec cette modification, la propriété devient “ambiante” : il suffit d’indiquer sa valeur sur un contrôle parent (par exemple la racine de la vue) pour que tous les descendants prennent en compte cette valeur. On peut donc très facilement faire des écrans avec des contrôles qui partagent le même style, mais en appliquant des couleurs différentes.

Le concept peut bien sûr être étendu à d’autres cas : en fait, dès qu’un élément du template doit varier selon un critère arbitraire, cette technique peut s’appliquer. Cela évite bien souvent de devoir dupliquer le template pour ne changer qu’un petit détail.

[WPF 4.5] Abonnement à un évènement à l’aide d’une markup extension

Voilà un certain temps que je n’avais plus parlé des markup extensions… J’y reviens à l’occasion de la sortie de Visual Studio 11 Developer Preview, qui introduit un certain nombre de nouveautés dans WPF. La nouveauté dont je vais parler n’est sans doute pas la plus spectaculaire, mais elle vient combler un manque des versions précédentes : le support des markup extensions pour les évènements.

Jusqu’ici, il était possible d’utiliser une markup extension en XAML pour affecter une valeur à une propriété, mais on ne pouvait pas faire la même chose pour s’abonner à un évènement. Dans WPF 4.5, c’est désormais possible. Voilà donc un petit exemple de ce que cela permet de faire…

Quand on utilise le pattern MVVM, on associe souvent des commandes du ViewModel à des contrôles de la vue, via le mécanisme de binding. Cette approche fonctionne généralement assez bien, mais elle présente certains inconvénients :

  • cela introduit beaucoup de code de “plomberie” dans le ViewModel
  • tous les contrôles n’ont pas une propriété Command (en fait, la plupart ne l’ont pas), et quand cette propriété existe, elle ne correspond qu’à un seul évènement du contrôle (par exemple le clic sur un bouton). Il n’y a pas de moyen vraiment simple de relier les autres évènements à des commandes du ViewModel.

Il serait plus pratique de pouvoir lier directement l’évènement à une méthode du ViewModel de la façon suivante:

        <Button Content="Click me"
                Click="{my:EventBinding OnClick}" />

Avec la méthode OnClick définie dans le ViewModel:

        public void OnClick(object sender, EventArgs e)
        {
            MessageBox.Show("Hello world!");
        }

Eh bien cela est désormais possible ! Voilà donc une petite preuve de concept… La classe EventBindingExtension présentée ci-dessous obtient d’abord le DataContext du contrôle, puis recherche la méthode spécifiée dans le DataContext, et renvoie un delegate pour cette méthode:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Windows;
using System.Windows.Markup;


    public class EventBindingExtension : MarkupExtension
    {
        public EventBindingExtension() { }

        public EventBindingExtension(string eventHandlerName)
        {
            this.EventHandlerName = eventHandlerName;
        }

        [ConstructorArgument("eventHandlerName")]
        public string EventHandlerName { get; set; }

        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            if (string.IsNullOrEmpty(EventHandlerName))
                throw new ArgumentException("The EventHandlerName property is not set", "EventHandlerName");

            var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));

            EventInfo eventInfo = target.TargetProperty as EventInfo;
            if (eventInfo == null)
                throw new InvalidOperationException("The target property must be an event");
            
            object dataContext = GetDataContext(target.TargetObject);
            if (dataContext == null)
                throw new InvalidOperationException("No DataContext found");

            var handler = GetHandler(dataContext, eventInfo, EventHandlerName);
            if (handler == null)
                throw new ArgumentException("No valid event handler was found", "EventHandlerName");

            return handler;
        }

        #region Helper methods

        static object GetHandler(object dataContext, EventInfo eventInfo, string eventHandlerName)
        {
            Type dcType = dataContext.GetType();

            var method = dcType.GetMethod(
                eventHandlerName,
                GetParameterTypes(eventInfo));
            if (method != null)
            {
                if (method.IsStatic)
                    return Delegate.CreateDelegate(eventInfo.EventHandlerType, method);
                else
                    return Delegate.CreateDelegate(eventInfo.EventHandlerType, dataContext, method);
            }

            return null;
        }

        static Type[] GetParameterTypes(EventInfo eventInfo)
        {
            var invokeMethod = eventInfo.EventHandlerType.GetMethod("Invoke");
            return invokeMethod.GetParameters().Select(p => p.ParameterType).ToArray();
        }

        static object GetDataContext(object target)
        {
            var depObj = target as DependencyObject;
            if (depObj == null)
                return null;

            return depObj.GetValue(FrameworkElement.DataContextProperty)
                ?? depObj.GetValue(FrameworkContentElement.DataContextProperty);
        }

        #endregion
    }

Cette classe est utilisable comme dans l’exemple présenté plus haut.

En l’état, cette markup extension présente une limitation un peu gênante : le DataContext doit être défini avant l’appel à ProvideValue, sinon il n’est pas possible de trouver la méthode qui gère l’évènement. Une solution pourrait être de s’abonner à l’évènement DataContextChanged pour s’abonner à l’évènement plus tard, mais en attendant il faut quand même renvoyer une valeur… et si on renvoie null, on obtient une exception car on ne peut pas s’abonner à un évènement avec un handler null. Il faudrait donc renvoyer un handler “bidon” généré dynamiquement en fonction de la signature de l’évènement. Voilà qui complique un peu les choses… mais ça reste faisable.

Voici une deuxième version qui implémente cette amélioration :

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using System.Windows;
using System.Windows.Markup;

    public class EventBindingExtension : MarkupExtension
    {
        private EventInfo _eventInfo;

        public EventBindingExtension() { }

        public EventBindingExtension(string eventHandlerName)
        {
            this.EventHandlerName = eventHandlerName;
        }

        [ConstructorArgument("eventHandlerName")]
        public string EventHandlerName { get; set; }

        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            if (string.IsNullOrEmpty(EventHandlerName))
                throw new ArgumentException("The EventHandlerName property is not set", "EventHandlerName");

            var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));

            var targetObj = target.TargetObject as DependencyObject;
            if (targetObj == null)
                throw new InvalidOperationException("The target object must be a DependencyObject");

            _eventInfo = target.TargetProperty as EventInfo;
            if (_eventInfo == null)
                throw new InvalidOperationException("The target property must be an event");

            object dataContext = GetDataContext(targetObj);
            if (dataContext == null)
            {
                SubscribeToDataContextChanged(targetObj);
                return GetDummyHandler(_eventInfo.EventHandlerType);
            }

            var handler = GetHandler(dataContext, _eventInfo, EventHandlerName);
            if (handler == null)
            {
                Trace.TraceError(
                    "EventBinding: no suitable method named '{0}' found in type '{1}' to handle event '{2'}",
                    EventHandlerName,
                    dataContext.GetType(),
                    _eventInfo);
                return GetDummyHandler(_eventInfo.EventHandlerType);
            }

            return handler;
            
        }

        #region Helper methods

        static Delegate GetHandler(object dataContext, EventInfo eventInfo, string eventHandlerName)
        {
            Type dcType = dataContext.GetType();

            var method = dcType.GetMethod(
                eventHandlerName,
                GetParameterTypes(eventInfo.EventHandlerType));
            if (method != null)
            {
                if (method.IsStatic)
                    return Delegate.CreateDelegate(eventInfo.EventHandlerType, method);
                else
                    return Delegate.CreateDelegate(eventInfo.EventHandlerType, dataContext, method);
            }

            return null;
        }

        static Type[] GetParameterTypes(Type delegateType)
        {
            var invokeMethod = delegateType.GetMethod("Invoke");
            return invokeMethod.GetParameters().Select(p => p.ParameterType).ToArray();
        }

        static object GetDataContext(DependencyObject target)
        {
            return target.GetValue(FrameworkElement.DataContextProperty)
                ?? target.GetValue(FrameworkContentElement.DataContextProperty);
        }

        static readonly Dictionary<Type, Delegate> _dummyHandlers = new Dictionary<Type, Delegate>();

        static Delegate GetDummyHandler(Type eventHandlerType)
        {
            Delegate handler;
            if (!_dummyHandlers.TryGetValue(eventHandlerType, out handler))
            {
                handler = CreateDummyHandler(eventHandlerType);
                _dummyHandlers[eventHandlerType] = handler;
            }
            return handler;
        }

        static Delegate CreateDummyHandler(Type eventHandlerType)
        {
            var parameterTypes = GetParameterTypes(eventHandlerType);
            var returnType = eventHandlerType.GetMethod("Invoke").ReturnType;
            var dm = new DynamicMethod("DummyHandler", returnType, parameterTypes);
            var il = dm.GetILGenerator();
            if (returnType != typeof(void))
            {
                if (returnType.IsValueType)
                {
                    var local = il.DeclareLocal(returnType);
                    il.Emit(OpCodes.Ldloca_S, local);
                    il.Emit(OpCodes.Initobj, returnType);
                    il.Emit(OpCodes.Ldloc_0);
                }
                else
                {
                    il.Emit(OpCodes.Ldnull);
                }
            }
            il.Emit(OpCodes.Ret);
            return dm.CreateDelegate(eventHandlerType);
        }

        private void SubscribeToDataContextChanged(DependencyObject targetObj)
        {
            DependencyPropertyDescriptor
                .FromProperty(FrameworkElement.DataContextProperty, targetObj.GetType())
                .AddValueChanged(targetObj, TargetObject_DataContextChanged);
        }

        private void UnsubscribeFromDataContextChanged(DependencyObject targetObj)
        {
            DependencyPropertyDescriptor
                .FromProperty(FrameworkElement.DataContextProperty, targetObj.GetType())
                .RemoveValueChanged(targetObj, TargetObject_DataContextChanged);
        }

        private void TargetObject_DataContextChanged(object sender, EventArgs e)
        {
            DependencyObject targetObj = sender as DependencyObject;
            if (targetObj == null)
                return;

            object dataContext = GetDataContext(targetObj);
            if (dataContext == null)
                return;

            var handler = GetHandler(dataContext, _eventInfo, EventHandlerName);
            if (handler != null)
            {
                _eventInfo.AddEventHandler(targetObj, handler);
            }
            UnsubscribeFromDataContextChanged(targetObj);
        }

        #endregion
    }

Voilà donc un exemple du genre de choses qu’on peut faire grâce à cette nouvelle fonctionnalité de WPF. On pourrait aussi imaginer un système de “behavior” similaire à ce qu’on peut faire avec des propriétés attachées, par exemple pour réaliser une action standard lorsqu’un évènement se produit. Les possiblités sont sans doute nombreuses, je vous laisse le soin de les trouver 😉

[WPF] Afficher une image GIF animée

WPF est une technologie géniale, mais parfois on a l’impression qu’il lui manque certaines fonctionnalités assez basiques… Un exemple souvent cité est l’absence de support pour les images GIF animées. En fait, le format GIF proprement dit est supporté, mais le contrôle Image n’affiche que la première image de l’animation.

De nombreuses solutions à ce problème ont été proposées sur les forums et blogs techniques, généralement des variantes autour des approches suivantes :

  • Utiliser le contrôle MediaElement : malheureusement ce contrôle ne supporte que les URI de type file:// ou http://, et non le schéma d’URI pack:// utilisé pour les ressources WPF ; l’image ne peut donc pas être inclue dans les ressources, elle doit être dans un fichier à part. De plus, la transparence n’est pas supportée, si bien que le résultat final est assez laid
  • Utiliser le contrôle PictureBox de Windows Forms, via un WindowsFormsHost : personnellement j’ai horreur d’utiliser des contrôles Windows Forms en WPF, ça me donne l’impression de faire quelque chose de mal 😛
  • Créer un contrôle dérivé de Image qui gère l’animation. Pour l’implémentation, certaines solutions tirent partie de la classe ImageAnimator de System.Drawing (GDI), d’autres utilisent une animation WPF pour changer de frame. C’est une approche assez “propre”, mais qui oblige à utiliser un contrôle spécifique pour les GIF. De plus la solution utilisant ImageAnimator se révèle assez peu fluide.

Comme vous l’aurez deviné, aucune de ces solutions ne me satisfait vraiment… De plus, aucune ne gère proprement la durée de chaque frame, et suppose simplement que toutes les frames durent 100ms (c’est presque toujours le cas, mais le presque fait toute la différence…). Je n’ai donc gardé que les meilleures idées dans les approches ci-dessus pour créer ma propre solution. Les objectifs que je souhaitais atteindre sont les suivants :

  • Ne pas dépendre de Windows Forms ou de GDI
  • Afficher l’image animée dans un contrôle Image standard
  • Pouvoir utiliser le même code XAML pour une image fixe ou animée
  • Supporter la transparence
  • Tenir compte de la durée réelle de chaque frame de l’image

Pour arriver à ce résultat, je suis parti d’une idée simple, voire évidente : pour animer l’image, il suffit d’appliquer une animation à la propriété Source du contrôle Image. Or WPF fournit tous les outils nécessaires pour réaliser ce type d’animation ; en l’occurrence la classe ObjectAnimationUsingKeyFrames répond parfaitement au besoin : on peut spécifier à quel instant exact affecter une valeur donnée à la propriété, ce qui permet de tenir compte de la durée des frames.

Le problème suivant est d’extraire les différentes frames de l’image : heureusement ce scénario est prévu dans WPF, et la classe BitmapDecoder fournit une propriété Frames qui sert à ça. Donc, pas de difficulté majeure à ce niveau…

Enfin, dernier obstacle : extraire la durée de chaque frame. C’est finalement la partie qui m’a demandé le plus de recherche… J’ai d’abord cru qu’il faudrait lire manuellement le fichier pour trouver cette information, en décodant directement les données binaires. Mais la solution est finalement assez simple, et tire partie de la classe BitmapMetadata. La seule difficulté a été de localiser le “chemin” de la métadonnée qui contient cette information, mais après quelques tâtonnements, la voilà : /grctlext/Delay.

La solution finale est implémentée sous forme d’une propriété attachée AnimatedSource applicable au contrôle Image, qui s’utilise en lieu et place de Source :

<Image Stretch="None" my:ImageBehavior.AnimatedSource="/Images/animation.gif" />

On peut également affecter une image fixe à cette propriété, elle s’affichera normalement ; on peut donc utiliser cette propriété sans se soucier de savoir si l’image à afficher sera fixe ou animée.

Au final, tous les objectifs fixés au départ sont donc atteints, et il y a même une cerise sur le gâteau : cette solution fonctionne également dans le designer (du moins dans Visual Studio 2010), on voit donc directement l’animation quand on affecte la propriété AnimatedSource 🙂

Sans plus attendre, voilà le code complet :

    public static class ImageBehavior
    {
        #region AnimatedSource

        [AttachedPropertyBrowsableForType(typeof(Image))]
        public static ImageSource GetAnimatedSource(Image obj)
        {
            return (ImageSource)obj.GetValue(AnimatedSourceProperty);
        }

        public static void SetAnimatedSource(Image obj, ImageSource value)
        {
            obj.SetValue(AnimatedSourceProperty, value);
        }

        public static readonly DependencyProperty AnimatedSourceProperty =
            DependencyProperty.RegisterAttached(
              "AnimatedSource",
              typeof(ImageSource),
              typeof(ImageBehavior),
              new UIPropertyMetadata(
                null,
                AnimatedSourceChanged));

        private static void AnimatedSourceChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
        {
            Image imageControl = o as Image;
            if (imageControl == null)
                return;

            var oldValue = e.OldValue as ImageSource;
            var newValue = e.NewValue as ImageSource;
            if (oldValue != null)
            {
                imageControl.BeginAnimation(Image.SourceProperty, null);
            }
            if (newValue != null)
            {
                imageControl.DoWhenLoaded(InitAnimationOrImage);
            }
        }

        private static void InitAnimationOrImage(Image imageControl)
        {
            BitmapSource source = GetAnimatedSource(imageControl) as BitmapSource;
            if (source != null)
            {
                var decoder = GetDecoder(source) as GifBitmapDecoder;
                if (decoder != null && decoder.Frames.Count > 1)
                {
                    var animation = new ObjectAnimationUsingKeyFrames();
                    var totalDuration = TimeSpan.Zero;
                    BitmapSource prevFrame = null;
                    FrameInfo prevInfo = null;
                    foreach (var rawFrame in decoder.Frames)
                    {
                        var info = GetFrameInfo(rawFrame);
                        var frame = MakeFrame(
                            source,
                            rawFrame, info,
                            prevFrame, prevInfo);

                        var keyFrame = new DiscreteObjectKeyFrame(frame, totalDuration);
                        animation.KeyFrames.Add(keyFrame);
                        
                        totalDuration += info.Delay;
                        prevFrame = frame;
                        prevInfo = info;
                    }
                    animation.Duration = totalDuration;
                    animation.RepeatBehavior = RepeatBehavior.Forever;
                    if (animation.KeyFrames.Count > 0)
                        imageControl.Source = (ImageSource)animation.KeyFrames[0].Value;
                    else
                        imageControl.Source = decoder.Frames[0];
                    imageControl.BeginAnimation(Image.SourceProperty, animation);
                    return;
                }
            }
            imageControl.Source = source;
            return;
        }

        private static BitmapDecoder GetDecoder(BitmapSource image)
        {
            BitmapDecoder decoder = null;
            var frame = image as BitmapFrame;
            if (frame != null)
                decoder = frame.Decoder;

            if (decoder == null)
            {
                var bmp = image as BitmapImage;
                if (bmp != null)
                {
                    if (bmp.StreamSource != null)
                    {
                        decoder = BitmapDecoder.Create(bmp.StreamSource, bmp.CreateOptions, bmp.CacheOption);
                    }
                    else if (bmp.UriSource != null)
                    {
                        Uri uri = bmp.UriSource;
                        if (bmp.BaseUri != null && !uri.IsAbsoluteUri)
                            uri = new Uri(bmp.BaseUri, uri);
                        decoder = BitmapDecoder.Create(uri, bmp.CreateOptions, bmp.CacheOption);
                    }
                }
            }

            return decoder;
        }

        private static BitmapSource MakeFrame(
            BitmapSource fullImage,
            BitmapSource rawFrame, FrameInfo frameInfo,
            BitmapSource previousFrame, FrameInfo previousFrameInfo)
        {
            DrawingVisual visual = new DrawingVisual();
            using (var context = visual.RenderOpen())
            {
                if (previousFrameInfo != null && previousFrame != null &&
                    previousFrameInfo.DisposalMethod == FrameDisposalMethod.Combine)
                {
                    var fullRect = new Rect(0, 0, fullImage.PixelWidth, fullImage.PixelHeight);
                    context.DrawImage(previousFrame, fullRect);
                }

                context.DrawImage(rawFrame, frameInfo.Rect);
            }
            var bitmap = new RenderTargetBitmap(
                fullImage.PixelWidth, fullImage.PixelHeight,
                fullImage.DpiX, fullImage.DpiY,
                PixelFormats.Pbgra32);
            bitmap.Render(visual);
            return bitmap;
        }

        private class FrameInfo
        {
            public TimeSpan Delay { get; set; }
            public FrameDisposalMethod DisposalMethod { get; set; }
            public double Width { get; set; }
            public double Height { get; set; }
            public double Left { get; set; }
            public double Top { get; set; }

            public Rect Rect
            {
                get { return new Rect(Left, Top, Width, Height); }
            }
        }

        private enum FrameDisposalMethod
        {
            Replace = 0,
            Combine = 1,
            RestoreBackground = 2,
            RestorePrevious = 3
        }

        private static FrameInfo GetFrameInfo(BitmapFrame frame)
        {
            var frameInfo = new FrameInfo
            {
                Delay = TimeSpan.FromMilliseconds(100),
                DisposalMethod = FrameDisposalMethod.Replace,
                Width = frame.PixelWidth,
                Height = frame.PixelHeight,
                Left = 0,
                Top = 0
            };

            BitmapMetadata metadata;
            try
            {
                metadata = frame.Metadata as BitmapMetadata;
                if (metadata != null)
                {
                    const string delayQuery = "/grctlext/Delay";
                    const string disposalQuery = "/grctlext/Disposal";
                    const string widthQuery = "/imgdesc/Width";
                    const string heightQuery = "/imgdesc/Height";
                    const string leftQuery = "/imgdesc/Left";
                    const string topQuery = "/imgdesc/Top";

                    var delay = metadata.GetQueryOrNull<ushort>(delayQuery);
                    if (delay.HasValue)
                        frameInfo.Delay = TimeSpan.FromMilliseconds(10 * delay.Value);

                    var disposal = metadata.GetQueryOrNull<byte>(disposalQuery);
                    if (disposal.HasValue)
                        frameInfo.DisposalMethod = (FrameDisposalMethod) disposal.Value;

                    var width = metadata.GetQueryOrNull<ushort>(widthQuery);
                    if (width.HasValue)
                        frameInfo.Width = width.Value;

                    var height = metadata.GetQueryOrNull<ushort>(heightQuery);
                    if (height.HasValue)
                        frameInfo.Height = height.Value;

                    var left = metadata.GetQueryOrNull<ushort>(leftQuery);
                    if (left.HasValue)
                        frameInfo.Left = left.Value;

                    var top = metadata.GetQueryOrNull<ushort>(topQuery);
                    if (top.HasValue)
                        frameInfo.Top = top.Value;
                }
            }
            catch (NotSupportedException)
            {
            }

            return frameInfo;
        }

        private static T? GetQueryOrNull<T>(this BitmapMetadata metadata, string query)
            where T : struct
        {
            if (metadata.ContainsQuery(query))
            {
                object value = metadata.GetQuery(query);
                if (value != null)
                    return (T) value;
            }
            return null;
        }

        #endregion
    }

Et voici la méthode d’extension DoWhenLoaded utilisée dans le code ci-dessus :

public static void DoWhenLoaded<T>(this T element, Action<T> action)
    where T : FrameworkElement
{
    if (element.IsLoaded)
    {
        action(element);
    }
    else
    {
        RoutedEventHandler handler = null;
        handler = (sender, e) =>
        {
            element.Loaded -= handler;
            action(element);
        };
        element.Loaded += handler;
    }
}

Cette classe sera inclue dans la prochaine version de la librairie Dvp.NET, dont j’avais déjà parlé il y quelque temps.

Mise à jour : le code qui récupère la durée d’une frame ne fonctionne que sous Windows Seven, et sous Windows Vista si la Platform Update est installée (non testé). La durée par défaut (100ms) sera utilisée sur les autres versions de Windows. Je mettrai à jour l’article si je trouve une solution qui fonctionne sur tous les systèmes (je sais que je pourrais utiliser System.Drawing.Bitmap, mais je préfèrerais éviter…)

Mise à jour 2 : comme Klaus l’a signalé dans un commentaire sur la version anglaise de mon blog, la classe ImageBehavior ne gérait pas certains attributs importants des frames : la méthode de destruction (est-ce qu’une frame doit simplement remplacer la frame précédente, ou être combinée avec elle), et la position des frames (Left/Top/Width/Height). J’ai mis à jour le code pour gérer ces attributs correctement. Merci Klaus !

Mise à jour 3 : encore un petit bug corrigé, la récupération du décodeur à partir d’une URI relative ne fonctionnait pas. Merci à l’anonyme qui l’a signalé!

Mise à jour 4 : plutôt que de continuer à poster les améliorations sur ce billet, j’ai finalement créé un projet sur CodePlex GitHub où cette classe sera maintenue. Vous pouvez aussi l’installer avec NuGet, l’id du package est WpfAnimatedGif. Merci à Diego Mijelshon pour la suggestion!

[WPF] Comment faire un binding dans les cas où on n’hérite pas du DataContext

La propriété DataContext de WPF est extrêmement pratique, car elle est automatiquement héritée par tous les enfants de l’élément où elle est définie ; il n’est donc pas nécessaire de la redéfinir pour chaque élément qu’on veut lier aux données. Cependant, il arrive que le DataContext ne soit pas accessible pour certains éléments : c’est le cas des éléments qui n’appartiennent pas à l’arbre visuel ni à l’arbre logique. Il devient alors très difficile de définir une propriété ce ces éléments par un binding…

Prenons un exemple simple : on veut afficher une liste de produits dans un DataGrid. Dans la grille, on veut pouvoir afficher où masquer la colonne du prix, en fonction d’une propriété ShowPrice exposée par le ViewModel. L’approche évidente consiste à binder la propriété Visibility de la colonne à la propriété ShowPrice :

<DataGridTextColumn Header="Price" Binding="{Binding Price}" IsReadOnly="False"
                    Visibility="{Binding ShowPrice,
                        Converter={StaticResource visibilityConverter}}"/>

Malheureusement, changer la valeur de la propriété ShowPrice n’a aucun effet, et la colonne reste toujours affichée… pourquoi ? Si on regarde la fenêtre de sortie de Visual Studio, on remarque la ligne suivante :

System.Windows.Data Error: 2 : Cannot find governing FrameworkElement or FrameworkContentElement for target element. BindingExpression:Path=ShowPrice; DataItem=null; target element is ‘DataGridTextColumn’ (HashCode=32685253); target property is ‘Visibility’ (type ‘Visibility’)

Derrière cet obscur charabia se cache une explication toute simple : WPF ne sait pas quel FrameworkElement utiliser pour récupérer le DataContext, car la colonne n’appartient pas à l’arbre visuel ni à l’arbre logique du DataGrid.

On peut toujours essayer de “triturer” le binding pour obtenir le résultat voulu, par exemple en essayant de binder par rapport au DataGrid lui-même :

<DataGridTextColumn Header="Price" Binding="{Binding Price}" IsReadOnly="False"
                    Visibility="{Binding DataContext.ShowPrice,
                        Converter={StaticResource visibilityConverter},
                        RelativeSource={RelativeSource FindAncestor, AncestorType=DataGrid}}"/>

Ou encore, en ajoutant une CheckBox bindée sur ShowPrice et en essayant de binder la visibilité de la colonne sur la propriété IsChecked, en spécifiant le nom de l’élément :

<DataGridTextColumn Header="Price" Binding="{Binding Price}" IsReadOnly="False"
                    Visibility="{Binding IsChecked,
                        Converter={StaticResource visibilityConverter},
                        ElementName=chkShowPrice}"/>

Mais rien à faire, on obtient toujours le même résultat…

A ce stade, il semble que la seule approche qui pourrait marcher est de passer par le code-behind, ce qu’on préfère généralement éviter quand on suit le pattern MVVM… mais ce serait dommage d’abandonner aussi vite 😉

La solution est en fait assez simple, et se base sur la classe Freezable. La vocation première de cette classe est de définir des objets qui ont un état modifiable et un état non modifiable. Mais en l’occurrence, la caractéristique qui nous intéresse est qu’un objet qui hérite de Freezable peut hériter du DataContext, bien qu’il ne s’agisse pas d’un élément visuel. Je ne connais pas le mécanisme exact qui permet d’obtenir ce comportement, mais toujours est-il que cela va nous permettre d’arriver au résultat voulu…

L’idée est de créer une classe, que j’ai appelée BindingProxy, qui hérite de Freezable et dans laquelle on va déclarer une dependency property Data :

    public class BindingProxy : Freezable
    {
        #region Overrides of Freezable

        protected override Freezable CreateInstanceCore()
        {
            return new BindingProxy();
        }

        #endregion

        public object Data
        {
            get { return (object)GetValue(DataProperty); }
            set { SetValue(DataProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Data.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty DataProperty =
            DependencyProperty.Register("Data", typeof(object), typeof(BindingProxy), new UIPropertyMetadata(null));
    }

On va ensuite déclarer une instance de cette classe dans les ressources du DataGrid, et binder la propriété Data sur le DataContext courant :

<DataGrid.Resources>
    <local:BindingProxy x:Key="proxy" Data="{Binding}" />
</DataGrid.Resources>

Il suffit ensuite de spécifier que la source de notre binding est cet objet BindingProxy, facilement accessible puisqu’il est déclaré comme ressource :

<DataGridTextColumn Header="Price" Binding="{Binding Price}" IsReadOnly="False"
                    Visibility="{Binding Data.ShowPrice,
                        Converter={StaticResource visibilityConverter},
                        Source={StaticResource proxy}}"/>

Remarquez qu’on a aussi préfixé le chemin du binding par “Data”, puisque le chemin est maintenant relatif à l’objet BindingProxy.

Le binding fonctionne maintenant comme prévu, moyennant une solution relativement simple à mettre en oeuvre…

[WPF] Une grille simplifiée utilisant des attributs XAML

Le composant Grid est l’un des contrôles les plus utilisés en WPF. Il permet de disposer facilement des éléments selon des lignes et des colonnes. Malheureusement le code pour l’utiliser, bien que simple à écrire, est relativement lourd :

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="5"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="60" />
        <ColumnDefinition Width="*" />
    </Grid.ColumnDefinitions>
    
    <Label Content="Name" Grid.Row="0" Grid.Column="0" />
    <TextBox Text="Hello world" Grid.Row="0" Grid.Column="1"/>
    <Rectangle Fill="Black" Grid.Row="1" Grid.ColumnSpan="2"/>
    <Label Content="Image" Grid.Row="2" Grid.Column="0" />
    <Image Source="Resources/Desert.jpg" Grid.Row="2" Grid.Column="1" />
</Grid>

Dans cet exemple, plus de la moitié du code est constitué de la définition de la grille ! Bien que cette syntaxe offre une certaine souplesse et permette de contrôler assez finement la disposition, dans la plupart des cas on a seulement besoin de définir la hauteur des lignes ou la largeur des colonnes… il serait donc beaucoup plus simple de pouvoir déclarer la grille de cette façon :

<Grid Rows="Auto,5,*" Columns="60,*">
    ...
</Grid>

La suite de cet article démontre comment atteindre précisément ce résultat, en créant une classe SimpleGrid héritée de Grid.

Pour commencer, notre classe aura besoin de deux nouvelles propriétés : Rows et Columns. Ces propriétés définissent respectivement les hauteurs et largeurs des lignes et des colonnes. Ces dimensions ne sont pas de simples nombres : en effet, des valeurs comme "*", "2*" ou "Auto" sont des dimensions valides. Il existe en WPF un type dédié pour représenter ces dimensions : la structure GridLength. Nos deux propriétés seront donc des collections de GridLength. Voilà donc la signature de la classe SimpleGrid :

public class SimpleGrid : Grid
{
    public IList<GridLength> Rows { get; set; }
    public IList<GridLength> Columns { get; set; }
}

Puisque ce sont ces propriétés qui vont contrôler les lignes et colonnes de la grille, il faut qu’elles modifient les RowDefinitions et ColumnDefinitions de la classe de base. Voilà donc comment les implémenter pour obtenir le résultat voulu :

        private IList<GridLength> _rows;
        public IList<GridLength> Rows
        {
            get { return _rows; }
            set
            {
                _rows = value;
                RowDefinitions.Clear();
                if (_rows == null)
                    return;
                foreach (var length in _rows)
                {
                    RowDefinitions.Add(new RowDefinition { Height = length });
                }
            }
        }

        private IList<GridLength> _columns;
        public IList<GridLength> Columns
        {
            get { return _columns; }
            set
            {
                _columns = value;
                ColumnDefinitions.Clear();
                if (_columns == null)
                    return;
                foreach (var length in _columns)
                {
                    ColumnDefinitions.Add(new ColumnDefinition { Width = length });
                }
            }
        }

Notre classe SimpleGrid est d’ores et déjà utilisable… à partir du code C#, ce qui ne nous aide pas beaucoup quand il s’agit de simplifier le code XAML. Il nous faut donc trouver un moyen de déclarer dans un attribut les valeurs de ces propriétés, ce qui n’est pas évident dans la mesure où ce sont des collections…

En XAML, tous les attributs sont écrits sous forme de chaines de caractères. Pour convertir ces chaines en valeurs du type voulu, WPF fait appel à des convertisseurs, qui sont des classes héritées de TypeConverter, associées à chaque type qui supporte les conversions de et vers d’autres types. Par exemple, le type GridLength a pour convertisseur le type GridLengthConverter, qui permet de convertir des nombres ou des chaines de caractères en GridLength, et inversement. Le mécanisme de conversion est décrit dans cet article sur MSDN.

Nous allons donc devoir créer un convertisseur et l’associer au type de nos propriétés. Comme nous n’avons pas la main sur le type IList<T>, nous allons d’abord créer un type spécifique GridLengthCollection qu’on utilisera à la place de IList<GridLength>, et lui associer un convertisseur GridLengthCollectionConverter :

    [TypeConverter(typeof(GridLengthCollectionConverter))]
    public class GridLengthCollection : ReadOnlyCollection<GridLength>
    {
        public GridLengthCollection(IList<GridLength> lengths)
            : base(lengths)
        {
        }
    }

Pourquoi une collection en lecture seule ? Tout bêtement parce que permettre d’ajouter ou de supprimer des lignes ou des colonnes compliquerait l’implémentation, et n’apporterait rien pour l’objectif qui nous intéresse, à savoir simplifier la déclaration de la grille en XAML. Donc, restons dans la simplicité… Pour éviter de réinventer la roue, on hérite de la classe ReadOnlyCollection<T>, qui correspond parfaitement à notre besoin.

Notez aussi l’utilisation de l’attribut TypeConverter : il sert à indiquer le convertisseur associé à un type. Il nous reste donc simplement à implémenter ce convertisseur :

    public class GridLengthCollectionConverter : TypeConverter
    {
        public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
        {
            if (sourceType == typeof(string))
                return true;
            return base.CanConvertFrom(context, sourceType);
        }

        public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
        {
            if (destinationType == typeof(string))
                return true;
            return base.CanConvertTo(context, destinationType);
        }

        public override object ConvertFrom(ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value)
        {
            string s = value as string;
            if (s != null)
                return ParseString(s, culture);
            return base.ConvertFrom(context, culture, value);
        }

        public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
        {
            if (destinationType == typeof(string) && value is GridLengthCollection)
                return ToString((GridLengthCollection)value, culture);
            return base.ConvertTo(context, culture, value, destinationType);
        }

        private string ToString(GridLengthCollection value, CultureInfo culture)
        {
            var converter = new GridLengthConverter();
            return string.Join(",", value.Select(v => converter.ConvertToString(v)));
        }

        private GridLengthCollection ParseString(string s, CultureInfo culture)
        {
            var converter = new GridLengthConverter();
            var lengths = s.Split(',').Select(p => (GridLength)converter.ConvertFromString(p.Trim()));
            return new GridLengthCollection(lengths.ToArray());
        }
    }

Ce convertisseur est capable de convertir une GridLengthCollection de et vers une chaine de caractères, dans laquelle les dimensions sont séparées par des virgules. Notez l’utilisation du convertisseur GridLengthConverter : puisqu’il existe déjà un convertisseur pour les éléments de notre collection, autant s’en servir…

Toutes les pièces du puzzle étant en place, il ne nous reste plus qu’à utiliser notre nouvelle grille simplifiée :

<my:SimpleGrid Rows="Auto,5,*" Columns="60,*">
    <Label Content="Name" Grid.Row="0" Grid.Column="0" />
    <TextBox Text="Hello world" Grid.Row="0" Grid.Column="1"/>
    <Rectangle Fill="Black" Grid.Row="1" Grid.ColumnSpan="2"/>
    <Label Content="Image" Grid.Row="2" Grid.Column="0" />
    <Image Source="Resources/Desert.jpg" Grid.Row="2" Grid.Column="1" />
</my:SimpleGrid>

On obtient donc un résultat beaucoup plus concis et lisible qu’en utilisant une Grid normale, l’objectif est donc atteint 🙂

On pourrait bien sûr envisager des améliorations, par exemple déclarer les propriétés Rows et Columns comme des DependencyProperty afin de permettre le binding, ou encore gérer l’ajout et la suppression de colonnes. Cependant, cette grille s’adresse à des scénarios simples où la grille est définie une fois pour toutes et n’est pas modifiée à l’exécution (ce qui correspond a priori au cas d’utilisation le plus fréquent), il semble donc plus judicieux de la garder la plus simple possible. Pour des besoins plus spécifiques, par exemple si l’on veut utiliser les propriétés MinWidth, MaxWidth ou encore SharedSizeGroup, il faudra donc revenir à la Grid standard.

Pour référence, voici le code final de la classe SimpleGrid :

    public class SimpleGrid : Grid
    {
        private GridLengthCollection _rows;
        public GridLengthCollection Rows
        {
            get { return _rows; }
            set
            {
                _rows = value;
                RowDefinitions.Clear();
                if (_rows == null)
                    return;
                foreach (var length in _rows)
                {
                    RowDefinitions.Add(new RowDefinition { Height = length });
                }
            }
        }

        private GridLengthCollection _columns;
        public GridLengthCollection Columns
        {
            get { return _columns; }
            set
            {
                _columns = value;
                if (_columns == null)
                    return;
                ColumnDefinitions.Clear();
                foreach (var length in _columns)
                {
                    ColumnDefinitions.Add(new ColumnDefinition { Width = length });
                }
            }
        }
    }

[VS 2010] Support du binding dans les InputBindings

LA fonctionnalité qui manquait à WPF !

La beta 2 de Visual Studio 2010 est là depuis quelques jours, et apporte à WPF une nouveauté que j’attendais depuis longtemps : le support du binding dans les InputBindings.

Pour rappel, le problème de la version précédente était que la propriété Command de la classe InputBinding n’était pas une DependencyProperty, on ne pouvait donc pas la définir par un binding. D’ailleurs, les InputBindings n’héritaient pas du DataContext, ce qui compliquait beaucoup les implémentations alternatives de cette fonctionnalité…

Jusqu’ici, pour lier la commande d’un KeyBinding ou MouseBinding à une propriété du DataContext, il fallait donc passer par des détours pas forcément très élégants… J’avais fini par trouver une solution acceptable, détaillée dans ce post, mais qui me laissait assez insatisfait (utilisation de la réflexion sur des membres privés, pas mal de limitations…).

J’ai découvert plus récemment le MVVM toolkit, qui propose une approche un peu plus propre : une classe CommandReference, héritée de Freezable, qui permet de mettre dans les ressources de la page ou du contrôle une référence à la commande, qu’on peut ensuite utiliser avec StaticResource. Plus propre, mais ça restait peu intuitif…

WPF 4.0 résout le problème une bonne fois pour toutes : la classe InputBinding hérite maintenant de Freezable, ce qui lui permet d’hériter du DataContext parent. De plus les propriétés Command, CommandParameter et CommandTarget sont maintenant des DependencyProperty. On peut donc laisser tomber tous les bricolages qu’il fallait utiliser jusqu’à maintenant, et aller droit au but :

    <Window.InputBindings>
        <KeyBinding Key="F5" Command="{Binding RefreshCommand}" />
    </Window.InputBindings>

Voilà qui devrait faciliter un peu le développement d’applications MVVM !

Help 3

Je change un peu de sujet pour vous parler du nouveau système de documentation offline de Visual Studio 2010, appelé “Help 3”. Il se présente comme une application web installée localement, les pages s’affichent donc dans le navigateur. Globalement, c’est beaucoup mieux… largement plus léger et plus réactif que le vieux Document Explorer livré avec les versions précédentes de Visual Studio.

En revanche, une fonction qui m’était absolument indispensable a disparu : l’index ! Il ne reste que la vue en arborescence et la recherche. Plus de trace de l’index que j’utilisais en permanence pour accéder directement à une classe ou un membre, sans forcément connaître son namespace. En plus, les résultats de la recherche ne montre pas clairement le namespace : par exemple, si vous tapez “button class” dans la recherche, pas moyen de voir la différence entre System.Windows.Forms.Button, System.Windows.Controls.Button et System.Web.UI.WebControls ! Avant, le volet “Résultats de l’index” affichait cette information clairement.

Mon sentiment sur ce nouveau système d’aide est donc un peu mitigé… il va falloir que je change ma façon d’utiliser la doc. Mais à part ce détail agaçant, c’est objectivement un gros progrès !

[WPF] Markup extensions et templates

Note : Ce billet est la suite de celui sur une markup extension qui met à jour sa cible, et réutilise le même code de départ.

Vous avez peut-être remarqué que l’utilisation d’une markup extension personnalisée dans un template donnait parfois des résultats inattendus… Nous allons voir dans ce billet comment faire une markup extension qui se comporte correctement dans un template.

Illustration du problème

Reprenons l’exemple du précédent billet : une markup extension qui renvoie l’état de la connectivité réseau, et met à jour la propriété cible quand le réseau est connecté ou déconnecté :

<CheckBox IsChecked="{my:NetworkAvailable}" Content="Network is available" />

Mettons maintenant la même CheckBox dans un ControlTemplate :

<ControlTemplate x:Key="test">
  <CheckBox IsChecked="{my:NetworkAvailable}" Content="Network is available" />
</ControlTemplate>

Et créons un contrôle qui utilise ce template :

<Control Template="{StaticResource test}" />

Si on se déconnecte du réseau, on remarque que la CheckBox n’est pas automatiquement mise à jour par la NetworkAvailableExtension, alors que ça fonctionnait bien quand on l’utilisait hors du template…

Explication et solution

La markup expression est évaluée quand elle est rencontrée par le parser XAML : en l’occurrence, lors du parsing du template. Or, à cet instant le contrôle CheckBox n’est pas encore créé, la méthode ProvideValue ne peut donc pas y accéder… Quand une markup extension est évaluée dans un template, le TargetObject est en fait un objet de type System.Windows.SharedDp, qui est une classe interne de WPF.

Pour que la markup extension puisse accéder à sa cible, il faut qu’elle soit évaluée lorsque le template est appliqué : on doit donc retarder son évaluation. Pour y arriver, il suffit en fait de renvoyer l’extension elle-même comme valeur de retour de ProvideValue : de cette façon, elle sera de nouveau évaluée lors de la création du contrôle cible.

Pour savoir si l’extension est appelée pour le template ou pour un contrôle “réel”, il suffit de tester si le type du TargetObject est System.Windows.SharedDp. Le code de la méthode ProvideValue devient donc :

        public sealed override object ProvideValue(IServiceProvider serviceProvider)
        {
            IProvideValueTarget target = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
            if (target != null)
            {
                if (target.TargetObject.GetType().FullName == "System.Windows.SharedDp")
                    return this;
                _targetObject = target.TargetObject;
                _targetProperty = target.TargetProperty;
            }

            return ProvideValueInternal(serviceProvider);
        }

Voilà, c’est réparé, la CheckBox se met à nouveau à jour en cas de changement de connectivité réseau 🙂

Encore un os

Mais ne crions pas victoire trop vite, on n’est pas encore tout à fait au bout de nos peines… Que se passe-t-il si on souhaite maintenant utiliser notre ControlTemplate sur plusieurs contrôles ?

<Control Template="{StaticResource test}" />
<Control Template="{StaticResource test}" />

Résultat : la seconde checkbox se met à jour, mais pas la première…

La raison est simple : il y a deux contrôles CheckBox, mais une seule instance de NetworkAvailableExtension, partagée entre toutes les instances du template. Or NetworkAvailableExtension ne peut référencer qu’un seul objet cible, c’est donc le dernier pour lequel ProvideValue a été appelée qui est conservé…

Il suffit donc de gérer non pas un objet cible, mais une collection d’objets cibles, qui seront tous mis à jour dans la méthode UpdateValue. Voilà le code final de la classe de base UpdatableMarkupExtension :

    public abstract class UpdatableMarkupExtension : MarkupExtension
    {
        private List<object> _targetObjects = new List<object>();
        private object _targetProperty;

        protected IEnumerable<object> TargetObjects
        {
            get { return _targetObjects; }
        }

        protected object TargetProperty
        {
            get { return _targetProperty; }
        }

        public sealed override object ProvideValue(IServiceProvider serviceProvider)
        {
            // Retrieve target information
            IProvideValueTarget target = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;

            if (target != null && target.TargetObject != null)
            {
                // In a template the TargetObject is a SharedDp (internal WPF class)
                // In that case, the markup extension itself is returned to be re-evaluated later
                if (target.TargetObject.GetType().FullName == "System.Windows.SharedDp")
                    return this;

                // Save target information for later updates
                _targetObjects.Add(target.TargetObject);
                _targetProperty = target.TargetProperty;
            }

            // Delegate the work to the derived class
            return ProvideValueInternal(serviceProvider);
        }

        protected virtual void UpdateValue(object value)
        {
            if (_targetObjects.Count > 0)
            {
                // Update the target property of each target object
                foreach (var target in _targetObjects)
                {
                    if (_targetProperty is DependencyProperty)
                    {
                        DependencyObject obj = target as DependencyObject;
                        DependencyProperty prop = _targetProperty as DependencyProperty;

                        Action updateAction = () => obj.SetValue(prop, value);

                        // Check whether the target object can be accessed from the
                        // current thread, and use Dispatcher.Invoke if it can't

                        if (obj.CheckAccess())
                            updateAction();
                        else
                            obj.Dispatcher.Invoke(updateAction);
                    }
                    else // _targetProperty is PropertyInfo
                    {
                        PropertyInfo prop = _targetProperty as PropertyInfo;
                        prop.SetValue(target, value, null);
                    }
                }
            }
        }

        protected abstract object ProvideValueInternal(IServiceProvider serviceProvider);
    }

La classe UpdatableMarkupExtension est donc maintenant pleinement opérationnelle… jusqu’à preuve du contraire ;). Cette classe constitue une bonne base pour toute markup extension devant mettre à jour sa cible, sans avoir à se préoccuper des aspects “bas niveau” du suivi des objets cibles et de leur mise à jour.