часть 2: ComboBox с фильтрацией содержимого, TimePicker, DateTimePicker
Относительно недавно мне поставили задачу — разработать достаточно простое Windows приложение. При выборе технологии я решил использовать проверенный временем WPF, с которым я работал раньше. Как правило, при разработке WPF‑приложения я использовал контролы от Telerik или DevExpress и созданием своих контролов не занимался. Но в текущей ситуации приобрести их проблематично и не факт, что не будет проблем с лицензией в будущем. Проект, над которым я работал, небольшой, навороченных гридов в нем не было, поэтому я решил использовать то, что есть в WPF «из коробки». При этом потребуется написать DateTimePicker и доработать Button, ToggleButton, ComboBox и ListBox. Задача казалась не особо сложной. В результате все оказалось не все так просто и очевидно, как я думал. Это навело меня на мысль написать серию статей с описанием проблем, с которыми я столкнулся. Может быть, это поможет другим разработчикам на наступать на те же грабли, что и я. В планах 3 статьи. В первой расскажу про подключение стилей и изменение дизайна у стандартных кнопки и переключателя, во второй — про расширение функционала стандартного ComboBox и разработку DateTimePicker, в третьей ‑про добавление в ListBox анимированного drag’n’dropа, масштабирование и сортировку содержимого.
Стили приложения
Со стилями особых проблем не возникло. Для каждого контрола я завел отдельный файл в папке Style. Для каждой темы создал отдельную папку, в которую положил файл с цветами и корневой файл темы, содержащий ссылки на цвета и стили контролов. В результате, чтобы поменять тему приложения, достаточно положить в ресурсы файл DarkTheme.xaml или LightTheme.xaml.

Пример кода в DarkTheme.xaml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <ResourceDictionary.MergedDictionaries> <!-- Используемые цвета --> <ResourceDictionary Source="DarkColors.xaml"/> <!-- Стили контролов --> <ResourceDictionary Source="../Button.xaml"/> <ResourceDictionary Source="../Calendar.xaml"/> <ResourceDictionary Source="../CheckBox.xaml"/> <ResourceDictionary Source="../ComboBox.xaml"/> … </ResourceDictionary.MergedDictionaries> </ResourceDictionary>
Для переключения тем в файл App.cs добавил следующий код:
public partial class App : Application { private ResourceDictionary ThemeDictionary => Resources.MergedDictionaries[0]; public Theme CurrentTheme { get; private set; } = Theme.Dark; public void ChangeTheme(Theme theme) { if (CurrentTheme == theme) { return; } CurrentTheme = theme; Uri themeSource; switch (CurrentTheme) { case Theme.Dark: themeSource = new Uri("pack://application:,,,/CustomWpfControls;component/Style/DarkTheme/DarkTheme.xaml", UriKind.Absolute); break; case Theme.Light: themeSource = new Uri("pack://application:,,,/CustomWpfControls;component/Style/LightTheme/LightTheme.xaml", UriKind.Absolute); break; default: return; } ThemeDictionary.Clear(); ThemeDictionary.Source = themeSource; } }
Также надо не забыть добавить в App.xaml ссылку на файл с ресурсами по умолчанию
<Application x:Class="CustomWpfControls.Sample.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="/Views/MainWindow.xaml"> <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="pack://application:,,,/CustomWpfControls;component/Style/DarkTheme/DarkTheme.xaml"/> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources> </Application>
Теперь приложение использует наши стили при отрисовке UI.
Кнопка со скругленными углами
В дизайне приложения все кнопки должны были быть со скругленными углами. Также были и круглые кнопки.

Первое решение было ошибочное: я написал наследника от Button. В XAML-разметке в Button.Template добавил Border со скруглениями, а в .cs файле описал новые свойства: цвет рамки фокуса, ее толщину, отступ от кнопки, радиус скругления углов и т.п.
Примерно так:
<Button x:Class="Scandoc.Scan.Controls.RoundedButton"> <Button.Template> <ControlTemplate TargetType="{x:Type Button}"> <Border Padding="{Binding FocusBorderPadding, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:RoundedButton}}}" BorderThickness="{Binding FocusBorderThickness, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:RoundedButton}}}" CornerRadius="{Binding FocusBorderCornerRadius, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:RoundedButton}}}" BorderBrush="{Binding FocusBorderBrush, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:RoundedButton}}}" Background="Transparent" SnapsToDevicePixels="true"> <Border Background="{TemplateBinding Background}" BorderBrush="Transparent" BorderThickness="0" CornerRadius="{Binding CornerRadius, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:RoundedButton}}}" SnapsToDevicePixels="true"> <ContentPresenter Margin="{TemplateBinding Padding}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}
Сначала это решение казалось неплохим и универсальным. Я мог в XAML-размете вьюхи задать все эти поля. Но, подумав, я понял, что решение не оптимальное. Правильнее было бы перенести шаблон в стили и сразу использовать нужные цвета для каждого типа кнопок, не создавая дополнительных свойств в контроле. Оставалось поле с радиусом скругления. В принципе для двух типов кнопок (круглой и со скругленными углами) значения можно было бы и захардкодить. Но мне хотелось иметь один базовый стиль, где переопределен шаблон и от него наследовать стили для всех кнопок.
На помощь пришли attached properties. В статическом классе RoundedButton я зарегистрировал свойство CornerRadius.
public static class RoundedButton { public static readonly DependencyProperty CornerRadiusProperty = DependencyProperty.RegisterAttached( "CornerRadius", typeof(CornerRadius), typeof(RoundedButton), new FrameworkPropertyMetadata(new CornerRadius(0d), FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender)); public static void SetCornerRadius(UIElement element, CornerRadius value) { element.SetValue(CornerRadiusProperty, value); } public static CornerRadius GetCornerRadius(UIElement element) { return (CornerRadius)element.GetValue(CornerRadiusProperty); } }
После этого к нему можно обращаться из шаблона в стилях.
<Style x:Key="RoundedButton" TargetType="{x:Type Button}"> <Setter Property="customWpfControls:RoundedButton.CornerRadius" Value="8"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type Button}"> <Border x:Name="FocusBorder" Padding="0" BorderThickness="0" CornerRadius="{Binding RelativeSource={RelativeSource TemplatedParent},Path=(customWpfControls:RoundedButton.CornerRadius)}" BorderBrush="Transparent" Background="Transparent" SnapsToDevicePixels="true"> <Border BorderThickness="0" BorderBrush="{TemplateBinding BorderBrush}" Background="{TemplateBinding Background}" CornerRadius="{Binding RelativeSource={RelativeSource TemplatedParent},Path=(customWpfControls:RoundedButton.CornerRadius)}" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"> <ContentPresenter Margin="{TemplateBinding Padding}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}" Focusable="False" RecognizesAccessKey="True" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> </Border> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>
В итоге не пришлось изобретать велосипед и писать свой контрол — все уместилось в стилях. Один из стилей — базовый с шаблоном кнопки, а остальные — наследники с конкретными реализациями. При этом свойство customWpfControls:RoundedButton.CornerRadius при необходимости можно переопределить и в стиле, и во вьюхе.
Стили кнопок можно посмотреть здесь.
Переключатель
Тут я снова наступил на те же грабли. Стиль переключателя в дизайне настолько отличался от обычного ToggleButton, что я опять написал полноценный UserControl с разметкой, полями, описывающими радиус кнопки и размеры контрола и кодом обработки клика с запуском анимации. Всего примерно на 500 строк кода...

По итогу размышлений, все удалось перенести в стили без единой строчки в CS-файлах. Несмотря на анимацию и внешний вид, функционал на 100% совпадал с ToggleButton. Надо было только правильно написать шаблон.
Первое, что я сделал: добавил в ресурсы DrawingImage для иконок с галочкой и кружком, чтобы не перегружать основной стиль контрола. После этого написал шаблон, в котором иконки и переключатель положил на Canvas. Переключателю добавил TranslateTransform для его перемещения. И, наконец, добавил VisualStateManager для обработки событий перехода в состояния Checked / Unchecked и запуска анимации переключения.
Код для анимации сдвига переключателя с помощью DoubleAnimation.
<DoubleAnimation Duration="0:0:0.1" To="20" AccelerationRatio="0.2" DecelerationRatio="0.7" Storyboard.TargetName="Switcher" Storyboard.TargetProperty="(RenderTransform).(TranslateTransform.X)"/>
В процессе переключения меняется координата X переключателя. Для этого используется TranslateTransform. При этом мы указываем только конечную координату (To), но не указываем начальную (From). Это обеспечивает корректное поведение переключателя при серии быстрых кликов.
Получился такой шаблон:
<Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type ToggleButton}"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <VisualStateManager.VisualStateGroups> <VisualStateGroup x:Name="CheckStates"> <VisualState x:Name="Checked"> <Storyboard> <DoubleAnimation Duration="0:0:0.1" To="20" AccelerationRatio="0.2" DecelerationRatio="0.7" Storyboard.TargetName="Switcher" Storyboard.TargetProperty="(RenderTransform).(TranslateTransform.X)"/> </Storyboard> </VisualState> <VisualState x:Name="Unchecked"> <Storyboard> <DoubleAnimation Duration="0:0:0.1" To="0" AccelerationRatio="0.2" DecelerationRatio="0.7" Storyboard.TargetName="Switcher" Storyboard.TargetProperty="(RenderTransform).(TranslateTransform.X)"/> </Storyboard> </VisualState> <VisualState x:Name="Indeterminate"/> </VisualStateGroup> </VisualStateManager.VisualStateGroups> <Border Grid.Column="0" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="11" Width="44" Height="22"> <Canvas HorizontalAlignment="Left" VerticalAlignment="Top"> <Image Source="{StaticResource CheckIcon}" Height="15" Width="15" Canvas.Left="6" Canvas.Top="4"/> <Image Source="{StaticResource UncheckIcon}" Height="15" Width="15" Canvas.Left="25" Canvas.Top="4"/> <Ellipse x:Name="Switcher" Width="14" Height="14" Canvas.Left="5" Canvas.Top="4" Fill="{DynamicResource BackgroundPrimary}"> <Ellipse.RenderTransform> <TranslateTransform x:Name="SwitchTransform"/> </Ellipse.RenderTransform> </Ellipse> </Canvas> </Border> <ContentPresenter Grid.Column="1" Margin="5 0 0 0" HorizontalAlignment="Left" VerticalAlignment="Center"/> </Grid> </ControlTemplate> </Setter.Value> </Setter>
В итоге удалось ограничиться использованием стандартного контрола, у которого, при необходимости легко заменить стиль отображения на классический.
Полностью cтиль переключателя можно посмотреть здесь.
Ссылка на проект с примерами.
Пара слов про DrawingImage
Иногда требуется добавить иконки в контролы. Чтобы они сохраняли свой размер в зависимости от масштаба, они должны быть в векторном формате. Есть много иконок в формате SVG, но проблема в том, что XAML его не поддерживает. Можно использовать пакет для работы с SVG, но мне не хотелось добавлять в проект еще одну зависимость и, если поддержка пакета прекратится, решать проблему с его заменой, поэтому я выбрал второй вариант — конвертировать SVG в XAML. На гитхабе есть для этого подходящий инструмент SvgToXaml.
Если требуется нарисовать свою иконку, могу порекомендовать онлайн редактор svg-path-editor. Мне его функциональности хватило для решения всех задач.
Заключение
В результате разработки я сделал для себя следующие выводы:
Если есть возможность использовать готовые библиотеки контролов типа Telerik или DevExpress, лучше использовать их. Это будет выгоднее в экономическом плане. И их функциональность будет на порядок лучше того, что 1–2 разработчика напишут за несколько месяцев работы.
Если взялся разрабатывать свой контрол, спроси себя, а нужно ли вообще писать новый контрол? Может, можно обойтись стилями и написанием шаблона? Это очень мощный инструмент, который я долго недооценивал.
