Стилизация элементов пользовательского интерфейса в экосистеме .net/WPF «позволяет разработчикам и дизайнерам создавать визуально привлекательные эффекты и согласованный внешний вид своих продуктов» [docs.microsoft.com] На первый взгляд, это аналогично разделению веб-страниц на семантическое содержание в HTML и  оформление в CSS.

Однако, стилизация в WPF является гораздо более мощным инструментом, позволяющим существенно обогатить интерфейс программы (UI) без непосредственного изменения кода приложения.

Однажды потребовалось в приложении добавить во все таблицы возможность быстрой фильтрации по содержимому. Пожелание заказчика выглядело примерно так:

То есть необходимо изменить поведение для каждого столбца каждого элемента DataGrid каждого экрана. Внести изменения в почти сотню форм.

Однако, проект был выполнен на WPF, поэтому в конечном счёте в самом приложении оказалось достаточно … добавить одну строку:

<ResourceDictionary Source="Themes/ItemsFilterStyle.xaml" />

И, конечно, подключить библиотеку, которую предстояло ещё разработать :=)

И что, это сработает?

Разумеется. Через внедрение поведения через стиль.

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

Для тех, кто не знаком с технологией WPF — немного об XAML, логическом и визуальном дереве.

Предупреждение. Материал под спойлером — недопустимо краткая выжимка из документации на docs.microsoft.com.

Пользовательский интерфейс в WPF описывается с помощью XAML — декларативного языка разметки. Текстовый файл XAML является XML-файлом, обычно с расширением .xaml. Для решения нашей задачи важно следующее.

При описании пользовательского интерфейса в файле XAML описывается один элемент пользовательского интерфейса, обычно – страница (Page) или элемент, производный от System.Windows.Controls.Control: окно (Window), UserControl и т.д. Элемент Control (как и Page), в свою очередь, может содержать один или несколько дочерних элементов (child Control`s). В зависимости от типа родительского элемента, свойством для задания дочерних элементов является Content, Items, Children и т.п. Таким образом в файле XAML (или из программного кода) задается логическое дерево элементов LogicalTree (см. Деревья в WPF).

Однако, при загрузке страницы или элемента управления логическое дерево развертывается в визуальное дерево элементов (VisualTree). Элементы логического дерева определяют поведение элемента, а элементы визуального дерева определяют внешний вид элемента в UI. Способ разворачивания содержимого элемента управления в визуальное дерево определяется его шаблоном (см. Стили и шаблоны WPF). Если не задано иное, используется шаблон элемента по умолчанию, заданный в исходной библиотеке элемента управления разработчиком библиотеки. Однако, в приложении шаблон элемента можно переопределить, используя свойство Control.Template (или Page.Template). Поскольку свойство Template является свойством зависимостей, задать пользовательское значение можно разными способами: как напрямую, так и через стиль или ресурсы элемента/приложения.

Для решения нашей задачи важно, что в WPF для элемента (логического или визуального) реализованы эффективные методы для доступа к другим элементам логического или визуального дерева и их свойствам.

Актуальное значение свойства элемента можно получить с помощью механизма привязки данных. Как описано в документации, привязка данных определяется как процесс установки соединения между пользовательским интерфейсом и отображаемыми данными. Привязку можно как описать декларативно в файле XAML, так и задать императивно из кода. Привязка данных является, по существу, мостом между целью привязки (значением свойства визуального элемента, которое необходимо установить) и источником привязки. Источником привязки может выступать значение свойства экземпляра любого другого объекта модели приложения, в том числе, разумеется, и свойства элементов визуального или логического дерева.

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

Дисклеймер. Все доступные из статьи данные о фирме Northwind получены из открытого источника, предоставляющего их по лицензии MIT. Все совпадения неслучайны. Все данные неперсональны.

Формализация задания

На начало работ можно сформулировать следующие требования:

  1. В заголовок столбца стандартного элемента управления DataGrid необходимо добавить некий пользовательский элемент управления для быстрой фильтрации по содержимому столбца. Назовем его ColumnFilter.

  2. «Фильтрация» в данном контексте означает, что элементы привязанной к DataGrid коллекции проверяются на соответствие некоему условию фильтрации (т.е. функция фильтрации, выполненная над элементом коллекции, возвращает true). Выводятся только те элементы коллекции, которые соответствуют условию фильтрации.

  3. Содержание выводимого DataGrid столбца определяется привязкой столбца. Привязка обычно определяет свойство элемента коллекции, отображаемое в столбце (источник привязки). Зафиксируем в задании, что расположенный в столбце элемент ColumnFilter выполняет простые операции сравнения с образцом над определяемым привязкой столбца свойством.

  4. В число простых операций включаем операции, допустимые для типа источника привязки. Например, если источник привязки имеет тип string, в набор операций включаем «эквивалентно …», «Начинается с …», «Содержит …». Если источник привязки имеет тип int, real и т.п., в набор операций включаем «равно», «больше», «меньше». Таким образом, отдельный элемент ColumnFilter будет представлять набор операций, допустимых над типом источника привязки – элементарных фильтров.

  5. Зафиксируем, что функции фильтрации элементарных фильтров ColumnFilter объединяются по «И», как и функции фильтрации всех активных ColumnFilter для привязанной коллекции.

В .net/WPF почти всё уже есть.

Элемент DataGrid отображает коллекцию, доступ к которой возможен через свойство Items. Items можно заполнить напрямую добавлением элементов, но чаще осуществляется привязка свойства ItemsSource к отображаемой коллекции. При привязке через ItemsSource исходная коллекция используется не напрямую, а через прокси-класс CollectionView, добавляющий (в числе прочего) функцию фильтрации (подробнее см. привязка к коллекциям). Кратко взаимодействие DataGrid и CollectionView отражает следующая диаграмма:

ItemsSource class diagram
ItemsSource class diagram

При привязке ItemsSource к коллекции для данного экземпляра коллекции извлекается представление по умолчанию defaultView, которое и используется для отображения в UI. Для экземпляра коллекции всегда формируется только один экземпляр defaultView (dotnet/api/system.windows.controls.itemscontrol); его можно получить с помощью класса CollectionViewSource:

ICollectionView defaultView = CollectionViewSource.GetDefaultView(source);

Для реализации фильтрации достаточно присвоить свойству defaultView.Filter функцию фильтрации типа Predicate<Object>. Данная функция будет вычислена для каждого элемента коллекции, элемент будет выведен или скрыт в зависимости от возвращаемого значения.

Инжекцию экземпляра ColumnFilter в DataGrid реализуем через стиль заголовка столбца (свойствоDataGrid.ColumnHeaderStyle). Для работы ColumnFilter потребуется доступ к CollectionView,выводимой элементом DataGrid, и значение привязки для столбца, в котором расположен ColumnFilter. Данная информация может быть получена поиском в визуальном дереве родительских элементов DataGridColumn и DataGrid. Из свойства ItemsSource (либо Items) родительского dataGrid можно получить отображаемую коллекцию:

<bsFilter:ColumnFilter 
	ParentCollection="{Binding ItemsSource, 
                    RelativeSource={RelativeSource FindAncestor, 
                    	AncestorType={x:Type DataGrid}}}" />

А из свойств родительского столбца dataGridColumn — значение привязки:

DataGridColumnHeader columnHeader = this.GetVisualTreeParent<DataGridColumnHeader>();
DataGridColumn column = columnHeader.Column;
string bindingPath;
if (column is DataGridBoundColumn columnBound
    	&& columnBound?.Binding is Binding binding) {
    bindingPath = binding.Path.Path;
}
else if (column is DataGridTemplateColumn templateColumn
    	&& templateColumn.Header is string header) {
    bindingPath = header;
}
else if (column is DataGridComboBoxColumn comboBoxColumn
    	&& comboBoxColumn.SelectedItemBinding is Binding _binding) {
    bindingPath = _binding.Path.Path;
}

Итак, план таков:

  1. Для фильтрации используем представление коллекции CollectionView, отображаемое в DataGrid.

  2. Ограничим бесконечное разнообразие условий фильтрации небольшим набором простых фильтров (далее – фильтров). Например: «Отобразить только заданные пользователем значения»; «Вывести значения, большие заданного пользователем критерия»;«Вывести строки, содержащие заданную пользователем подстроку» и т.д.

  3. Каждый фильтр будет основан на одной логической операции (базовая операция фильтра); одним из операндов базовой операции фильтра является значение свойства, выводимое в столбце (т.е. привязкой столбца). Если базовая операция фильтра допустима для типа источника привязки, фильтр отображается в пользовательском интерфейсе и участвует в условиях фильтрации коллекции.

  4. Пусть каждый фильтр имеет признак активности. Элемент коллекции выводится (проходит фильтр), если выполняются условия для всех активных фильтров (т.е. условия отдельных фильтров объединяются по «И», результирующая функция фильтрации присваивается свойству CollectionView.Filter).

  5. В отдельной библиотеке определяем пользовательский элемент управления FilterControl и производный от него ColumnFilter — быстрый фильтр. FilterControl имеет поведение, не зависящее от места применения элемента в форме. ColumnFilter предназначен для использования в заголовке столбца DataGrid и может быть внедрен через шаблон.

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

  7. Для взаимодействия FilterControl и CollectionView определяем специализированный класс FilterPresenter. Экземпляр класса FilterPresenter жестко связан с экземпляром представления коллекции CollectionView, для которого он создан, и выполняет задачи подготовки функции фильтрации, взаимодействия с представлением коллекции, инициализации элементарных фильтров и предоставление модели для FilterControl.

Реализация — дело техники

После реализации в отдельной библиотеке определены классы FilterControl (элемент UI), FilterPresenter (реализующий фильтрацию представления коллекции) и необходимые для их работы вспомогательные классы:

Несколько пояснений

Класс FilterPresenter имеет связь с исходным представлением коллекции CollectionView и содержит коллекцию объектов Filter. Объекты Filter создаются классом FilterPresenter и, с одной стороны, реализуют функцию фильтрации, а с другой стороны, предоставляются через FilterControlVm в качестве элементов коллекции для отображения в UI. Элемент FilterControl отображает FilterControlVm в UI.

Свойство FilterControl.ParentCollection ссылается на представление фильтруемой коллекции CollectionView; через ссылку на представление можно получить экземпляр прикрепленного к представлению FilterPresenter.

Свойство FilterInitializersManager содержит коллекцию инициализаторов элементарных фильтров, которая определяет — какие виды фильтров могут быть созданы для данного элемента FilterControl.

При привязке коллекции к свойству FilterControl.ParentCollection извлекается FilterPresenter для привязанной коллекции; в экземпляр filterPresenter передается запрос на формирование FilterControlVm. Одним из параметров запроса передается FilterInitializersManager, который используется для определения возможного набора фильтров, включаемых в FilterControlVm.

Класс ColumnFilter конкретизирует использование FilterControl в визуальном дереве в качестве дочернего элемента (DataGrid –> DataGridColumnHeader –> ColumnFilter).

И, разумеется, исходный код библиотеки имеется на Github и доступен по свободной лицензии GPLv3.

В библиотеку также добавлен файл стиля ItemsFilterStyle.xaml, в котором определен шаблон заголовка DataGrid:

<Style x:Key="DataGridColumnHeaderStyle" TargetType="{x:Type DataGridColumnHeader}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type DataGridColumnHeader}">
                ...
                <bsif:ColumnFilter 
                    ParentCollection="{Binding ItemsSource,
                        RelativeSource={RelativeSource FindAncestor,
                            AncestorType={x:Type DataGrid}}}" />
                ...
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

и указано, что данный стиль следует применять для всех элементов DataGrid:

<Style TargetType="{x:Type DataGrid}">
    <Setter Property="ColumnHeaderStyle" Value="{StaticResource DataGridColumnHeaderStyle}" />
</Style>

Для активизации стиля разработчику приложения достаточно выполнить единственное изменение в коде основного проекта – включить ресурсы файла стиля в состав ресурсов приложения, добавив в файл App.xaml приведенную во введении строку.

Результат

Итак, в форме приложения содержится DataGrid:

<DataGrid ItemsSource="{Binding}">
    <DataGrid.Columns>
        <DataGridTextColumn Binding="{Binding Id}"
                            Header="Id" />
        <DataGridTextColumn Binding="{Binding Name}"
                            Header="Category name" />
        <DataGridTextColumn Binding="{Binding Description}" 
			    Header="Description" />
    </DataGrid.Columns>
</DataGrid>

После внедрения стиля ItemsFilterStyle.xaml в заголовке столбца DataGrid появился быстрый фильтр по содержимому:

DataGrid ItemsFilter
DataGrid ItemsFilter

На этом задача решена, можно было бы и закончить. Однако..

Самое интересное только начинается

Исходный код .net, равно как и сам API платформы, можно рассматривать как учебный материал, пример для подражания, образец грамотного распределения ролей и зон ответственности классов. Даже попытка следования принятым в .net практикам дает богатые результаты. Вот и в данной задаче получается более богатое решение, чем изначально поставлено в задании.

Как было указано выше, при привязке к коллекции элементы ItemsControl выводят представление коллекции по умолчанию. При повторном извлечении предоставляется один и тот же экземпляр представления. В результате, при привязке к одной коллекции нескольких элементов ItemsControl изменения сортировки, группировки и фильтрации, выполненные одним из элементов, передаются в остальные экземпляры. В реализации класса FilterPresenter установлена жесткая связь экземпляра со своим представлением коллекции; для одного экземпляра CollectionView создается один и только один экземпляр FilterPresenter, который и извлекается при последующих обращениях. В результате фильтрация, выполненная с помощью элементов FilterControl (или ColumnFilter), влияет на все представления, отображающие эту коллекцию.

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

Экземпляр элементарного фильтра filter, созданный классом FilterPresenter, однозначно идентифицируется через ключ {viewKey, filterType} и может быть извлечен повторно. В результате, возможно отображение фильтра не только в составе DataGrid, но и в любом другом месте представления. А внешний вид фильтра в форме WPF возможно изменить радикально.

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

Функциональность элемента DataGrid, определяющая работу с представлением коллекции, определена в базовом классе System.Windows.Controls.ItemsControl. Однако, от этого класса наследуют много других элементов управления. Насколько сложно добавить быстрый фильтр, например, в элемент ComboBox?

При проектировании класса ColumnFilter функциональность, не зависящая от деталей окружения, в котором он используется, вынесена в базовый класс FilterControl. В результате FilterControl сам может использоваться для отображения какого-либо специализированного фильтра — как в составе произвольной формы, так и в составе производного от ItemsControl элемента. Причем для описания такого пользовательского фильтра требуется на удивление мало кода.

Предположим, в представлении отображается ComboBox, отображающий коллекцию Customers. Требуется при выборе элемента ComboBox предоставить пользователю возможность быстрого поиска и фильтрации по подстроке. Критерий фильтрации формулируется так: «подстрока для поиска может быть найдена среди значений одного из свойств customer: City, Code, Name и т.д.».

Определяем специализированный фильтр CustomersComboBoxFilter как производный от StringFilter, переопределив функцию извлечения значения из объекта customer,

File CustomersComboBoxFilter.cs
// ****************************************************************************
// <author>mishkin Ivan</author>
// <email>Mishkin_Ivan@mail.ru</email>
// <date>28.01.2015</date>
// <project>***********</project>
// <license> GNU General Public License version 3 (GPLv3) </license>
// ****************************************************************************
using BolapanControl.ItemsFilter.Model;
using BolapanControl.ItemsFilter.View;
using Northwind.NET.EF6Model;
using System.Text;
namespace Northwind.NET.Sample.ViewModel {
    [View(typeof(StringFilterView))]
    // Define specialized filter for CustomersComboBox.
    public sealed class CustomersComboBoxFilter : StringFilter, IFilter {
        private static readonly StringBuilder sb = new();
        internal CustomersComboBoxFilter()
            // To search for combine the values of several properties.
            : base(item => 
            {
                if (item is Customer customer) {
                    sb.Clear();
                    sb.Append(customer.City);
                    sb.Append(',');
                    sb.Append(customer.Code);
                    sb.Append(',');
                    sb.Append(customer.ContactName);
                }
                return sb.ToString();
            }) {
        }
    }
}

определяем класс – инициализатор фильтра,

File CustomersComboBoxFilterInitializer.cs
// ****************************************************************************
// <author>mishkin Ivan</author>
// <email>Mishkin_Ivan@mail.ru</email>
// <date>28.01.2015</date>
// <project>***********</project>
// <license> GNU General Public License version 3 (GPLv3) </license>
// ****************************************************************************
using BolapanControl.ItemsFilter;
using BolapanControl.ItemsFilter.Initializer;
using BolapanControl.ItemsFilter.Model;
using Northwind.NET.EF6Model;
using System.Collections.Generic;
namespace Northwind.NET.Sample.ViewModel {
    public class CustomersComboBoxFilterInitializer : FilterInitializer {
        public override Filter? TryCreateFilter(FilterPresenter filterPresenter, object key) {
            if (key != null && filterPresenter.CollectionView.SourceCollection is IEnumerable<Customer>) {
                var filter= new CustomersComboBoxFilter();
                filter.Attach(filterPresenter);
                return filter;
            }
            return null;
        }
    }
}

и изменяем шаблон для элемента ComboBox, в который вносим элемент FilterControl; через XAML выполняем привязку свойства FilterControl.ParentCollection к отображаемой коллекции; указываем что для данного элемента фильтр может быть инициализирован посредством созданного класса CustomersComboBoxFilterInitializer

File CustomerComboBoxStyle.xaml
<bsFilter:FilterControl Key="CustomerAnyFieldFilter"
           ParentCollection="{TemplateBinding ItemsSource}">
    <bsFilter:FilterControl.Resources>
    <Style BasedOn="{StaticResource CustomerComboBox_StringFilterStyle}" 
           TargetType="{x:Type bsFilter:StringFilterView}" />
    </bsFilter:FilterControl.Resources>
    <bsFilter:FilterControl.FilterInitializersManager>
        <bsFilter:FilterInitializersManager>
            <vm:CustomersComboBoxFilterInitializer />
        </bsFilter:FilterInitializersManager>
    </bsFilter:FilterControl.FilterInitializersManager>
</bsFilter:FilterControl>

Осталось определить внешний вид фильтра в ресурсе для элемента ComboBox, и вот результат:

Немногим сложнее добавляется фильтр в TreeView и другие элементы, производные от ItemsControl. Однако, для использования FilterControl экземпляр ItemsControl может и вовсе не понадобиться.

Допустим, в приложении имеется некое представление OrdersView в виде слайдера, отображающего коллекцию Orders. OrdersView отображает текущий элемент order, а перемещение вперед/назад осуществляется по командам MoveToFirst, MoveToNext и т.д.
Для отображения текущего элемента и реализации команд перемещения используется представление коллекции CollectionView, доступное в модели представления формы через свойство OrdersCollectionView. Пусть стоит задача – в представлении OrdersView добавить быстрый фильтр, ограничивающий перемещение слайдера указанным в фильтре сотрудником/сотрудниками. Что же, добавляем в подходящем месте OrdersView быстрый фильтр (да-да, 4 строки текста):

<bsFilter:ColumnFilter Key="Employee"
    ParentCollection="{Binding DataContext.OrdersCollectionView, 
                      ElementName=LayoutRoot}">
</bsFilter:ColumnFilter>

Этого достаточно. Результат выглядит так (впрочем, в OrdersView добавлено несколько штрихов для настройки внешнего вида представления):

Привязка ParentCollection="{Binding DataContext.OrdersCollectionView, ElementName=LayoutRoot}" предоставляет доступ к отображаемому представлению коллекции, а через Key="Employee" указывается, что для фильтрации будет использоваться свойство order.Employee.

WPF – жив (но это не точно)

Стек технологий .net/WPF дает действительно мощные средства для настройки пользовательского интерфейса через стиль.

Почему же технология WPF осталась … скажем так, нишевой? Конечно, один из основных факторов – то, что WPF привязан к Windows. Мы говорим WPF – подразумеваем [применение в] Windows. Но если сравнивать со стеком html/css, я бы выдвинул ещё одну причину.

HTML и CSS стандартизованы. Чтобы знать и применять html/css, достаточно знать стандарт (правда, необходимо знать какие версии продукта поддерживают ту или иную функциональность стандарта, но об этом умолчим). У разработчика есть уверенность, что страницу, написанную сегодня, можно будет отобразить на компьютерах и через десять, и через двадцать лет, на любой операционной системе. А навыки, полученные сегодня, останутся востребованными завтра.

WPF вроде бы тоже демонстрирует стабильность. Некоторые даже говорят – «замороженность». Но стандарта нет. Зато вспоминаются Silverlight и UWP. Один уже в прошлом, другой же сперва подавался как замена WPF, а сейчас подается скорее, как параллельная реальность. Всё течет, всё меняется. Что останется завтра (и останется ли WPF), решает только одна организация; нам же это достоверно неизвестно. Так что у разработчика WPF нет уверенности в будущем. Как и у многих из нас – с недавнего времени.

Enjoy life code.

Комментарии (5)


  1. realchel
    19.05.2022 23:38
    +1

    перевод статьи из 1996‽


    1. Dotarev Автор
      20.05.2022 07:55

      ‽ Прошу уточнить: это вопрос, утверждение или что-то ещё. В любом случае, если ожидаете конструктивный ответ, лучше изложить предпосылки – почему у Вас возникла данная мысль.


      1. realchel
        20.05.2022 08:39

        там на скриншоте год даже есть а интерфейс как под винду 3.1.


        1. Dotarev Автор
          20.05.2022 09:22

          Понятно. При анализе даты стоит обращать внимание на самую позднюю найденную в статье дату, а не на самую раннюю. И стоит обратить внимание на дисклеймер из статьи (ссылки там неспроста).


  1. ruma46
    20.05.2022 10:37

    Спасибо за статью! Редкость для хабра и вообще в последнее время для WPF.

    WPF вроде бы тоже демонстрирует стабильность.

    Хоронят WPF уже больше десятилетия, однако WPF имеет большую базу знаний, накопленную за годы. Практически любая проблема, с которой может столкнуться изучающий, уже обсуждена и решена на stackoverflow, codeproject, или где-то ещё.

    То есть, порог входа достаточно низкий. Хотя, конечно, сделать красиво - нужно потрудиться. Это как HTML/CSS - лекго, а сверстать что-то красивое, не будучи дизайнером (а будучи программистом) - сложно.

    Хотелось бы кросс-платформенность, конечно (чего мы, кажется, никогда не дождёмся), но хоронить рано.

    Сам в состоянии перехода от 20+ лет использования стека MFC/C++ на WPF/C#.