[WPF] Utiliser les InputBindings avec le pattern MVVM

Si vous développez des applications WPF en suivant le design pattern Model-View-ViewModel, vous vous êtes peut-être déjà trouvé confronté au problème suivant : comment, en XAML, lier un raccourci clavier ou une action de la souris à une commande du ViewModel ? Idéalement, on aimerait pouvoir faire comme ça :

    <UserControl.InputBindings>
        <KeyBinding Modifiers="Control" Key="E" Command="{Binding EditCommand}"/>
    </UserControl.InputBindings>

Malheureusement, ce code ne fonctionne pas, pour deux raisons :

  1. La propriété Command n’est pas une DependencyProperty, on ne peut donc pas faire de binding dessus
  2. Les InputBindings ne font pas partie de l’arbre logique ou visuel du contrôle, ils n’héritent donc pas du DataContext

Une solution, bien sûr, serait de passer par le code-behind pour créer les InputBindings, mais en général, dans une application développée selon le pattern MVVM, on préfère éviter d’écrire du code-behind. J’ai longuement cherché des solutions alternatives pour pouvoir le faire en XAML, mais la plupart sont relativement complexes et peu intuitives. J’ai donc finalement créé une markup extension qui permet de se binder à une commande du DataContext, à n’importe quel endroit du code XAML, même si l’élément n’hérite pas du DataContext.

Cette extension s’utilise comme un simple binding :

    <UserControl.InputBindings>
        <KeyBinding Modifiers="Control" Key="E" Command="{input:CommandBinding EditCommand}"/>
    </UserControl.InputBindings>

(Le namespace XML input étant mappé sur le namespace CLR où est déclarée la markup extension)

Pour réaliser cette extension, j’avoue que j’ai un peu triché… j’ai fouillé avec Reflector le code des classes de WPF, afin de trouver des champs privés qui permettraient de récupérer le DataContext de l’élément racine. J’accède ensuite à ces champs par réflexion.

Voici le code :

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

namespace MVVMLib.Input
{
    [MarkupExtensionReturnType(typeof(ICommand))]
    public class CommandBindingExtension : MarkupExtension
    {
        public CommandBindingExtension()
        {
        }

        public CommandBindingExtension(string commandName)
        {
            this.CommandName = commandName;
        }

        [ConstructorArgument("commandName")]
        public string CommandName { get; set; }

        private object targetObject;
        private object targetProperty;

        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            IProvideValueTarget provideValueTarget = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
            if (provideValueTarget != null)
            {
                targetObject = provideValueTarget.TargetObject;
                targetProperty = provideValueTarget.TargetProperty;
            }

            if (!string.IsNullOrEmpty(CommandName))
            {
                // The serviceProvider is actually a ProvideValueServiceProvider, which has a private field "_context" of type ParserContext
                ParserContext parserContext = GetPrivateFieldValue<ParserContext>(serviceProvider, "_context");
                if (parserContext != null)
                {
                    // A ParserContext has a private field "_rootElement", which returns the root element of the XAML file
                    FrameworkElement rootElement = GetPrivateFieldValue<FrameworkElement>(parserContext, "_rootElement");
                    if (rootElement != null)
                    {
                        // Now we can retrieve the DataContext
                        object dataContext = rootElement.DataContext;

                        // The DataContext may not be set yet when the FrameworkElement is first created, and it may change afterwards,
                        // so we handle the DataContextChanged event to update the Command when needed
                        if (!dataContextChangeHandlerSet)
                        {
                            rootElement.DataContextChanged += new DependencyPropertyChangedEventHandler(rootElement_DataContextChanged);
                            dataContextChangeHandlerSet = true;
                        }

                        if (dataContext != null)
                        {
                            ICommand command = GetCommand(dataContext, CommandName);
                            if (command != null)
                                return command;
                        }
                    }
                }
            }

            // The Command property of an InputBinding cannot be null, so we return a dummy extension instead
            return DummyCommand.Instance;
        }

        private ICommand GetCommand(object dataContext, string commandName)
        {
            PropertyInfo prop = dataContext.GetType().GetProperty(commandName);
            if (prop != null)
            {
                ICommand command = prop.GetValue(dataContext, null) as ICommand;
                if (command != null)
                    return command;
            }
            return null;
        }

        private void AssignCommand(ICommand command)
        {
            if (targetObject != null && targetProperty != null)
            {
                if (targetProperty is DependencyProperty)
                {
                    DependencyObject depObj = targetObject as DependencyObject;
                    DependencyProperty depProp = targetProperty as DependencyProperty;
                    depObj.SetValue(depProp, command);
                }
                else
                {
                    PropertyInfo prop = targetProperty as PropertyInfo;
                    prop.SetValue(targetObject, command, null);
                }
            }
        }

        private bool dataContextChangeHandlerSet = false;
        private void rootElement_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
        {
            FrameworkElement rootElement = sender as FrameworkElement;
            if (rootElement != null)
            {
                object dataContext = rootElement.DataContext;
                if (dataContext != null)
                {
                    ICommand command = GetCommand(dataContext, CommandName);
                    if (command != null)
                    {
                        AssignCommand(command);
                    }
                }
            }
        }

        private T GetPrivateFieldValue<T>(object target, string fieldName)
        {
            FieldInfo field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
            if (field != null)
            {
                return (T)field.GetValue(target);
            }
            return default(T);
        }

        // A dummy command that does nothing...
        private class DummyCommand : ICommand
        {

            #region Singleton pattern

            private DummyCommand()
            {
            }

            private static DummyCommand _instance = null;
            public static DummyCommand Instance
            {
                get
                {
                    if (_instance == null)
                    {
                        _instance = new DummyCommand();
                    }
                    return _instance;
                }
            }

            #endregion

            #region ICommand Members

            public bool CanExecute(object parameter)
            {
                return false;
            }

            public event EventHandler CanExecuteChanged;

            public void Execute(object parameter)
            {
            }

            #endregion
        }
    }

Cette solution a cependant une limitation : elle ne fonctionne que pour le DataContext de la racine du XAML. On ne peut donc pas l’utiliser, par exemple, pour définir des InputBindings sur un contrôle dont on redéfinit aussi le DataContext, car la markup extension renverra le DataContext de l’élément racine.

17 thoughts on “[WPF] Utiliser les InputBindings avec le pattern MVVM”

  1. Hello,

    Pour info, ce code ne fonctionne pas avec la Beta 1 de WPF 4. En effet, la ligne:

    ParserContext parserContext = GetPrivateFieldValue(serviceProvider, “_context”);

    Renvoit false car le champ ne se nomme plus _context mais _xamlContext. De plus, on travaille à présent avec la classe ObjectWriterContext, qui est internal 🙁

    1. Arf… évidemment, quand on triche comme j”ai fait, il faut s”attendre à ce que ça ne marche plus dans les versions suivantes ;).
      Je viens d”installer VS2010, mais j”ai pas encore eu le temps de beaucoup tester. En tous cas, à première vue, j”ai pas l”impression qu”il y ait quoi que ce soit dans WPF 4 qui facilite l”utilisation de MVVM… grosse déception, je comptais un peu dessus :(. Ce serait bien que MS fournisse au moins un framework MVVM à installer séparément, quitte à l”intégrer à une version suivante de WPF (comme ils ont fait pour ASP.NET AJAX et le WPF toolkit, par exemple).

  2. Voici le code qui permet de fonctionner (pour le moment) en Beta 1:

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
    IProvideValueTarget provideValueTarget = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
    if (provideValueTarget != null)
    {
    targetObject = provideValueTarget.TargetObject;
    targetProperty = provideValueTarget.TargetProperty;
    }

    FrameworkElement rootElement = null;

    if (!string.IsNullOrEmpty(CommandName))
    {
    // The serviceProvider is actually a ProvideValueServiceProvider, which has a private field “_context” of type ParserContext
    ParserContext parserContext = GetPrivateFieldValue(serviceProvider, “_context”);
    if (parserContext != null)
    {
    // A ParserContext has a private field “_rootElement”, which returns the root element of the XAML file
    rootElement = GetPrivateFieldValue(parserContext, “_rootElement”);
    }
    else
    {
    var theParserContext = serviceProvider.GetType().GetField(“_xamlContext”, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(serviceProvider);

    rootElement = theParserContext.GetType().GetField(“_rootInstance”, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(theParserContext) as FrameworkElement;
    }

    if (rootElement != null)
    {
    // Now we can retrieve the DataContext
    object dataContext = rootElement.DataContext;

    // The DataContext may not be set yet when the FrameworkElement is first created, and it may change afterwards,
    // so we handle the DataContextChanged event to update the Command when needed
    if (!dataContextChangeHandlerSet)
    {
    rootElement.DataContextChanged += new DependencyPropertyChangedEventHandler(rootElement_DataContextChanged);
    dataContextChangeHandlerSet = true;
    }

    if (dataContext != null)
    {
    ICommand command = GetCommand(dataContext, CommandName);
    if (command != null)
    return command;
    }
    }
    }

    // The Command property of an InputBinding cannot be null, so we return a dummy extension instead
    return DummyCommand.Instance;
    }

  3. Sympa, merci pour le lien !
    La syntaxe est un peu plus lourde, mais c”est nettement plus propre que la réflexion sur des champs privés, et ça ne pose pas de problème en partial trust. Il y a peut-être moyen de faire encore mieux en s”inspirant de cette idée, je vais étudier ça…

  4. Bonjour,

    le comportement de la classe est assez étrange ou alors je l”utilise mal mais le raccourci clavier n”est effectif que si on exécute au moins une fois la commande. étrange non ?

  5. Bonjour,
    Je me permets de poster une ”astuce” que je viens de trouver en tant que débutant WPF et qui pourra faire gagner du temps à d”autres dans mon cas.

    En utilisant le KeyBinding pour la touche LeftAlt je n”arrivais pas à déclencher la commande. Il suffisait d”ajouter dans la balise l”extrait suivant :
    Modifiers=”Alt”

    A ce que j”ai compris cela permet de modifier le comportement par dfaut de la touche.

  6. Bonjour,
    Avec la modification de Thomas Lebrun, le code ne compile plus dans ma version.
    Y-a-t-il un site où l’on pourrait télécharger un exemple de ce code.
    merci et bravo

    1. Bonjour Christian,
      En fait la classe CommandBinding n’a plus lieu d’être si vous utilisez .NET 4.0 ou plus, car InputBinding.Command supporte maintenant le binding nativement (voir ici). Vous pouvez donc utiliser un binding normal à la place de CommandBinding

    2. Rebonjour,
      j’ai corrigé le code, mais j’a toujours un problème avec la syntaxe XAML:input:CommandBinding est inconnu. j’ai
      Pouvez-vous fournir un projet de code dans un zip

      1. Bonjour,
        Malheureusement je n’ai plus ce code depuis bien longtemps…
        Comme je l’ai dit dans mon précédent message, vous n’avez pas besoin de CommandBinding (en supposant que vous utilisiez .NET 4/VS2010 ou supérieur) ; remplacez simplement input:CommandBinding par Binding

Leave a Reply

Your email address will not be published. Required fields are marked *