Thursday, March 17, 2011

WPF DataGrid tips: Forwarding the DataContext to columns

В прошлом посте я показал как можно изменить поведение DataGrid’а с помощью кастомного behavior’а. Давайте, взяв за основу созданное там приложениe, решим еще одну задачку.

Сейчас название тега в гриде представлено как простейший DataGridTextColumn. Т.е. в режиме редактирования мы получаем TextBox и можем изменять название как нам угодно. Давайте ограничим свободу ввода тегов, разрешив пользователю только выбирать теги из некоторого списка допустимых значений.

Для начала во ViewModel’ь добавим коллекцию AllTags, которая будет содержать все допустимые названия тегов.

public MainViewModel(IDataProvider provider)
{
    Tags = new ObservableCollection<TagInfo>(provider.Tags);
    AllTags = new ObservableCollection<string>(Tags.Select(t => t.Name));
}

public ObservableCollection<TagInfo> Tags
{
    get;
    private set;
}

public ObservableCollection<string> AllTags
{
    get;
    private set;
}

Теперь заменим колонку в гриде:

<DataGridComboBoxColumn
    Header="Tag"
    ItemsSource="{Binding AllTags}"
    SelectedValueBinding="{Binding Name}" />

Запускаем приложение и видим вот такую картину.

emptyCombo

Что случилось? Где потерялись названия тегов? Давайте разберемся. Для начала, как всегда в ситуациях, когда Binding ведет себя не так как мы ожидали, смотрим в окно Output, где находим ошибку:

System.Windows.Data Error: 2 : Cannot find governing FrameworkElement or FrameworkContentElement for target element. BindingExpression:Path=AllTags; DataItem=null; target element is 'DataGridComboBoxColumn' (HashCode=29758132); target property is 'ItemsSource' (type 'IEnumerable')

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

Решение состоит с том, чтобы при изменении  DataContext’а для грида, руками устанавливать DataContext для каждого столбца. Т.к. тип DataGridColumn (и интересующие нас производные от него) не содержит свойства DataContext, то мы воспользуемся DataContext’ом из типа FrameworkElement. Новую функциональность мы не будем добавлять в codebehind, а определим как отдельный behavior.

public sealed class PushDataContextToColumnsBehavior : Behavior<DataGrid>
{
    protected override void OnAttached()
    {
        base.OnAttached();
        AssociatedObject.DataContextChanged += DataGrid_DataContextChanged;
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();
        AssociatedObject.DataContextChanged -= DataGrid_DataContextChanged;
    }

    private void DataGrid_DataContextChanged(
        object sender, DependencyPropertyChangedEventArgs e)
    {
        foreach (var column in AssociatedObject.Columns)
        {
            column.SetValue(
                FrameworkElement.DataContextProperty,
                e.NewValue);
        }
    }
}

Теперь наш грид будет выглядеть вот так:

<DataGrid
    ItemsSource="{Binding Tags}"
    AutoGenerateColumns="False">
    <DataGrid.Columns>
        <DataGridComboBoxColumn
            Header="Tag"
            ItemsSource="{Binding (FrameworkElement.DataContext).AllTags,
                RelativeSource={RelativeSource Self}}"
            SelectedValueBinding="{Binding Name}" />
        <DataGridTextColumn Header="Score" Binding="{Binding Score}" />
    </DataGrid.Columns>
    <i:Interaction.Behaviors>
        <bhvr:PerCellCommitBehavior />
        <bhvr:PushDataContextToColumnsBehavior />
    </i:Interaction.Behaviors>
</DataGrid>

Запустив приложение мы увидим то, что и ожидали

combo

Как обычно, проект можно скачать отсюда. А вот здесь можно найти альтернативный вариант решения.

Sunday, March 13, 2011

WPF DataGrid tips: Per cell commiting behavior

Несколько месяцев назад проект над которым я тогда работал благополучно переехал на .Net 4.0. Одним из результатов этого перехода было то, что мы решили отказаться от грида Infragistics в пользу WPF DataGrid. Производительность нового грида оказалась выше всяких похвал. Там где Infragistics надолго задумывался, WPF DataGrid стал отображать данные буквально без задержки. Сам процесс замены гридов прошел достаточно безболезненно, однако были несколько моментов, над которыми пришлось поломать голову. В этом и нескольких последующих постах я хочу привести проблемы с которыми я столкнулся и решения на которых остановился.

Итак, проблема первая. При редактировании ячеек WPF DataGrid’a данные в исходной коллекции обновляются не после того как вы закончите редактировать непосредственно ячейку, а только после того как закончите редактировать всю строку целиком.

Для того чтобы получить нужное поведение грида оказалось достаточным в обработчике события CellEditEnding заставлять грид сохранять все имеющиеся изменения.

private void Grid_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e)
{
    if (_manualEditCommit)
        return;

    if (e.EditAction != DataGridEditAction.Commit)
        return;

    _manualEditCommit = true;
    var grid = (DataGrid)sender;
    grid.CommitEdit(DataGridEditingUnit.Row, true);
    _manualEditCommit = false;
}

Однако тут возник другой вопрос: где именно это реализовать? В code behind? Однозначно нет. Сделать производный от грида PerCellCommitDataGrid? Тоже не то.

Мне нравятся два варианта. Вариант первый: Сделать Attached property PerCellCommit, которое будет подписывать нужный обработчик на событие CellEditEnding.

public static class DataGridHelper
{
    public static readonly DependencyProperty PerCellCommitProperty =
        DependencyProperty.RegisterAttached(
            "PerCellCommit",
            typeof(bool),
            typeof(DataGridHelper),
            new PropertyMetadata(false, PerCellCommitChanged));

    public static bool GetPerCellCommit(DependencyObject obj)
    {
        return (bool)obj.GetValue(PerCellCommitProperty);
    }

    public static void SetPerCellCommit(DependencyObject obj, bool value)
    {
        obj.SetValue(PerCellCommitProperty, value);
    }

    public static void PerCellCommitChanged(
        DependencyObject obj, DependencyPropertyChangedEventArgs e)
    {
        var grid = obj as DataGrid;
        if (grid == null)
            return;

        if ((bool)e.NewValue)
        {
            grid.CellEditEnding += Grid_CellEditEnding;
        }
        else
        {
            grid.CellEditEnding -= Grid_CellEditEnding;
        }
    }

    private static bool _isManualEditCommit;

    private static void Grid_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e)
    {
        if (_isManualEditCommit)
            return;

        if (e.EditAction != DataGridEditAction.Commit)
            return;

        _isManualEditCommit = true;
        var grid = (DataGrid) sender;
        grid.CommitEdit(DataGridEditingUnit.Row, true);
        _isManualEditCommit = false;
    }
}

В этом случае в XAML новую функциональность гриду можно подключить следующим образом:

<DataGrid
    ItemsSource="{Binding Tags}"
    presentation:DataGridHelper.PerCellCommit="True">
    <DataGrid.Columns>
        ...
    </DataGrid.Columns>
</DataGrid>

Вариант второй: Реализовать для грида кастомный behavior. Что такое behavior’ы и чем они полезны рекомендую почитать вот тут.

public class DataGridPerCellCommitBehavior : Behavior<DataGrid>
{
    private static bool _manualEditCommit;

    protected override void OnAttached()
    {
        base.OnAttached();

        AssociatedObject.CellEditEnding += Grid_CellEditEnding;
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();

        AssociatedObject.CellEditEnding -= Grid_CellEditEnding;
    }

    private void Grid_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e)
    {
        if (_manualEditCommit)
            return;

        if (e.EditAction != DataGridEditAction.Commit)
            return;

        _manualEditCommit = true;
        var grid = (DataGrid)sender;
        grid.CommitEdit(DataGridEditingUnit.Row, true);
        _manualEditCommit = false;
    }
}

В этом случае грид будет выглядеть примерно так:

<DataGrid
    ItemsSource="{Binding Tags}">
    <DataGrid.Columns>
        ...
    </DataGrid.Columns>
    <i:Interaction.Behaviors>
        <bhvr:DataGridPerCellCommitBehavior />
    </i:Interaction.Behaviors>
</DataGrid>

Исходники демки с реализованным behavior’ом можно скачать тут.