вторник, 12 апреля 2011 г.

TreeView с элементами CheckBox в WPF

В данной статье описывается реализация дерева TreeView элементы которого содержат контролы CheckBox в Windows Presentation Foundation (WPF).

Казалось бы ничего сложного, но подход в WPF сильно отличается от Windows Forms, где тоже самое реализуется намного проще. В WPF совсем другой подход, более гибкий, но данная гибкость привносит и определенную сложность, хотя один раз разобравшись, потом все идет как по маслу, можно крутить, вертеть контролы как душе угодно, в Windows Forms это сделать очень проблематично.

В сети существует прекрасное решение от Josh Smith Working with Checkboxes in the WPF TreeView. Но оно показалось мне довольно громоздким и я решил сделать что-нибудь по проще.

В итоге получим это.

Итак начнем.

Создадим базовый класс Node, который связывает состояние свойств контролов TextBlock, TreeViewItem и CheckBox, за счет чего мы можем манипулировать ими как единым целым. Связывание происходит за счет наследования интерфейса INotifyPropertyChanged, который уведомляет клиентов об изменении значения свойств объекта и класса ObservableCollection, представляющий собой коллекцию динамических данных, обеспечивающих выдачу уведомления при получении и удалении элементов или при обновлении всего списка. В класс Node также включены параметры контролов TextBlock, CheckBox и TreeViewItem, которые затем связываются с ними в XAML.

Класс Node:
public class Node : INotifyPropertyChanged
    {
        public Node()
        {
            this.id = Guid.NewGuid().ToString();
        }

        private ObservableCollection<Node> children = new ObservableCollection<Node>();
        private ObservableCollection<Node> parent = new ObservableCollection<Node>();
        private string text;
        private string id;
        private bool? isChecked = true;
        private bool isExpanded;
        
        public ObservableCollection<Node> Children
        {
            get { return this.children; }
        }

        public ObservableCollection<Node> Parent
        {
            get { return this.parent; }
        }

        public bool? IsChecked
        {
            get { return this.isChecked; }
            set
            {
                this.isChecked = value;
                RaisePropertyChanged("IsChecked");
            }
        }

        public string Text
        {
            get { return this.text; }
            set
            {
                this.text = value;
                RaisePropertyChanged("Text");
            }
        }

        public bool IsExpanded
        {
            get { return isExpanded; }
            set
            {
                isExpanded = value;
                RaisePropertyChanged("IsExpanded");
            }
        }

        public string Id
        {
            get { return this.id; }
            set
            {
                this.id = value;
            }
        }
    }

Разберемся в этом хламе.

Свойства класса Node:

  • Основными элементами здесь являются объекты типа ObservableCollection Children и Parent, которые включают в себя друг друга, создавая при этом объектную модель дерева.
  • Text принадлежит контролу TextBlock, который служит для отображения текстового значения.
  • Id связывается в XAML с параметром контрола CheckBox Uid. Каждый раз, при генерации нового экземпляра класса Node, генерируется новый, уникальный Id для идентификации элемента дерева TreeView.
  • IsChecked принадлежит контролу CheckBox. Принимает три значения - true, false и null. Значение null принимается в том случае, если хоть один из дочерних элементов иеем значение false.
  • IsExpanded принадлежит контролу TreeViewItem и сообщает состояние элемента, развернуто или свернуто.
Свойства Text, IsChecked и IsExpanded подписаны на событие PropertyChanged, которое также добавляем в наш класс.

public event PropertyChangedEventHandler PropertyChanged;

Класс Node создан, теперь необходимо связать все это с вышеперечисленными котролами в XAML. Для этого в коде XAML прописываем следующее.

<Window.Resources>
    <HierarchicalDataTemplate DataType="{x:Type local:Node}" ItemsSource="{Binding Children}">
        <StackPanel Orientation="Horizontal">
        <CheckBox IsChecked="{Binding IsChecked}" Uid="{Binding Id}" />
        <TextBlock Text="{Binding Text}"/>                
        </StackPanel>
    </HierarchicalDataTemplate>
    <Style TargetType="TreeViewItem">
        <Setter Property="IsExpanded" Value="{Binding Path=IsExpanded, Mode=TwoWay}" />
    </Style>
</Window.Resources>

И добавляем TreeView

<TreeView Name="treeView"/>

Все, наша модель TreeView с контролами CheckBox готова, теперь добавим основную логику в наше псевдо приложение.

Начнем с обработки изменения состояния контрола CheckBox. Добавим в класс Node следующий метод, который обрабатывает события PropertyChanged. Данное событие возникает при изменении значения подписанных на него свойств.

private void RaisePropertyChanged(string propertyName)
{
    if (this.PropertyChanged != null)
        this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    if (propertyName == "IsChecked")
    {
        if (this.Id == CheckBoxId.checkBoxId && this.Parent.Count == 0 && this.Children.Count != 0)
        {
            CheckChildNodes(this.Children, this.IsChecked);
        }
        if (this.Id == CheckBoxId.checkBoxId && this.Parent.Count > 0 && this.Children.Count > 0)
        {
            CheckChildAndParent(this.Parent, this.Children, this.IsChecked);
        }
        if (this.Id == CheckBoxId.checkBoxId && this.Parent.Count > 0 && this.Children.Count == 0)
        {
            CheckParentNodes(this.Parent);
        }
    }            
}

Данный код обрабатывает событие изменения значения свойства IsChecked. Для обработки события необходимо знать какой же именно CheckBox был источником события, для этого дополнительно вводится небольшая структура, содержащая свойство checkBoxId, с помощью которого мы идентифицируем наш контрол.

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

private void CheckChildAndParent(ObservableCollection<Node> itemsParent, ObservableCollection<Node> itemsChild, bool? isChecked)
{
    CheckChildNodes(itemsChild, isChecked);
    CheckParentNodes(itemsParent);
}

 private void CheckChildNodes(ObservableCollection<Node> itemsChild, bool? isChecked)
{
    foreach (Node item in itemsChild)
    {
        item.IsChecked = isChecked;
        if (item.Children.Count != 0) CheckChildNodes(item.Children, isChecked);
    }
}

private void CheckParentNodes(ObservableCollection<Node> itemsParent)
{
    int countCheck = 0;
    bool isNull = false;
    foreach (Node paren in itemsParent)
    {
        foreach (Node child in paren.Children)
        {
            if (child.IsChecked == true || child.IsChecked == null)
            {
                countCheck++;
                if (child.IsChecked == null)
                    isNull = true;
            }
        }
        if (countCheck != paren.Children.Count && countCheck != 0) paren.IsChecked = null;
        else if (countCheck == 0) paren.IsChecked = false;
        else if (countCheck == paren.Children.Count && isNull) paren.IsChecked = null;
        else if (countCheck == paren.Children.Count && !isNull) paren.IsChecked = true;
        if (paren.Parent.Count != 0) CheckParentNodes(paren.Parent);
    }
}

На этом закончим с классом Node и перейдем в back-end нашего окна с TreeView, где добавим методы создания дерева и вспомогательные методы обработки событий мыши и клавиатуры.

Для идентификации элемента уже была создана структура CheckBoxId и нам необходимо передать ей значение свойства Uid контрола CheckBox, для этого создаем два метода, обрабатывающих события клика мыши и нажатия клавиши пробел на клавиатуре.

В back-end код добавляем следующее.

private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    CheckBox currentCheckBox = (CheckBox)sender;
    CheckBoxId.checkBoxId = currentCheckBox.Uid;
}

private void OnKeyDown(object sender, KeyEventArgs e)
{
    if (e.Key==Key.Space)
    {
        CheckBox currentCheckBox = (CheckBox)sender;
        CheckBoxId.checkBoxId = currentCheckBox.Uid;
    }
}

И прописываем эти методы в XAML, добавив обработку событий для CheckBox.

<CheckBox IsChecked="{Binding IsChecked}" Uid="{Binding Id}" PreviewMouseLeftButtonDown="OnMouseLeftButtonDown" PreviewKeyDown="OnKeyDown" />

Ну и теперь добавим метод генерации самого дерева. Но сначала создадим свойство класса ObservableCollection<Node>, которое будет нашим объектным отображением дерева TreeView.

public ObservableCollection<Node> Nodes { get; private set; }

И инициализируем его при создании окна, добавив в контруктор класса следующую строку.

Nodes = new ObservableCollection<Node>();

Метод генерации дерева:

private void FillingTree()
{
    Nodes.Clear();
    for (int i = 0; i < 5; i++)
    {
        var level_1_items = new Node() { Text = " Level 1 Item " + (i + 1) };
        for (int j = 0; j < 2; j++)
        {
            var level_2_items = new Node() { Text = " Level 2 Item " + (j + 1) };
            level_2_items.Parent.Add(level_1_items);
            level_1_items.Children.Add(level_2_items);
            for (int n = 0; n < 2; n++)
            {
                var level_3_items = new Node() { Text = " Level 3 Item " + (n + 1) };
                level_3_items.Parent.Add(level_2_items);
                level_2_items.Children.Add(level_3_items);
            }
        }

        Nodes.Add(level_1_items);
    }
    treeView.ItemsSource = Nodes;
}

Подытожим повествование.

Итак теперь наше дерево получило адекватное поведение. Используя свойство Nodes можно манипулировать элементами дерева как угодно, сворачивать разворачивать дерево, менять свойства IsChecked для всех элементов разом, легко менять текстовое поле элементов. Главным минусом решения является генерация самого дерева, хотелось бы конечно присваивать коллекцию напрямую, но думать и гадать как это реализовать мне было лень, для моего проекты вполне сгодилось и такое решение.

Весь исходный код проекта лежит здесь. Там также имеются методы сворачивания и разворачивания дерева, смена свойства IsChecked для всех элементов разом, включая инвертирование.

3 комментария:

  1. крутая штука, сразу все понятно. сначала нашел решение от Josh Smith. тяжело его вкуривать :)
    спасибо

    ОтветитьУдалить
  2. а вообще решение бажное. попробуйте выделить все узлы, потом снять выделение с узла в середине дерева и понажимать "анчекед алл" и "чекед алл" :) я вылечил, если интересно пишите
    monstr_01@mail.ru

    и идея с использованием идентификаторов при чекед-анчекед узла не очень понравилась, подумаю как избавиться от них

    ОтветитьУдалить
  3. Это из серии как не надо делать, решение от Josh Smith гораздо меньшее по количеству кода, не понятно зачем в экземпляре список из Parent? У вашего TreeItem несколько родительских Node? Там можно обойтись и Node Parent; Вписывать условия в RaisePropertyChanged - дичь. Что мешает вписать условие там где вызывается RaisePropertyChanged? ID вообще не нужен. Я мог бы расписать подробнее, но выйдет аналог решения от Josh Smith, моя версия отличается обработкой Update без параметров, и соответсвенно SetChecked. Ну и List тоже заменил на ObservableCollection.

    ОтветитьУдалить