Множественное выделение в дереве используя M-V-VM


Стандартный WPF-ный TreeView не поддерживает возможность множественного выделения. Поэтому в том случае, когда это необходимо приходится либо пользоваться сторонними коммерческими контролами (которые, как правило, весьма невысокого качества), либо реализовывать свой вариант дерева, что является достаточно нетривиальной задачей. Но если вы в своём проекте используете шаблон проектирования Model - View - ViewModel (M-V-VM), то реализовать множественное выделение можно значительно более простым путём.
Множественное выделение в дереве используя M-V-VM

Итак, для начала, я создал простейшую модель для элемента дерева, реализующую интерфейс INotifyPropertyChanged и имеющую три свойства: заголовок (Header), флаг состояния выделен/не выделен (IsSelected) и коллекцию детей (Children):
public class ItemModel: INotifyPropertyChanged
{
    #region Implementation of INotifyPropertyChanged

    /// <summary>
    /// Occurs when a property value changes.
    /// </summary>
    public event PropertyChangedEventHandler PropertyChanged;

    /// <summary>
    /// Raise PropertyChanged event
    /// </summary>
    /// <param name="propertyName">Name of changed property</param>
    private void RaisePropertyChanged(string propertyName)
    {
        if (PropertyChanged != null) PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }

    #endregion

    #region Properties

    #region Header

    /// <summary>
    /// ItemModel header
    /// </summary>
    private string header;

    /// <summary>
    /// Gets or sets ItemModel header
    /// </summary>
    public string Header
    {
        get { return header; }
        set
        {
            if (header != value)
            {
                header = value;
                RaisePropertyChanged("Header");
            }
        }
    }

    #endregion

    #region Children

    /// <summary>
    /// ItemModel children collection
    /// </summary>
    private ObservableCollection<ItemModel> children;

    /// <summary>
    /// Gets ItemModel children collection
    /// </summary>
    public ObservableCollection<ItemModel> Children
    {
        get
        {
            if(children == null) children = new ObservableCollection<ItemModel>();
            return children;
        }
    }

    #endregion

    #region IsSelected

    /// <summary>
    /// Value indicating whether a ItemModel is selecting
    /// </summary>
    private bool isSelected;

    /// <summary>
    /// Gets or sets a value indicating whether a ItemModel is selecting
    /// </summary>
    public bool IsSelected
    {
        get { return isSelected; }
        set
        {
            if (isSelected != value)
            {
                isSelected = value;
                RaisePropertyChanged("IsSelected");
            }
        }
    }

    #endregion

    #endregion
}
Я использовал простую модель для примера, в рабочем приложении, как правило, модель более нагруженная.
Потом я реализовал ViewModel для дерева, она содержит:
  • Коллекцию элементов дерева:
#region Items

/// <summary>
/// Tree items collection
/// </summary>
private ObservableCollection<ItemModel> items;

/// <summary>
/// Gets tree items collection
/// </summary>
public ObservableCollection<ItemModel> Items
{
    get { return items ?? (items = new ObservableCollection<ItemModel>()); }
}

#endregion
  • Список выделенных элементов:
#region Fields

/// <summary>
/// Selected items collection
/// </summary>
private readonly List<ItemModel> selectedItems = new List<ItemModel>();

#endregion
  • И метод для выделения элемента дерева:
/// <summary>
/// Select item
/// </summary>
/// <param name="item">Item to select</param>
public void Select(ItemModel item)
{
    if (!Keyboard.IsKeyDown(Key.LeftCtrl) &amp;&amp; !Keyboard.IsKeyDown(Key.RightCtrl))
    {
        // If Ctrl is not pressed then clear selection
        foreach (ItemModel selectedItem in selectedItems)
        {
            selectedItem.IsSelected = false;
        }
        selectedItems.Clear();
    }
    if (item != null)
    {
        if (!selectedItems.Contains(item))
        {
            // Is item is not selected then select item
            item.IsSelected = true;
            selectedItems.Add(item);
        }
    }
}
Для данного примера метод выделения реализован простейшим способом: если нажат Ctrl, то элемент добавляется в список выделенных, а если нет, то единственным выделенным становится данный элемент. В реальном приложении сюда можно добавить снятие выделения по Ctrl-у, поддержка нажатия Shift-а, выделение детей при выделении родителя, возможность выделения только какого-то одного типа элементов дерева (в том случае, если в вашем дереве находятся элементы различных типов) и многое другое, в зависимости от конкретных потребностей.
Потом я реализовал DataTemplate для элемента дерева:
<HierarchicalDataTemplate DataType="{x:Type Models:ItemModel}" ItemsSource="{Binding Children}">
  <Border BorderThickness="1" CornerRadius="1" x:Name="outterBorder" Background="Transparent">
    <Border BorderThickness="1" CornerRadius="2" x:Name="innerBorder">
      <TextBlock Margin="5,3,10,3" VerticalAlignment="Center" Text="{Binding Header}" Foreground="Black"/>
    </Border>
  </Border>
  <HierarchicalDataTemplate.Triggers>
    <Trigger Property="IsMouseOver" Value="True">
      <Setter Property="BorderBrush" TargetName="outterBorder" Value="#FFB8D6FB"/>
      <Setter Property="BorderBrush" TargetName="innerBorder">
        <Setter.Value>
          <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
            <GradientStop Color="#FFFCFDFE" Offset="0"/>
            <GradientStop Color="#FFF2F7FE" Offset="1"/>
          </LinearGradientBrush>
        </Setter.Value>
      </Setter>
      <Setter Property="Background" TargetName="innerBorder">
        <Setter.Value>
          <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
            <GradientStop Color="#FFFAFBFD" Offset="0"/>
            <GradientStop Color="#FFEBF3FD" Offset="1"/>
          </LinearGradientBrush>
        </Setter.Value>
      </Setter>
    </Trigger>
    <DataTrigger Binding="{Binding IsSelected}" Value="True">
      <Setter Property="BorderBrush" TargetName="outterBorder" Value="#FF7DA2CE"/>
      <Setter Property="BorderBrush" TargetName="innerBorder">
        <Setter.Value>
          <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
            <GradientStop Color="#FFEBF4FD" Offset="0"/>
            <GradientStop Color="#FFDBEAFD" Offset="1"/>
          </LinearGradientBrush>
        </Setter.Value>
      </Setter>
      <Setter Property="Background" TargetName="innerBorder">
        <Setter.Value>
          <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
            <GradientStop Color="#FFDCEBFC" Offset="0"/>
            <GradientStop Color="#FFC1DBFC" Offset="1"/>
          </LinearGradientBrush>
        </Setter.Value>
      </Setter>
    </DataTrigger>
  </HierarchicalDataTemplate.Triggers>
</HierarchicalDataTemplate>
Темплейт представляет из себя два Border-а, в которые заключён TextBlock. Border-ы используются для подсветки при выделении и наведении мышью, а TextBlock прибинден к Header-у модели.
После я реализовал вызов метода Select у ViewModel-и при изменении выделенного элемента дерева:
private void OnSelectionItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
    TreeViewModel viewModel = DataContext as TreeViewModel;
    if (viewModel != null &amp;&amp; e.NewValue != null)
    {
        viewModel.Select(e.NewValue as ItemModel);
    }
}
Конечно в том случае, если Вы используете какой-либо M-V-VM фреймворк, гораздо правильней было бы реализовать Select командой и вызывать её через behavior EventToCommand, но чтоб не загромождать пример я использовал такой вариант.
Итак, если сейчас запустить приложение, то можно увидеть, что кроме красивой рамочки из DataTemplate-а последний выделенный элемент ещё подсвечивается системным цветом подсветки. Это можно обойти реализовав свой стиль для TreeViewItem-а, в ControlTemplate-е которого не будет реализована подсветка по IsSelected. Но я пойду более простым путём и просто заменю цвет подсветки для данного элемента на белый:
<Style TargetType="TreeViewItem">
  <Style.Resources>
    <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="White"/>
  </Style.Resources>          
</Style>
Кроме визуальной проблемы с выделением TreeViewItem-а, которую я обошёл достаточно простым способом, есть более сложная проблема: событие SelectionItemChanged приходит при переходе фокуса к TreeViewItem-у, а не по клику, и, соответственно, если я выделю с Ctrl-ом несколько элементов, а потом без Ctrl-а кликаю на последний выделенный элемент, то ничего не происходит и все выделенные элементы так и останутся выделенными, тогда как ожидалось, что только кликнутый элемент будет выделен. Эту проблему можно решить различными способами. Например, в OnSelectionItemChanged переводить фокус ввода с элемента, этот способ достаточно прост, но при нём теряется возможность клавиатурной навигации по дереву. Можно использовать вместо события SelectionItemChanged событие TreeView.Select, которое с помощью AttachedProperty слать при клике на элемент, но в этом случае нужно учитывать, что это же событие будет слаться при получении элементом фокуса. На данный момент ни один из данных способов не устраивает меня в полной мере, поэтому я не буду выкладывать их реализацию. Так же теоретически возможен ряд других способов, но их я не пробовал на практике, поэтому описывать не буду. Если найдётся способ в полной мере удовлетворяющий моим требованиям, то я опишу его позже.

Скачать исходники:

КОММЕНТАРИИ


Дмитрий
Дмитрий 30.10.2014 9:04:44 #1
Большое спасибо за полезный пример!
Дмитрий
Дмитрий 30.10.2014 15:20:33 #2
Пришел в голову такой способ отлавливать события мыши, связанные с элементами дерева: связать Border с моделью через Tag, и в обработчике события Border'a через Tag получать доступ к модели.
В модели добавляем свойство объекта, возвращающее сам объект - это нужно будет для привязки:
public ItemModel Self { get { return this; } }

В Border'e (я взял внутренний) привязываем модель к свойству Tag и добавляем обработчик необходимых событий:
<Border x:Name="innerBorder" Tag="{Binding Path=Self}" PreviewMouseDown="innerBorder_PreviewMouseDown" ...

Теперь в обработчик события можем получить доступ к выделенному в дереве объекту:
private void innerBorder_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
ItemModel track = (sender as Border).Tag as ItemModel;
...

Я попробовал - у меня получилось. Может кому-то окажется полезным. Еще раз спасибо за полезный пример реализации!




НОВЫЙ КОММЕНТАРИЙ


*жирный*
_курсив_
+подчеркнутый+
! заголовок 1
!! заголовок 2
* список
** список 2
# нумерованый список
## нумерованый список 2
[url:http://www.example.com]
{"без форматирования"}
Полное описание синтаксиса