Разработка элемента управления ListView с автоматической фильтрацией

В этой статье описывается разработка элемента управления ListView с автоматической фильтрацией в стиле Excel.

Введение

В Microsoft Excel есть очень полезная функция автоматической фильтрации. Если эта функция включена для столбца, пользователь может выбрать из раскрывающегося списка фильтры, которые будучи выбраны, применяются к столбцу. В этой статье описывается, как добавить аналогичные функциональные возможности фильтра в элемент управления ListView WPF.

Предыстория

До появления WPF настройка элемента управления была сложной и даже запутанной задачей. С помощью WinForms можно выполнить определенный объем настроек, если элемент управления предоставляет события, позволяющие разработчику настроить некоторые аспекты стиля до визуализации. В других элементах управления, чтобы выполнить дополнительную обработку после визуализации самого элемента управления, можно переопределить метод Paint. Но любые значительные изменения внешнего вида элемента управления, вероятно, потребуют от разработчика взять на себя выполнение визуализации всего элемента управления (рисование владельцем). В итоге настройка элемента управления с помощью WinForms оказывается похожей на хакерство!

С появлением WPF ситуация значительно улучшилась, механизмы, используемые элементами управления для своей визуализации, стали намного лучше видны разработчику. Использование различных шаблонов (для элементов управления и данных) позволяет разработчику с легкостью выполнять любые действия, от минимальных настроек внешнего вида до полной визуальной переработки. Важным различием между WPF и WinForms является то, что в WPF процесс визуализации элемента управления доступен явно, а в WinForms он скрыт.

Шаг 1. С чего начать

Мне хотелось, чтобы этот элемент управления не только предоставлял функциональные возможности фильтрации, но также позволял пользователю выполнять сортировку щелчком заголовка столбца. Вместо повторного изобретения колеса я нашел элемент ListView с поддержкой сортировки в блоге Джоела Румермана (Joel Rumerman), который оказался подходящей отправной точкой для моего элемента управления.

На следующем рисунке показан этот элемент управления в действии, со списком, отсортированным по имени (Forename):

В качестве начальной точки для этого элемента управления я создал подкласс класса SortableListView, FilterableListView.

Шаг 2. Добавление элементов управления

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

Основная проблема с элементом управления ListView заключается в том, что хотя XAML-код для вставки этого элемента управления достаточно прост, как показано ниже, фактически за этой простотой скрыто немало сложного. Если просмотреть видимое дерево для ListView с помощью отладчика, такого как Mole, будет видно, что элемент ListView состоит из многих визуальных элементов, многие из которых, возможно, хотелось бы изменить.

<ListView> 
    <ListView.View>
        <GridView>
            <GridViewColumn Header="Column Header"/> 
        </GridView>
    </ListView.View>
</ListView>

В блогах существует множество отличных постов, описывающих, как изменять различные визуальные аспекты элемента управления ListView, включая статью Стилизация элемента ListView, в которой описывается, как изменить стиль контейнера ItemContainer, который (как можно догадаться!) содержит элементы списка, шаблон CellTemplate для каждого столбца GridViewColumn, стиль ColumnHeaderContainerStyle и т. д. Основной принцип состоит в том, что элемент ListView предоставляет шаблоны и стили создаваемых визуальных элементов в качестве свойств самого элемента ListView. Чтобы добиться нужного эффекта, важно знать, какие из этих свойств нужно использовать.

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

Чтобы добиться вышеописанного, нужно изменить само содержимое заголовка столбца, а не визуальный стиль всего заголовка. Следовательно, добавлять эти элементы управления нужно в шаблон данных заголовка столбца. Следующий XAML-код, показывает, как это делается.

<DataTemplate x:Key="FilterGridHeaderTemplate">
    <StackPanel Orientation="Horizontal">
        <!-- визуализировать текст заголовка -->
        <TextBlock HorizontalAlignment="Center" 
          VerticalAlignment="Center" Text="{Binding}"/>
        <!-- добавить метку, используемую для отображения индикатора сортировки -->
        <Label Name="sortIndicator" 
          VerticalAlignment="Center" 
          Style="{StaticResource HeaderTemplateTransparent}"/>            
        <!-- Добавить кнопку фильтра и всплвающее окно  -->
        <Button  ContentTemplate="{StaticResource filterButtonInactiveTemplate}"
                 Name="filterButton" 
                 Command="{x:Static c:FilterableListView.ShowFilter}"/>
        <Popup StaysOpen="false" 
               Name="filterPopup" Placement="Bottom"                    
               PlacementTarget="{Binding ElementName=filterButton}">
            <ListView x:Name="filterList" 
                   ItemsSource="{Binding}"  BorderThickness="1"                             
                   ItemContainerStyle="{StaticResource ListItemRolloverHighlight}">
               <ListView.View>
                    <GridView>
                        <!-- скрыть заголовок столбца -->
                        <GridView.ColumnHeaderContainerStyle>
                            <Style TargetType="GridViewColumnHeader">
                                <Setter Property="Visibility" Value="Hidden" />
                                <Setter Property="Height" Value="0" />
                            </Style>
                        </GridView.ColumnHeaderContainerStyle>
                        <GridViewColumn DisplayMemberBinding="{Binding Path=ItemView}"/>
                    </GridView>
                </ListView.View>
            </ListView>
        </Popup>
    </StackPanel>
</DataTemplate>

Тем, кто ознакомился с упомянутым выше блогом, известно, что у каждого столбца GridViewColumn есть свойство HeaderTemplate, которое можно использовать, чтобы задать шаблон данных для заголовка столбца. Можно было просто определить, что, когда разработчик использует элемент управления FilterableListView, он должен соответствующим образом задать HeaderTemplateдля каждого столбца, но использование элемента управления становится намного более удобным, если описанный выше шаблон автоматически становится шаблоном данных для заголовка столбца.

Для этого назначим в коде свойство HeaderTemplate в методе OnInitialised нашего элемента ListView следующим образом:

protected override void OnInitialized(EventArgs e)
{
    base.OnInitialized(e);
 
    Uri uri = new Uri("/Controls/FiterListViewDictionary.xaml", UriKind.Relative);
    dictionary = Application.LoadComponent(uri) as ResourceDictionary;
  
    // привести свойство View элемента ListView к GridView 
    GridView gridView = this.View as GridView;
    if (gridView != null)
    {
        foreach (GridViewColumn gridViewColumn in gridView.Columns)
        {
            gridViewColumn.HeaderTemplate = 
               (DataTemplate)dictionary["FilterGridHeaderTemplate"];                    
        }
    }
}

Шаг 3. Отображение всплывающего окна

Когда компоненты помещаются в элемент в Window, обработка событий оказывается простым процессом связывания событий с обработчиками в программной части. Но в данном случае наш шаблон данных определен в словаре ресурсов. Сначала это может показаться удивительным, но способ напрямую связать событие щелчка мышью с кодом отсутствует! Подробнее эта проблема описана в следующей статье: Команды и элементы управления в WPF, предлагающей в качестве решения использовать команды, предоставляющие намного более свободное связывание представления (XAML) и управления (программная часть).

В примере кода 2 можно видеть, что кнопка фильтра выдает команду ShowFilter. Это событие RoutedEvent, которое будет туннелировать и всплывать сквозь видимое дерево. Элемент FilterableListView использует привязку команд, позволяя элементу обрабатывать это событие.

Когда FilterableListView обрабатывает команду ShowFilter, этому элементу понадобится определить следующее:

  • Имя свойства, которое будет использоваться для фильтрации.
  • Всплывающее окно, которое будет связано с этой кнопкой.

Простейший способ получить эти сведения — просмотреть видимое дерево, сначала вверх, до заголовка столбца, чтобы получить имя свойства для выполнения фильтрации, затем вниз от заголовка, чтобы найти связанное всплывающее окно. Класс VisualTreeHelper предоставляет ряд статических вспомогательных методов для перемещения по дереву, которые Эндрю Виддетт (Andrew Whiddett) заключил в класс WPFHelper, который улучшает описанные функциональные возможности, позволяя найти в видимом дереве классы, соответствующие определенным условиям (имя, тип и т. д.).

public FilterableListView()
{
    CommandBindings.Add(new CommandBinding(ShowFilter, ShowFilterCommand));            
}
 
private void ShowFilterCommand(object sender, ExecutedRoutedEventArgs e)
{
    Button button = e.OriginalSource as Button;
    
    // перейти вверх к заголовку 
    GridViewColumnHeader header = (GridViewColumnHeader)
        Helpers.FindElementOfTypeUp(button, typeof(GridViewColumnHeader));
  
    // затем вниз к всплывающему окну 
    Popup popup = (Popup)Helpers.FindElementOfType(header, typeof(Popup));
  
    if (popup != null)
    {
        // найти имя свойства, используемого для фильтрации 
        SortableGridViewColumn column = (SortableGridViewColumn)header.Column;
        String propertyName = column.SortPropertyName;
  
        // очистить следующий фильтр 
        filterList.Clear();
  
        PropertyDescriptor filterPropDesc =
            TypeDescriptor.GetProperties(typeof(Employee))[propertyName];
  
        // перебрать все объекты в списке 
        foreach (Object item in Items)
        {
            object value = filterPropDesc.GetValue(employee);
            if (value != null)
            {
                FilterItem filterItem = new FilterItem(value as IComparable);
                if(!filterList.Contains(filterItem))
                {
                    filterList.Add(filterItem);
                }
            }
        }
 
        filterList.Sort();
 
 
        // открыть всплывающее окно для отображения списка 
        popup.DataContext = filterList;
        popup.IsOpen = true;
  
        // подключить к событию изменения выбора 
        ListView listView = (ListView)popup.Child;
        listView.SelectionChanged += SelectionChangedHandler;
    }
}

Шаг 4. Применение фильтра

Заключительный шаг — просто обработать событие SelectionChange, источником которого является элемент ListView, создавая соответствующие фильтры и применяя их к элементам Item списка. Если при использовании представления FilterableListс фильтром, заданным для одного из столбцов, нужно применить фильтр к одному из других столбцов, можно ожидать, что раскрывающийся список будет содержать только элементы, являющиеся результатом применения первого фильтра. Другими словами, все фильтры объединяются с помощью логического И. Самое интересное, что эта возможность дается даром! Это вызвано тем, что мы заполнили раскрывающийся список фильтра, перебирая свойство Items нашего элемента ListView. Свойство Items является производным от элемента System.Windows.Data.CollectionView, который, в соответствии с именем, является представлением для привязанных данных, поддерживающим эффект применения группировки, сортировки, фильтрации и т. д.

// создать фильтр и применить его                
Items.Filter = delegate(object item)
{
    // при применении фильтра к каждому элементу выполнить перебор по всем 
    // текущим фильтрам 
    bool match = true;
    foreach (KeyValuePair<String, FilterStruct> filter in currentFilters)
    {
        FilterStruct filter = filter.value;
  
        // получить значение этого свойства для тестируемого элемента 
        PropertyDescriptor filterPropDesc =
            TypeDescriptor.GetProperties(typeof(Employee))[filter.property];
        object itemValue = filterPropDesc.GetValue((Employee)item);
  
        if (itemValue != null)
        {
            // проверить, удовлетворяются ли заданные условия фильтрации 
            if (!itemValue.Equals(filter.value.Item))
                match = false;
        }
        else 
        {
            if (filter.value.Item != null)
                match = false;
        }
    }
    return match;
};

Практическое использование этого элемента управления

Использование элемента управления FilterableListViewтак же просто, как и использование обычного элемента ListView. Единственным отличием является добавление еще одного свойства SortPropertyName, которое является свойством, используемым для сортировки / фильтрации конкретного столбца.

<slogic:FilterableListView ItemsSource="{Binding}">
    <ListView.View>
        <GridView>
            <slogic:SortableGridViewColumn Header="Surname"
               SortPropertyName="LastName" 
               DisplayMemberBinding="{Binding Path=LastName}"/>
            <slogic:SortableGridViewColumn Header="Forename"
               SortPropertyName="FirstName" 
               DisplayMemberBinding="{Binding Path=FirstName}" />
            <slogic:SortableGridViewColumn Header="Salary"
               SortPropertyName="Salary" 
               DisplayMemberBinding="{Binding Path=Salary}" />
            <slogic:SortableGridViewColumn Header="Start Date"
               SortPropertyName="StartDate" 
               DisplayMemberBinding="{Binding Path=StartDate}" />
        </GridView>
    </ListView.View>
</slogic:FilterableListView>

Но элемент FilterableListView страдает от той же проблемы, что и элемент ListView. Этот элемент управления состоит из множества других элементов управления, которые разработчик не создает явно в своем XAML-коде. Элемент FilterableListView использует тот же подход, что и ListView — он предоставляет разработчику содержащиеся в нем свойства, используя свойства зависимости. Для настройки стиля кнопки раскрывающегося списка можно использовать свойства FilterButtonActiveи FilterButtonInactive. (Примечание. Я не показал все возможные проблемы, которые могут быть интересными, оставив это в качестве упражнения для читателя!)

На следующем рисунке приведены два примера элементов управления FilterableListViewв действии. В одном используется стиль по умолчанию, а в другом изменены стили заголовка столбца, кнопок и контейнеров элементов списка ListView.

Заключение

В этой статье показано, как улучшить функциональные возможности предоставленного элемента управления ListView, чтобы добавить поддержку автоматической фильтрации в заголовки столбцов. Элемент управления ListView в WPF сначала кажется слегка недоступным, но его настройка оказывается ненамного сложнее, чем настройка других элементов управления в библиотеках WPF.