Относительно недавно мне поставили задачу – разработать достаточно простое 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. Мне его функциональности хватило для решения всех задач.
Заключение
В результате разработки я сделал для себя следующие выводы:
1. Если есть возможность использовать готовые библиотеки контролов типа Telerik или DevExpress, лучше использовать их. Это будет выгоднее в экономическом плане. И их функциональность будет на порядок лучше того, что 1-2 разработчика напишут за несколько месяцев работы.
2. Если взялся разрабатывать свой контрол, спроси себя, а нужно ли вообще писать новый контрол? Может, можно обойтись стилями и написанием шаблона? Это очень мощный инструмент, который я долго недооценивал.
Комментарии (0)
MorozovDamian
15.09.2025 08:49WinUI почему не рассматриваете в качестве UI-фрйемворка?
Alex063 Автор
15.09.2025 08:49На момент начала проекта у меня был большой опыт работы с WPF,а с WinUI я не работал. Так же UI в приложении максимально простой, особой выгоды от перехода на WinUI я бы не получил.
xtraroman
На мой взгляд, перспективнее делать новые проекты на AvaloniaUI. Там разметка очень похожа на WPF. Не нужно тратить много времени на переучивание. Приложения на AvaloniaUI работают не только на Windows но и на Linux(включая отечественные). Под AvaloniaUI в России производит контролы Eremex. Продукт называется "eremexcontrols", он входит в реестр отечественного ПО. Без проблем можно купить как в коммерческую так и в бюджетную организацию.