Стилизация элементов пользовательского интерфейса в экосистеме .net/WPF «позволяет разработчикам и дизайнерам создавать визуально привлекательные эффекты и согласованный внешний вид своих продуктов» [docs.microsoft.com] На первый взгляд, это аналогично разделению веб-страниц на семантическое содержание в HTML и оформление в CSS.
Однако, стилизация в WPF является гораздо более мощным инструментом, позволяющим существенно обогатить интерфейс программы (UI) без непосредственного изменения кода приложения.
Однажды потребовалось в приложении добавить во все таблицы возможность быстрой фильтрации по содержимому. Пожелание заказчика выглядело примерно так:
![](https://habrastorage.org/webt/hv/qy/yp/hvqyypyqxjmsbilkcoenedntw4y.png)
То есть необходимо изменить поведение для каждого столбца каждого элемента 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. Все совпадения неслучайны. Все данные неперсональны.
Формализация задания
На начало работ можно сформулировать следующие требования:
В заголовок столбца стандартного элемента управления
DataGrid
необходимо добавить некий пользовательский элемент управления для быстрой фильтрации по содержимому столбца. Назовем егоColumnFilter
.«Фильтрация» в данном контексте означает, что элементы привязанной к
DataGrid
коллекции проверяются на соответствие некоему условию фильтрации (т.е. функция фильтрации, выполненная над элементом коллекции, возвращаетtrue
). Выводятся только те элементы коллекции, которые соответствуют условию фильтрации.Содержание выводимого
DataGrid
столбца определяется привязкой столбца. Привязка обычно определяет свойство элемента коллекции, отображаемое в столбце (источник привязки). Зафиксируем в задании, что расположенный в столбце элемент ColumnFilter выполняет простые операции сравнения с образцом над определяемым привязкой столбца свойством.В число простых операций включаем операции, допустимые для типа источника привязки. Например, если источник привязки имеет тип
string
, в набор операций включаем «эквивалентно …», «Начинается с …», «Содержит …». Если источник привязки имеет типint
,real
и т.п., в набор операций включаем «равно», «больше», «меньше». Таким образом, отдельный элементColumnFilter
будет представлять набор операций, допустимых над типом источника привязки – элементарных фильтров.Зафиксируем, что функции фильтрации элементарных фильтров
ColumnFilter
объединяются по «И», как и функции фильтрации всех активныхColumnFilter
для привязанной коллекции.
В .net/WPF почти всё уже есть.
Элемент DataGrid
отображает коллекцию, доступ к которой возможен через свойство Items
. Items
можно заполнить напрямую добавлением элементов, но чаще осуществляется привязка свойства ItemsSource
к отображаемой коллекции. При привязке через ItemsSource
исходная коллекция используется не напрямую, а через прокси-класс CollectionView
, добавляющий (в числе прочего) функцию фильтрации (подробнее см. привязка к коллекциям). Кратко взаимодействие DataGrid
и CollectionView
отражает следующая диаграмма:
![ItemsSource class diagram ItemsSource class diagram](https://habrastorage.org/getpro/habr/upload_files/9c1/c75/753/9c1c75753007a8254a57569a97867e37.png)
При привязке 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;
}
Итак, план таков:
Для фильтрации используем представление коллекции
CollectionView
, отображаемое вDataGrid
.Ограничим бесконечное разнообразие условий фильтрации небольшим набором простых фильтров (далее – фильтров). Например: «Отобразить только заданные пользователем значения»; «Вывести значения, большие заданного пользователем критерия»;«Вывести строки, содержащие заданную пользователем подстроку» и т.д.
Каждый фильтр будет основан на одной логической операции (базовая операция фильтра); одним из операндов базовой операции фильтра является значение свойства, выводимое в столбце (т.е. привязкой столбца). Если базовая операция фильтра допустима для типа источника привязки, фильтр отображается в пользовательском интерфейсе и участвует в условиях фильтрации коллекции.
Пусть каждый фильтр имеет признак активности. Элемент коллекции выводится (проходит фильтр), если выполняются условия для всех активных фильтров (т.е. условия отдельных фильтров объединяются по «И», результирующая функция фильтрации присваивается свойству
CollectionView.Filter
).В отдельной библиотеке определяем пользовательский элемент управления
FilterControl
и производный от негоColumnFilter
— быстрый фильтр.FilterControl
имеет поведение, не зависящее от места применения элемента в форме.ColumnFilter
предназначен для использования в заголовке столбцаDataGrid
и может быть внедрен через шаблон.Визуально
ColumnFilter
будет представлять раскрывающийся список, отражающий набор инициализированных для столбца фильтров. Каждый фильтр набора участвует в формировании функции фильтрации и одновременно является моделью для отображения вColumnFilter
.Для взаимодействия
FilterControl
иCollectionView
определяем специализированный классFilterPresenter
. Экземпляр классаFilterPresenter
жестко связан с экземпляром представления коллекцииCollectionView
, для которого он создан, и выполняет задачи подготовки функции фильтрации, взаимодействия с представлением коллекции, инициализации элементарных фильтров и предоставление модели дляFilterControl
.
Реализация — дело техники
После реализации в отдельной библиотеке определены классы FilterControl
(элемент UI), FilterPresenter
(реализующий фильтрацию представления коллекции) и необходимые для их работы вспомогательные классы:
![](https://habrastorage.org/webt/qe/z7/fo/qez7fo41dtbb3mkkwe1whholsa4.png)
Несколько пояснений
Класс 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](https://habrastorage.org/webt/vj/lw/jl/vjlwjlez2fnbjnexiktf4qcbuge.png)
На этом задача решена, можно было бы и закончить. Однако..
Самое интересное только начинается
Исходный код .net, равно как и сам API платформы, можно рассматривать как учебный материал, пример для подражания, образец грамотного распределения ролей и зон ответственности классов. Даже попытка следования принятым в .net практикам дает богатые результаты. Вот и в данной задаче получается более богатое решение, чем изначально поставлено в задании.
Как было указано выше, при привязке к коллекции элементы ItemsControl
выводят представление коллекции по умолчанию. При повторном извлечении предоставляется один и тот же экземпляр представления. В результате, при привязке к одной коллекции нескольких элементов ItemsControl
изменения сортировки, группировки и фильтрации, выполненные одним из элементов, передаются в остальные экземпляры. В реализации класса FilterPresenter
установлена жесткая связь экземпляра со своим представлением коллекции; для одного экземпляра CollectionView
создается один и только один экземпляр FilterPresenter
, который и извлекается при последующих обращениях. В результате фильтрация, выполненная с помощью элементов FilterControl
(или ColumnFilter
), влияет на все представления, отображающие эту коллекцию.
Например: в одном окне приложения выводится некий справочник, а в другом окне при изменении свойства предлагается раскрывающийся список для выбора одного из элементов этого справочника. Если в окне справочника пользователь наложит фильтр, раскрывающийся список выбора элемента также будет отфильтрован:
![](https://habrastorage.org/webt/2a/nj/oa/2anjoala26wv5s07up5jal2ntuc.png)
Экземпляр элементарного фильтра filter
, созданный классом FilterPresenter
, однозначно идентифицируется через ключ {viewKey, filterType}
и может быть извлечен повторно. В результате, возможно отображение фильтра не только в составе DataGrid
, но и в любом другом месте представления. А внешний вид фильтра в форме WPF возможно изменить радикально.
В иллюстрации ниже один экземпляр фильтра отображается как в заголовке DataGrid
, так и в шапке формы. Разумеется, операции, выполненные пользователем в одном представлении фильтра, будут отражены одновременно во всех представлениях.
![](https://habrastorage.org/webt/br/t9/3k/brt93kexf6rrw_0hllvyfucrzwy.png)
Функциональность элемента 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, и вот результат:
![](https://habrastorage.org/webt/_i/v0/aa/_iv0aaabymvmole-f5rg9hkpdjq.png)
Немногим сложнее добавляется фильтр в 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
добавлено несколько штрихов для настройки внешнего вида представления):
![](https://habrastorage.org/webt/xu/ju/o-/xujuo-mijut4rpyv-udrkfq1rom.png)
Привязка 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.