WPF: Привязка для свойств отличных от свойств зависимостей

в 9:33, , рубрики: .net, binding, mvvm, wpf, XAML, интерфейсы, метки: , , ,

Введение

WPF — замечательная технология, которую, не смотря на все ее недостатки, очень люблю. Тем не менее, часто приходится писать не разметку, а код, который помогает первой работать как надо. Хотелось бы этого избегать и писать чистый XAML, но до сих пор ни одно мое приложение сложнее простого не обходилось без различных хелперов (классов-помощников), написанных на C#. К счастью, есть распространенные случаи, где можно одним хелпером решить сразу группу проблем.

Речь ниже пойдет о привязке в обычных свойствах визуальных элементов, которые не являются свойствами зависимостей (dependecy properties). Штатными средствами WPF этого сделать не получится. Ко всему прочему, мы не можем узнать об изменениях такого свойства, кроме как подписавшись на специальное событие, что противоречит шаблону MVVM. Такие события для каждого свойства могут быть свои. Самый распространенный пример — это PasswordBox и его свойство Password. Так у нас сделать не получится:

<PasswordBox Password={Binding OtherProperty} />

Не будем вдаваться в подробности, зачем разработчики PasswordBox не разрешили привязываться к свойству пароля. Подумаем, что тут можно сделать.

Если вам не хочется читать мои рассуждения, то в конце статьи опубликован финальный код и ссылка на Github.

Определяемся со способом реализации

И так! Хотелось бы такое решение, чтобы в обращении походило на обычную привязку, но с некоторыми дополнительными параметрами. Также, неплохо иметь возможность делать привязку в обе стороны (two way binding). Для реализации перечисленного хелперу понадобятся три входных параметра:

  1. Свойство визуального элемента
  2. Событие, сообщающее об изменениях
  3. Источник данных для привязки

К примеру, для PasswordBox это будут соответственно: свойство Password, событие PasswordChanged и источник OtherProperty.

Есть разные способы достичь желаемого. Остановимся на стандартном для таких случаев механизме — поведениях.

Поведение (behavior) — это класс, который добавляет визуальному элементу дополнительную функциональность. Есть два вида поведений: статические и наследуемые от класса Behavior<T>. Описание их различий выходит за рамки статьи. Я выбрал второй вариант, как обладающий большими возможностями.

Пишем код

Добавим ссылку на сборку System.Windows.Interactivity.dll. Это часть SDK редактора Expression Blend и находится в разделе «Расширения» окна выбора сборок Visual Studio.

Создадим класс, наследуемый от Behavior<T>:

public class DependecyPropertyBehavior : Behavior<DependencyObject>
{
}

Тип генерика DependencyObject выбран как наиболее общий. Ведь мы пишем универсальный класс, подходящий для любого элемента, а не только PasswordBox.

Алгоритм работы будет краток. Для привязки от свойства к источнику:

  1. Подписываемся на событие об изменении свойства визуального элемента.
  2. В обработчике записываем обновленное значение в источник.

Для обратной привязки:

  1. Через привязку определяем момент изменения значения источника.
  2. Записываем обновленное значение в свойство визуального элемента.

Для вышеописанных входных параметров создадим три свойства:

public string Property { get; set; }
public string UpdateEvent { get; set; }

public static readonly DependencyProperty BindingProperty = DependencyProperty.RegisterAttached(
    "Binding",
    typeof(object),
    typeof(DependecyPropertyBehavior),
    new FrameworkPropertyMetadata { BindsTwoWayByDefault = true }
    );
        
public object Binding
{
    get { return GetValue(BindingProperty); }
    set { SetValue(BindingProperty, value); }
}

  • Property — имя свойства визуального элемента, не поддерживающее привязку;
  • UpdateEvent — имя события, уведомляющего об изменении значения нашего свойства;
  • Binding - свойство зависимостей для привязки к источнику данных. Позволяет применять знакомый механизм привязки.

В данном случае свойства Property и UpdateEvent являются обычными, этого достаточно. Свойство Binding, напротив, должно быть свойством зависимостей, ведь именно сюда подключается источник данных.

Теперь, когда у нас есть все входные данные, приступим к их обработке в переопределенном методе OnAttached(). Он вызывается при присоединении поведения к визуальному элементу. К последнему можно обращаться через свойство класса AssociatedObject. В противовес, при отсоединении вызывается OnDetaching().

Нам нужны объекты для работы со свойством визуального элемента и событием через рефлексию. Ниже показано их получение и подписывание на событие, уведомляющее об изменениях свойства визуального элемента.

Далее в примерах я опустил различные проверки на null, чтобы не засорять внимание. В итоговом варианте класса они присутствуют.

private Delegate _handler;
private EventInfo _eventInfo;
private PropertyInfo _propertyInfo;

protected override void OnAttached()
{
    Type elementType = AssociatedObject.GetType();

    // Получаем свойство визуального элемента
    _propertyInfo = elementType.GetProperty(Property, BindingFlags.Instance | BindingFlags.Public);

    // Получаем событие, уведомляющее об изменении визуального элемента
    _eventInfo = elementType.GetEvent(UpdateEvent);

    // Создаем делегат для подписывания на событие
    _handler = CreateDelegateForEvent(_eventInfo, EventFired);

    // Подписываемся
    _eventInfo.AddEventHandler(AssociatedObject, _handler);
}

protected override void OnDetaching()
{
    // Отписываемся
    _eventInfo.RemoveEventHandler(AssociatedObject, _handler);
}

В коде выше имеется метод CreateDelegateForEvent(). Он компилирует объект делегата для указанного события во время выполнения. Ведь заранее мы не знаем сигнатуру обработчика события. При компиляции в делегат помещается вызов метода action, которым в нашем случае является EventFired(). В нем мы будем выполнять нужные нам действия для обновления значения источника данных.

private static Delegate CreateDelegateForEvent(EventInfo eventInfo, Action action)
{
    ParameterExpression[] parameters = 
        eventInfo
        .EventHandlerType
        .GetMethod("Invoke")
        .GetParameters()
        .Select(parameter => Expression.Parameter(parameter.ParameterType))
        .ToArray();

    return Expression.Lambda(
        eventInfo.EventHandlerType,
        Expression.Call(Expression.Constant(action), "Invoke", Type.EmptyTypes),
        parameters
        )
        .Compile();
}

Эта операция довольно ресурсоемка, но выполняется лишь раз, при подключении поведения. Ее можно оптимизировать жертвуя гибкостью, предположив, что события могут быть только RoutedEvent. Тогда, вместо дорогой компиляции, достаточно подписаться на событие с указанием обработчика EventFired(), предварительно изменив ему сигнатуру на совместимую с RoutedEventHandler. Но оставим здесь оригинальный вариант. Преждевременная оптимизация — зло.

Метод EventFired() предельно прост, он записывает новое значение в источник данных:

private void EventFired()
{
    Binding = _propertyInfo.GetValue(AssociatedObject, null);
}

Осталась самая малость — менять значение свойства визуального элемента при изменении источника данных. Для этого подойдет переопределяемый метод OnPropertyChanged(), который сообщает об изменениях свойств зависимостей класса. Так как при изменении источника данных меняется и свойство Binding, то нам достаточно отслеживать его новые значения.

protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
{
    if (e.Property.Name != "Binding") return;

    _propertyInfo.SetValue(AssociatedObject, e.NewValue, null);
    base.OnPropertyChanged(e);
}

Все, вроде бы, прекрасно. Мы задаем новое значение свойства визуального элемента и… получаем StackOverflowException.

Проблема в том, что при изменении свойства автоматом вызывается уведомляющее событие, на которое мы подписаны. В событии изменяется значение источника, а при изменении источника меняется свойство Binding, что приводит нас снова в метод OnPropertyChanged(). Рекурсия.

Самым простым решением будет добавление сравнения старого и нового значения свойства:

protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
{
    if (e.Property.Name != "Binding") return;

    object oldValue = _propertyInfo.GetValue(AssociatedObject, null);
    if (oldValue.Equals(e.NewValue)) return;

    _propertyInfo.SetValue(AssociatedObject, e.NewValue, null);
    base.OnPropertyChanged(e);
}

Здесь делаем допущение, что Equals() у типа реализован как надо и не будет вечно возвращать false.

Наш хелпер готов!

Результат

Пример использования:

<StackPanel
    xmlns:local="clr-namespace:DependecyPropertyBehaviorNamesapce"
    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
    >
    <PasswordBox>
        <i:Interaction.Behaviors>
            <local:DependecyPropertyBehavior 
                UpdateEvent="PasswordChanged"
                Property="Password"
                Binding="{Binding Text, ElementName=TestTextBox}"
                />
        </i:Interaction.Behaviors>
    </PasswordBox>
    <TextBox x:Name="TestTextBox" />
</StackPanel>

В данном случае TextBox и PasswordBox будут синхронно менять значения.

WPF: Привязка для свойств отличных от свойств зависимостей

Заключение

Мы добились работы привязки для свойства визуального элемента, которое изначально ее не поддерживало. Привязка работает в оба направления и можно использовать класс поведения для любого элемента, не беспокоясь о различиях в именах свойств и событий.

Заранее извиняюсь за неточности в тексте, моя первая статья.
Как и обещал, финальный код:

Без проверок на null для быстрого ознакомления

using System;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Windows;
using System.Windows.Interactivity;
using Expression = System.Linq.Expressions.Expression;

namespace DependecyPropertyBehaviorNamesapce
{
    public class DependecyPropertyBehavior : Behavior<DependencyObject>
    {
        private Delegate _handler;
        private EventInfo _eventInfo;
        private PropertyInfo _propertyInfo;

        public static readonly DependencyProperty BindingProperty = DependencyProperty.RegisterAttached(
            "Binding",
            typeof(object),
            typeof(DependecyPropertyBehavior),
            new FrameworkPropertyMetadata { BindsTwoWayByDefault = true }
            );
        
        public object Binding
        {
            get { return GetValue(BindingProperty); }
            set { SetValue(BindingProperty, value); }
        }

        public string Property { get; set; }
        public string UpdateEvent { get; set; }

        protected override void OnAttached()
        {
            Type elementType = AssociatedObject.GetType();
            _propertyInfo = elementType.GetProperty(Property, BindingFlags.Instance | BindingFlags.Public);
            _eventInfo = elementType.GetEvent(UpdateEvent);
            _handler = CreateDelegateForEvent(_eventInfo, EventFired);
            _eventInfo.AddEventHandler(AssociatedObject, _handler);
        }

        protected override void OnDetaching()
        {
            _eventInfo.RemoveEventHandler(AssociatedObject, _handler);
        }

        protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
        {
            if (e.Property.Name != "Binding") return;

            object oldValue = _propertyInfo.GetValue(AssociatedObject, null);
            if (oldValue.Equals(e.NewValue)) return;
            
            _propertyInfo.SetValue(AssociatedObject, e.NewValue, null);
            base.OnPropertyChanged(e);
        }

        private static Delegate CreateDelegateForEvent(EventInfo eventInfo, Action action)
        {
            ParameterExpression[] parameters = 
                eventInfo
                .EventHandlerType
                .GetMethod("Invoke")
                .GetParameters()
                .Select(parameter => Expression.Parameter(parameter.ParameterType))
                .ToArray();

            return Expression.Lambda(
                eventInfo.EventHandlerType,
                Expression.Call(Expression.Constant(action), "Invoke", Type.EmptyTypes),
                parameters
                )
                .Compile();
        }

        private void EventFired()
        {
            Binding = _propertyInfo.GetValue(AssociatedObject, null);
        }
    }
}

Итоговый вариант со всеми проверками

using System;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Windows;
using System.Windows.Interactivity;
using Expression = System.Linq.Expressions.Expression;

namespace DependecyPropertyBehaviorNamesapce
{
    public class DependecyPropertyBehavior : Behavior<DependencyObject>
    {
        private Delegate _handler;
        private EventInfo _eventInfo;
        private PropertyInfo _propertyInfo;

        public static readonly DependencyProperty BindingProperty = DependencyProperty.RegisterAttached(
            "Binding",
            typeof(object),
            typeof(DependecyPropertyBehavior),
            new FrameworkPropertyMetadata { BindsTwoWayByDefault = true }
            );
        
        public object Binding
        {
            get { return GetValue(BindingProperty); }
            set { SetValue(BindingProperty, value); }
        }

        public string Property { get; set; }
        public string UpdateEvent { get; set; }

        protected override void OnAttached()
        {
            Type elementType = AssociatedObject.GetType();

            // Getting property.

            if (Property == null)
            {
                PresentationTraceSources.DependencyPropertySource.TraceData(
                    TraceEventType.Error,
                    1, 
                    "Target property not defined."
                    );
                return;
            }

            _propertyInfo = elementType.GetProperty(Property, BindingFlags.Instance | BindingFlags.Public);

            if (_propertyInfo == null)
            {
                PresentationTraceSources.DependencyPropertySource.TraceData(
                    TraceEventType.Error,
                    2,
                    string.Format("Property "{0}" not found.", Property)
                    );
                return;
            }

            // Getting event.

            if (UpdateEvent == null) return;
            _eventInfo = elementType.GetEvent(UpdateEvent);

            if (_eventInfo == null)
            {
                PresentationTraceSources.MarkupSource.TraceData(
                    TraceEventType.Error, 
                    3,
                    string.Format("Event "{0}" not found.", UpdateEvent)
                    );
                return;
            }

            _handler = CreateDelegateForEvent(_eventInfo, EventFired);
            _eventInfo.AddEventHandler(AssociatedObject, _handler);
        }

        protected override void OnDetaching()
        {
            if (_eventInfo == null) return;
            if (_handler == null) return;

            _eventInfo.RemoveEventHandler(AssociatedObject, _handler);
        }

        protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
        {
            if (e.Property.Name != "Binding") return;
            if (AssociatedObject == null) return;
            if (_propertyInfo == null) return;

            object oldValue = _propertyInfo.GetValue(AssociatedObject, null);
            if (oldValue.Equals(e.NewValue)) return;
            
            _propertyInfo.SetValue(AssociatedObject, e.NewValue, null);

            base.OnPropertyChanged(e);
        }

        private static Delegate CreateDelegateForEvent(EventInfo eventInfo, Action action)
        {
            ParameterExpression[] parameters = 
                eventInfo
                .EventHandlerType
                .GetMethod("Invoke")
                .GetParameters()
                .Select(parameter => Expression.Parameter(parameter.ParameterType))
                .ToArray();

            return Expression.Lambda(
                eventInfo.EventHandlerType,
                Expression.Call(Expression.Constant(action), "Invoke", Type.EmptyTypes),
                parameters
                )
                .Compile();
        }

        private void EventFired()
        {
            if (AssociatedObject == null) return;
            if (_propertyInfo == null) return;

            Binding = _propertyInfo.GetValue(AssociatedObject, null);
        }
    }
}

Github
Мой тех.блог

Автор: exvel

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js