пятница, 24 мая 2013 г.

WPF: Работа с TreeView. Часть №1.

Недавно, в программе мне пришлось реализовывать отображение древовидной структуры. В WPF выбор без промедления пал на элемент TreeView. О том, как работать с TreeView ниже пойдет речь.

{В первой части, я опишу процесс работы с шаблонами/стилями более подробно}

TreeView - это элемент управления, который отображает иерархические данные в древовидной структуре и позволяет скрывать/показывать элементы. Инициализация в XAML происходит следующим способом:
        <TreeView Name="SampletreeView" FontFamily="Verdana" FontSize="14">
            <TreeViewItem Header="Настройки">
                <TreeViewItem Header="Внешний вид">
                    <TreeViewItem Header="Цвет фона" />
                    <TreeViewItem Header="Цвет шрифта" />                    
                </TreeViewItem>

                <TreeViewItem Header="Конфигурация">
                    <TreeViewItem Header="Подключение к серверу" />
                </TreeViewItem>
            </TreeViewItem>
        </TreeView>
Ниже предоставлен скриншот:


Лично меня стандартный вид не устроил и я решил поменять стиль TreeView. Стили отвечают за внешний вид элемента, поэтому чтобы задать вид, нужно править стиль. Например, чтобы задать цвет фона, шрифт, рамку и т.д. нужно править стиль. Если же нужно править содержимое элемента, то уже нужно менять шаблон. Также, стили связаны с шаблонами. Чтобы взять пример шаблона элемента, можно обратиться за помощью к MSDN: http://msdn.microsoft.com/ru-ru/library/ms752043.aspx. Наш же шаблон TreeView находится по адресу: http://msdn.microsoft.com/ru-ru/library/ms752048.aspx. Чтобы задать данный шаблон, нужно скопировать все части и поместить их в файл App.xaml, в котором будут хранится все стили и шаблоны элементов. По умолчанию данный файл "пустой", то есть не содержит стилей:
<Application x:Class="TreeViewPaper.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
         
    </Application.Resources>
</Application>
После вставки скачанных стилей, внешний вид нашего элемента станет вот таким:


Как мы видим, изменения на лицо. Вместо "плюсика" стала "стрелка", и изменился фон выделения текущего элемента. Давайте посмотрим на код, который отвечает за данные изменения:
        <Style x:Key="{x:Type TreeViewItem}" TargetType="{x:Type TreeViewItem}">
            <!-- 1: [Параметры по умолчанию] -->
            <Setter Property="Background" Value="Transparent" />
            <Setter Property="HorizontalContentAlignment" Value="{Binding Path=HorizontalContentAlignment, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}" />
            <Setter Property="VerticalContentAlignment" Value="{Binding Path=VerticalContentAlignment, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}}}" />
            <Setter Property="Padding" Value="1,0,0,0" />
            <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" />
            <Setter Property="FocusVisualStyle" Value="{StaticResource TreeViewItemFocusVisual}" />
            
            <!-- 2: [Шаблон] -->
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type TreeViewItem}">
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition MinWidth="19" Width="Auto" />
                                <ColumnDefinition Width="Auto" />
                                <ColumnDefinition Width="*" />
                            </Grid.ColumnDefinitions>
                            
                            <Grid.RowDefinitions>
                                <RowDefinition Height="Auto" />
                                <RowDefinition />
                            </Grid.RowDefinitions>

                            <!-- 3: [Менеджер визуальных состояний, группа - SelectionStates] --> 
                            <!-- 3.1: [Состояние: Selected (выбрано) ] --> 
                            <VisualStateManager.VisualStateGroups>
                                <VisualStateGroup x:Name="SelectionStates">
                                    <VisualState x:Name="Selected">
                                        <Storyboard>
                                            <ColorAnimationUsingKeyFrames Storyboard.TargetName="Bd" Storyboard.TargetProperty="(Panel.Background).(SolidColorBrush.Color)">
                                                <EasingColorKeyFrame KeyTime="0" Value="{StaticResource SelectedBackgroundColor}" />
                                            </ColorAnimationUsingKeyFrames>
                                        </Storyboard>
                                    </VisualState>

                                    <!-- 3.2: [Состояние: Unselected (не выбрано)] --> 
                                    <VisualState x:Name="Unselected" />

                                    <!-- 3.3: [Состояние: SelectedInactive (элемент выбран, но потерян фокус)] --> 
                                    <VisualState x:Name="SelectedInactive">
                                        <Storyboard>
                                            <ColorAnimationUsingKeyFrames Storyboard.TargetName="Bd" Storyboard.TargetProperty="(Panel.Background).(SolidColorBrush.Color)">
                                                <EasingColorKeyFrame KeyTime="0" Value="{StaticResource SelectedUnfocusedColor}" />
                                            </ColorAnimationUsingKeyFrames>
                                        </Storyboard>
                                    </VisualState>
                                </VisualStateGroup>

                                <!-- 4: [Менеджер визуальных состояний, группа - ExpansionStates] --> 
                                <VisualStateGroup x:Name="ExpansionStates">
                                    <!-- 4.1: [Состояние: Expanded (элемент раскрыт)] --> 
                                    <VisualState x:Name="Expanded">
                                        <Storyboard>
                                            <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)" Storyboard.TargetName="ItemsHost">
                                                <DiscreteObjectKeyFrame KeyTime="0" Value="{x:Static Visibility.Visible}" />
                                            </ObjectAnimationUsingKeyFrames>
                                        </Storyboard>
                                    </VisualState>

                                    <!-- 4.2: [Состояние: Collapsed (элемент закрыт)] --> 
                                    <VisualState x:Name="Collapsed" />                                    
                                </VisualStateGroup>
                            </VisualStateManager.VisualStateGroups>
                            
                            <!-- 5: [Кнопка-экспандер ] --> 
                            <ToggleButton x:Name="Expander" Style="{StaticResource ExpandCollapseToggleStyle}" ClickMode="Press" IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}}" />
                            
                            <!-- 6: [Рамка, в которой содержится заголовок родителя] --> 
                            <Border x:Name="Bd" Grid.Column="1" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Padding="{TemplateBinding Padding}">
                                <ContentPresenter x:Name="PART_Header" ContentSource="Header" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" />
                            </Border>
                            
                            <!-- 7: [Потомки, принадлежат родителю] --> 
                            <ItemsPresenter x:Name="ItemsHost" Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Visibility="Collapsed" />
                        </Grid>
                        
                        <!-- 8: [Триггеры шаблона] --> 
                        <ControlTemplate.Triggers>
                            <!-- 8.1: [Если родитель пустой, то скрывать кнопку-экспандер] --> 
                            <Trigger Property="HasItems" Value="false">
                                <Setter TargetName="Expander" Property="Visibility" Value="Hidden" />
                            </Trigger>
                            
                            <!-- 8.2: [Мультриггер для задания минимальной ширины] --> 
                            <MultiTrigger>
                                <MultiTrigger.Conditions>
                                    <Condition Property="HasHeader" Value="false" />
                                    <Condition Property="Width" Value="Auto" />
                                </MultiTrigger.Conditions>
                                
                                <Setter TargetName="PART_Header" Property="MinWidth" Value="75" />
                            </MultiTrigger>

                            <!-- 8.3: [Мультриггер для задания минимальной высоты] --> 
                            <MultiTrigger>
                                <MultiTrigger.Conditions>
                                    <Condition Property="HasHeader" Value="false" />
                                    <Condition Property="Height" Value="Auto" />
                                </MultiTrigger.Conditions>
                                
                                <Setter TargetName="PART_Header" Property="MinHeight" Value="19" />
                            </MultiTrigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
1. Параметры по умолчанию. В этом разделе задаются параметры, воспринимаемые стилем по умолчанию. Например, фон (background) изначально прозрачный (transparent), но если вы захотите задать его явно для элемента (<TreeViewItem Header="Настройки" Background="White" />), то текущий цвет будет тот, который вы явно задали. Это возможно благодаря строке Background="{TemplateBinding Background}", которая находится ниже в шаблоне.

2. Шаблон. Здесь задается сам шаблон. Как видите, шаблон является частью стиля, поэтому, на первый взгляд может возникать небольшая путаница. Но обычно стилем называют стиль, который меняет лишь параметры вида, а именно цвет фона, шрифта и т.д. А если в стиле есть шаблон, который меняет часть, либо само содержимое, то его могут называть шаблоном (хотя фактически это стиль).

3. Менеджер визуальных состояний, группа - SelectionStates. За динамический контент в WPF отвечают триггеры. Они разделяются на три группы: триггеры стиля, триггеры данных и триггеры шаблона (ниже мы их рассмотрим). Но в последствии, был разработан менеджер визуальных состояний, который  был внедрен в начале в Silverlight, а позже в WPF версии 3.5. Его задача - расширять работу визуального отображения элемента. Так как визуальных состояний у элемента может быть несколько, то их объединяют в группы. Первая группа - это SelectionStates. В ней содержатся все возможные состояния выделения элемента TreeViewItem, а именно: 

3.1. Состояние Selected (выбрано). Визуальное состояние должно иметь Storyboard, в котором и содержатся действия, применяемые к цели TargetProperty. В данном случае цель - это цвет панели (Panel.Background).(SolidColorBrush.Color), а действие - это установка произвольного цвета, взятого из ресурса Value="{StaticResource SelectedBackgroundColor}"

3.2. Состояние Unselected (не выбрано). Данное состояние является по умолчанию, и оно указывается здесь, чтобы задать имя начального состояния. Оно не является обязательным.

3.3. Состояние SelectedInactive. Элемент выбран, но TreeView потерял фокус.

4. Группа - ExpansionStates. Следующа группа - это состояние экспандера. Обычно экспандер можно раскрыть/показать, соответственно у него два состояния.

4.1. Состояние Expanded (раскрыт). Устанавливает видимость ItemsPresenter, отвечающего за размещение дочерних элементов.

4.2. Состояние Collapsed (закрыт). Состояние по умолчанию.

5. Кнопка-экспандер ToggleButton. Имеет свойство IsChecked, которое может содержать одно из трех значений: null, false, true. Здесь указывается стиль Style="{StaticResource ExpandCollapseToggleStyle}", в котором и содержится отображение нашей кнопки (рассмотрим ниже).

6. Рамка Border, в которой содержится заголовок. В рамке (Border) находится ContentPresenter с именем PART_Header, который отвечает за отображение заголовка. Просто изначально, у нас в заголовке может находится любой элемент, не только текст (TextBlock), поэтому здесь указывается ContentPresenter, который может содержать в себе любой визуальный элемент.

7. Потомки - ItemsPresenter. Здесь содержится ItemsPresenter, отвечает за отображение потомков, принадлежащих к заголовку-родителю.

8. Триггеры шаблона. Тут содержатся все триггеры, действия которых применяются к текущему шаблону. Единственное весомое отличие триггеров шаблона  <ControlTemplate.Triggers> от тригеров стилей <Style.Triggers> в том, что у триггеров стилей нельзя указать TargetName.

8.1. Если у родителя нет потомков Property="HasItems" Value="false", то скрывать кнопку-экспандер, так как у нас нечего показывать.

8.2; 8.3. Мультриггеры для задания минимальной высоты и ширины. Мультитриггер - это тот же триггер, только может содержать сразу несколько условий, они задаются в <MultiTrigger.Conditions>.

Ниже расположен стиль кнопки-экспандера ExpandCollapseToggleStyle:
        <Style x:Key="ExpandCollapseToggleStyle" TargetType="ToggleButton">
            <Setter Property="Focusable" Value="False" />
            
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="ToggleButton">
                        <Grid Width="15" Height="13" Background="Transparent">
                            <VisualStateManager.VisualStateGroups>
                                <VisualStateGroup x:Name="CheckStates">
                                    <VisualState x:Name="Checked">
                                        <Storyboard>
                                            <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)" Storyboard.TargetName="Collapsed">
                                                <DiscreteObjectKeyFrame KeyTime="0" Value="{x:Static Visibility.Hidden}" />
                                            </ObjectAnimationUsingKeyFrames>
                                            
                                            <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)" Storyboard.TargetName="Expanded">
                                                <DiscreteObjectKeyFrame KeyTime="0" Value="{x:Static Visibility.Visible}" />
                                            </ObjectAnimationUsingKeyFrames>
                                        </Storyboard>
                                    </VisualState>
                                    
                                    <VisualState x:Name="Unchecked" />
                                    
                                    <VisualState x:Name="Indeterminate" />
                                </VisualStateGroup>
                            </VisualStateManager.VisualStateGroups>
                            
                            <!-- 1: [Значок при скрытии потомков] --> 
                            <Path x:Name="Collapsed" HorizontalAlignment="Left" VerticalAlignment="Center" Margin="1,1,1,1" Data="M 4 0 L 8 4 L 4 8 Z">
                                <Path.Fill>
                                    <SolidColorBrush Color="{DynamicResource GlyphColor}" />
                                </Path.Fill>
                            </Path>

                            <!-- 2: [Значок при показе потомков] --> 
                            <Path x:Name="Expanded" HorizontalAlignment="Left" VerticalAlignment="Center" Margin="1,1,1,1" Data="M 0 4 L 8 4 L 4 8 Z" Visibility="Hidden">
                                <Path.Fill>
                                    <SolidColorBrush Color="{DynamicResource GlyphColor}" />
                                </Path.Fill>
                            </Path>
                        </Grid>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
Здесь мы видим знакомый нам менеджер визуальных состояний с тремя состояниями: Checked, Unckecked и Indeterminate. При состояниях Unckecked и Indeterminate значки не меняются. В состоянии Checked задается скрытие - Visibility.Hidden для значка закрытия, а значок открытия становится видимым - Visibility.Visible. Тем самым реализуется замена одного значка на другого. Дополнительные комментарии:

1. Значок скрытия. Здесь задается объект, который показывает, что список потомков является закрытым. Объект может быть типа Image, Path, и других.

2. Значок показа. Аналогично, но значок должен соответствовать открытию списка.

Допустим, мы хотим задать другие значки. Для этого мы просто берем соответствующие объекты и заменяем их в стиле кнопки. Я для себя взял стрелки в стиле Metro и поменял размер главного грида на Width=17, Height=15, чтобы они благополучно помещялись:
<Path x:Name="Collapsed" Width="12" Height="15" HorizontalAlignment="Left" VerticalAlignment="Center" Stretch="Fill" Margin="1,1,1,1" Data="F1 M 39.8307,37.6042L 36.6641,34.4375L 25.1849,23.3542L 35.4766,23.3542L 50.5182,37.6042L 35.4766,51.8542L 25.1849,51.8542L 36.6641,40.7708L 39.8307,37.6042 Z ">
     <Path.Fill>
         <SolidColorBrush Color="{DynamicResource GlyphColor}" />
     </Path.Fill>
</Path>

<Path x:Name="Expanded" Width="15" Height="12" HorizontalAlignment="Left" VerticalAlignment="Center" Stretch="Fill" Margin="1,1,1,1" Data="F1 M 37.8516,39.5833L 52.1016,24.9375L 52.1016,35.2292L 37.8516,50.2708L 23.6016,35.2292L 23.6016,24.9375L 37.8516,39.5833 Z " Visibility="Hidden">
     <Path.Fill>
         <SolidColorBrush Color="{DynamicResource GlyphColor}" />
     </Path.Fill>
</Path>
Теперь наше дерево выглядит так:


Зададим немного пространства между элементами. Для этого существует параметр Padding. Так как Padding нужно указывать для каждого элемента TreeView, то нужно его задавать в стиле. Но как быть, стиль ведь у нас уже есть? WPF позваляет наследовать стили. Наследование осуществляется с помощью ключевого слова BasedOn, следовательно нам нужно наследовать базовый стиль, и добавить/заменить нужные нам параметры:
<Style TargetType="{x:Type TreeViewItem}" BasedOn="{StaticResource {x:Type TreeViewItem}}">
    <Setter Property="Padding" Value="5" />
</Style>
Данный стиль следует поместить в ресурсы окна - <Window.Resources>. После этого, вид будет таким:


Добавим немного интерактивности для нашего дерева. Начнем с кнопки-экспандера. Пусть при наведении курсора на стрелку, у нее будет меняться цвет и выходить подсказка (ToolTip) с действием, типа: "Раскрыть/Закрыть". Для этого найдем знакомый нам стиль кнопки ExpandCollapseToggleStyle и добавим туда триггеры шаблона: 
<ControlTemplate.Triggers>
    <!-- 1: [Срабатывает при наведение курсора: для закрытой стрелки] -->
    <Trigger Property="IsMouseOver" Value="True">
        <Setter TargetName="Collapsed" Property="Fill" Value="OrangeRed" />
        <Setter TargetName="Collapsed" Property="ToolTip" Value="Раскрыть" />
    </Trigger>

    <!-- 2: [Срабатывает при наведение курсора: для открытой стрелки] -->
    <Trigger Property="IsMouseOver" Value="True">
        <Setter TargetName="Expanded" Property="Fill" Value="OrangeRed" />
        <Setter TargetName="Expanded" Property="ToolTip" Value="Закрыть" />
    </Trigger>
</ControlTemplate.Triggers>
Теперь наша стрелка стала более дружелюбной:


Сделаем аналогичное выделение для заголовка. Добавим в стиль TreeViewItem триггер:
<Trigger SourceName="PART_Header" Property="IsMouseOver" Value="True">
    <Setter TargetName="Bd" Property="BorderBrush" Value="OrangeRed" />
</Trigger>
И в раздел начальных значений добавим <Setter Property="BorderThickness" Value="1" />. Обратите внимание, что источник должен быть PART_Header, если его не указать, то выделяться будет не только заголовок, но и его потомки. Ниже представлен результат:


Также я поменял цвет выделения текущего элемента, он у нас находится в ресурсах с именем SelectedBackgroundColor. Я задал для него #F5B79C:
<Color x:Key="SelectedBackgroundColor">#F5B79C</Color>
Зададим цвет шрифта черно-серым, добавляя строчку <Setter Property="Foreground" Value="#565656" /> в <Window.Resources>. Для выбранного заголовка зададим произвольный цвет, подчеркивая выделенное. Чтобы это реализовать, посмотрим на стиль TreeViewItem, а конкретно на состояние Selected. Добавляем еще один объект к Storyboard, который бы задавал произвольный цвет шрифта:
<!-- Группа SelectionStates, состояние Selected -->
<ColorAnimationUsingKeyFrames Storyboard.TargetName="Bd" Storyboard.TargetProperty="(TextBlock.Foreground).(SolidColorBrush.Color)">
     <EasingColorKeyFrame KeyTime="0" Value="{StaticResource SelectedForegroundColor}" />
</ColorAnimationUsingKeyFrames>
Определяем новый цвет SelectedForegroundColor:
<Color x:Key="SelectedForegroundColor">Black</Color>
Если запустить программу с данными изменениями, выйдет RuntimeException, потому что у рамки Bd не задано свойство TextBlock.Foreground="{TemplateBinding Foreground}", и поэтому наше дополнение к Storyboard не может найти данное свойство. Взглянем теперь на слегка измененное дерево:



Дерево может содержать различные элементы, названия которых могут быть аббревиатуры, поэтому хорошо было бы задать для каждого из них подсказку (ToolTip). Зададим подсказку таким образом:
<TreeViewItem Header="Настройки" ToolTip="Настройки">
После этого заветный ToolTip появляется, но все потомки его наследуют, и если, например, навести на элемент "Внешний вид" (у которого не задан ToolTip), то у него появляется подсказка "Настройки", что естественно, не есть хорошо. Давайте попробуем убрать этот ToolTip. Первое что пришло на ум, это задать для потомков, у которых не должно быть подсказки параметр ToolTipService.IsEnabled="False". Но, как показывает практика, такой финт не прошел. Свойство IsOpen у элемента ToolTip, доступно только для чтения, поэтому задать мы его не сможем. Задание типа {x:Null} дает такой же результат. После гугления, было найдено сообщение на форуме www.cyberforum.ru с подобным же вопросом. Пользователь с ником kenny69 предложил следуещее решение:
<TreeView Name="treeView1">
     <TreeView.Items>
          <TreeViewItem>
               <TreeViewItem.Header>
                    <TextBlock ToolTip="Родительский элемент">Parent Node</TextBlock>
               </TreeViewItem.Header>
                    
               <TreeViewItem Header="Child Node"/>                 
          </TreeViewItem>
     </TreeView.Items>
</TreeView>
То есть он просто задает для заголовка принудительно элемент TextBlock, у которого есть объект ToolTip. Такой прием не сработает, ведь мы теперь переписали наш заранее определенный стиль своим элементом, у которого нет стиля. Соответственно, нужно теперь задавать другие стили, что не есть хорошо :). Я решил пойти другим путем. Попробывал создать простой стиль, в котором задавались бы ширина и высота:
<!-- Стиль для скрытия ToolTip -->
<Style x:Key="NullToolTip" TargetType="{x:Type ToolTip}">
     <Setter Property="Width" Value="0" />
     <Setter Property="Height" Value="0" />
     <Setter Property="Content" Value="{x:Null}" />
</Style>
Как видно из кода, мы просто задали нулевую ширину и высоту, с содержимым типа {x:Null}. Теперь зададим его для TreeViewItem:
<TreeViewItem Header="Внешний вид">
     <TreeViewItem.ToolTip>
          <ToolTip Style="{StaticResource NullToolTip}" />
     </TreeViewItem.ToolTip>
...
После проверки, данный прием оказывается рабочим, но писать такую конструкцию для каждого элемента затруднительно, поэтому нужно подумать, как ее сократить. Благодаря гибкой поддержки ресурсов в WPF, мы можем задать практически любой элемент в ресурсе. Единственное требование, это наличие ключа Key у каждого элемента, определенного в ресурсах. Определяем ToolTip с нулевым стилем в ресурсах:
<ToolTip x:Key="NoToolTip" Style="{StaticResource NullToolTip}" />
И задаем для элемента:
<TreeViewItem Header="Внешний вид" ToolTipService.ToolTip="{StaticResource NoToolTip}">
Теперь мы можем задать данную строку в стиле, который наследовали от базового. Ниже предоставлено полное решение скрытия ToolTip:
<Style x:Key="NullToolTip" TargetType="{x:Type ToolTip}">
     <Setter Property="Width" Value="0" />
     <Setter Property="Height" Value="0" />
     <Setter Property="Content" Value="{x:Null}" />
</Style>

<ToolTip x:Key="NoToolTip" Style="{StaticResource NullToolTip}" />

<Style TargetType="{x:Type TreeViewItem}" BasedOn="{StaticResource {x:Type TreeViewItem}}">
     <Setter Property="Padding" Value="5" />
     <Setter Property="Foreground" Value="#565656" />
     <Setter Property="ToolTipService.ToolTip" Value="{StaticResource NoToolTip}" />
</Style>
Во второй части, я расскажу про работу с функционалом элемента TreeView. Спасибо за внимание!

1 комментарий:


profile for Anatoliy Nikolaev at Stack Overflow, Q&A for professional and enthusiast programmers