Относительно недавно мне поставили задачу — разработать достаточно простое 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 разработчика напишут за несколько месяцев работы.
Если взялся разрабатывать свой контрол, спроси себя, а нужно ли вообще писать новый контрол? Может, можно обойтись стилями и написанием шаблона? Это очень мощный инструмент, который я долго недооценивал.