Developer Soft corporate blog
March 2011 15

Установка DataContext вложенным невизуальным объектам в WPF/Silverlight

image
При разработке DXScheduler for WPF мы получили от пользователя сценарий, в котором использовался MVVM шаблон.
Пользовательский объект назначался свойству DataContext нашего планировщика, а в XAML-разметке осуществлялась «привязка» к соответствующим свойствам объекта с использованием Binding-выражений.
Но возникла проблема — планировщик содержал некий невизуальный объект Storage, который хранил набор настроек для данных. В том виде, в котором были записаны Binding-выражения, свойства объекта-стораджа не обновлялись.

О том, как была решена эта проблема, вы узнаете ниже…

В статье приведен упрощенный вариант решения, который демонстрирует описанный выше сценарий. Поэтому не буду усложнять приведённый код полными реализациями шаблона MVVM, нагромождать интерфейсами INotifyPropertyChanged и т.д. Наша задача заключается в том, чтобы пример максимально просто отражал суть вопроса.

Итак, начнём с визуального контрола, который будет являться представлением модели.

Класс View


Визуальный контрол, который содержит свойство для вложенного объекта DataStore. Будет создан и назначен в XAML.
public class SomeVisualControl : Control {
    public static readonly DependencyProperty InnerDataStoreProperty =
        DependencyProperty.Register("InnerDataStore", typeof(DataStore), typeof(SomeVisualControl), new PropertyMetadata(null));

    public DataStore InnerDataStore {
        get { return (DataStore)GetValue(InnerDataStoreProperty); }
        set { SetValue(InnerDataStoreProperty, value); }
     }
 }


Класс DataStore


Невизуальное хранилище данных, создаётся в XAML и содержится как внутреннее свойство в SomeVisualControl.

В DXScheduler аналогичный внутренний объект был наследником DependencyObject и по определению не содержал DataContext. Как следствие, Binding-выражения на свойства этого объекта не работали. Поэтому первое, что приходит в голову — это наследовать этот объект от класса, содержащего DataContext, и проблема будет решена.

Таким классом является FrameworkElement, и мы будем использовать именно его как базовый класс.

public class DataStore : FrameworkElement {
    public static readonly DependencyProperty ConnectionStringProperty =
        DependencyProperty.Register("ConnectionString", typeof(string), typeof(DataStore), new PropertyMetadata(string.Empty));

     public string ConnectionString {
         get { return (string)GetValue(ConnectionStringProperty); }
         set { SetValue(ConnectionStringProperty, value); }
     }
}


Теперь определим объекты уровня пользователя.

Класс Model


Определяет пользовательский объект. Свойство ConnectionString будет «связано» со свойством внутреннего хранилища визуального контрола.

public class DataStoreModel {
    public string ConnectionString { get; set; }
 
    public DataStoreModel(string connection) {
         ConnectionString = connection;
    }
}


Класс ModelView


Определяет представление модели пользовательского объекта. Следуя требованиям шаблона MVVM, данный класс должен реализовывать INotifyPropertyChanged, но в нашем примере в этом нет необходимости.

public class DataStoreViewModel {
    DataStoreModel dataStore;
 
    public DataStoreViewModel(DataStoreModel dataStore) {
        if (dataStore == null)
             throw new ArgumentNullException("dataStore");
        this.dataStore = dataStore;
    }
    public string ModelConnectionString { get { return dataStore.ConnectionString; } }
}


Диаграмма полученных классов приведена ниже:


Итак, поставим задачу: cвязать свойство внутреннего невизуального объекта со свойством модели.

Перейдем к приложению и создадим необходимые контролы в XAML-разметке
<Window x:Class="DataContextWpfSample.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:DataContextWpfSample"
    Loaded="Window_Loaded" 
    Title="MainWindow" Height="350" Width="525">
  <Grid>
    <local:SomeVisualControl x:Name="MyVisualControl">
      <local:SomeVisualControl.InnerDataStore>
        <local:DataStore ConnectionString="{Binding ModelConnectionString}" />
      </local:SomeVisualControl.InnerDataStore>

      <local:SomeVisualControl.Template>
        <ControlTemplate>
          <StackPanel>
            <TextBlock Text="DataStore Connection:" FontWeight="Bold" />
            <TextBlock Text="{Binding Path=InnerDataStore.ConnectionString, RelativeSource={RelativeSource Mode=TemplatedParent}}" TextWrapping="Wrap" />
          </StackPanel>
        </ControlTemplate>
      </local:SomeVisualControl.Template>
    </local:SomeVisualControl>
  </Grid>
</Window>

* This source code was highlighted with Source Code Highlighter.


Зададим имя MyVisualControl нашему контролу — это будет необходимо для обращения к нему из code-behind файла окна. Определим шаблон показа и выведем интересующие нас свойства, чтобы удостовериться, что они корректно были получены с объекта модели.

Код инициализации модели в файле класса окна выглядит следующим образом:

public partial class MainWindow : Window {
        
    public MainWindow() {
        InitializeComponent();
    }
 
    private void Window_Loaded(object sender, RoutedEventArgs e) {
        string connection = @"Provider=Microsoft.Jet.OLEDB.4.0;Data Source=|DataDirectory|\MyDB.mdb;Persist Security Info=True";
        DataStoreModel sourceData = new DataStoreModel(connection);
        DataStoreViewModel sourceDataModel = new DataStoreViewModel(sourceData);

        this.MyVisualControl.DataContext = sourceDataModel;
}


Особо отметим последнюю строку — присвоение DataContext позволит таким образом «связать» свойства контрола со свойствами модели, используя binding-выражения.

Запустим приложение, но вместо ожидаемой коннекшн-строки мы видим пустую.
Воспользуемся утилитой SNOOP и убедимся что DataContext объекта хранилища не назначен:



Похоже, это происходит из-за того что:
Объект DataStore НЕ лежит в визуальном дереве, и поэтому на него НЕ устанавливается контекст родителя.

Назначение DataContext


Таким образом, нам необходимо определить, когда назначается DataContext в визуальном контроле, и установить это значение на внутренний объект DataStore.

Класс FrameworkContentElement содержит событие DataContextChanged.
Воспользуемся этим и, написав несложный код, получим правильный результат.

public class SomeVisualControl : Control {
    // ...
    public SomeVisualControl() {
        this.DataContextChanged += new DependencyPropertyChangedEventHandler(SomeVisualControl_DataContextChanged);
    }
    void  SomeVisualControl_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e) {
        InnerDataStore.DataContext = e.NewValue;
    }
}


К сожалению, есть одно НО…
Если вы используете WPF — то вполне можете использовать такой подход. Все дело в том, что текущая версия Silverlight не содержит событие DataContextChanged.
А так как мы пишем общий код для WPF и SL контролов, то необходимо было написать универсальное решение.

Так как же узнать когда меняется DataContext визуального контрола?

Можно воспользоваться следующим подходом…
Скажу честно — идея не нова. Я лишь постарался обобщить её так, чтобы можно было использовать в разных классах и для классов с иерархией вложенных невизуальных объектов.
Суть идеи в том, что создается DependencyProperty и делается binding, где связывается созданное свойство с DataContext-свойством контрола, в котором вы хотите знать об изменении контекста. При этом при регистрации DependencyProperty необходимо указать PropertyChangedCallback. Эта callback-функция будет вызываться при изменении значения свойства DataContext. И именно тут можно назначить контекст на все необходимые объекты, в нашем случае на InnerDataStore объект.

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

public interface IDataContextOwner {
    object DataContext { get; }
    void UpdateInnerDataContext(object dataContext);
}


Реализуем описанный выше класс, содержащий binding на DataContext и PropertyChangedCallback-метод.
При этом в конструктор класса передадим объект, реализующий интерфейс IDataContextOwner (в нашем случае это будет SomeVisualControl) и вызовем интерфейсный метод UpdateInnerDataContext, чтобы сообщить нашему визуальному контролу, что пора обновлять контекст его внутреннего хранилища.

public class DataContextBinder : DependencyObject {
    IDataContextOwner owner;
    public DataContextBinder(IDataContextOwner owner) {
        if (owner == null)
            throw new ArgumentNullException("owner");
        this.owner = owner;

        InitializeBinding();
    }
    protected virtual void InitializeBinding() {
        Binding binding = new Binding("DataContext");
        binding.Source = owner;
        binding.Mode = BindingMode.OneWay;

        BindingOperations.SetBinding(this, DataContextProperty, binding);
    }
    public object DataContext {
        get { return (object)GetValue(DataContextProperty); }
        set { SetValue(DataContextProperty, value); }
    }
    public static readonly DependencyProperty DataContextProperty =
        DependencyProperty.Register("DataContext", typeof(object), typeof(DataContextBinder), new PropertyMetadata(null, new PropertyChangedCallback(OnDataContextChanged)));

    public static void OnDataContextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) {
        ((DataContextBinder)d).OnDataContextChanged(e.OldValue, e.NewValue);
    }
    private void OnDataContextChanged(object oldValue, object newValue) {
        owner.UpdateInnerDataContext(newValue);
    }
}


Теперь напишем довольно простую реализацию интерфейса IDataContextOwner в нашем View (SomeVisualControl):
public class SomeVisualControl : Control, IDataContextOwner {
    // ...
    object IDataContextOwner.DataContext {
         get { return DataContext; }
    }
    void IDataContextOwner.UpdateInnerDataContext(object dataContext) {
        if (InnerDataStore != null)
            InnerDataStore.DataContext = dataContext;
    }
}


Последнее, что необходимо сделать — это создать экземпляр DataContextBinder внутри View.
Сделать это можно прямо в конструкторе класса:
public SomeVisualControl() {
    this.dataContextBinder = new DataContextBinder(this);
}


Запустим приложение и убедимся, что контекст назначен на внутренний объект и строка из модели корректно установилась в объект DataStore. Теперь окно приложения отображает данные из определенной пользователем модели.



Выводы


Данная реализация не привязана к конкретному классу и может быть применена там, где возникла необходимость назначить DataContext на объект, который не может его получить «штатными» средствами.
При этом, когда есть необходимость передавать контекст вглубь по иерархии невизуальных объектов, вы просто создаете объект класса DataContextBinder и реализуете интерфейс IDataContextOwner.
Это освобождает вас от громоздкого написания dependency-свойств и определения binding между ними в каждом из классов. DataContextBinder инкапсулирует в себе функционал и в определенный момент извещает владельца о необходимости установить новое значение контекста на вложенные объекты.

Исходные тексты примеров доступны здесь:
WPF и Silverlight
+23
7.8k 22
Comments 1
Top of the day