Относительно недавно мне поставили задачу – разработать достаточно простое 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)


  1. xtraroman
    15.09.2025 08:49

    На мой взгляд, перспективнее делать новые проекты на AvaloniaUI. Там разметка очень похожа на WPF. Не нужно тратить много времени на переучивание. Приложения на AvaloniaUI работают не только на Windows но и на Linux(включая отечественные). Под AvaloniaUI в России производит контролы Eremex. Продукт называется "eremexcontrols", он входит в реестр отечественного ПО. Без проблем можно купить как в коммерческую так и в бюджетную организацию.


  1. Alex063 Автор
    15.09.2025 08:49

    Спасибо, посмотрю eremexcontrols.


  1. MorozovDamian
    15.09.2025 08:49

    WinUI почему не рассматриваете в качестве UI-фрйемворка?


    1. Alex063 Автор
      15.09.2025 08:49

      На момент начала проекта у меня был большой опыт работы с WPF,а с WinUI я не работал. Так же UI в приложении максимально простой, особой выгоды от перехода на WinUI я бы не получил.


  1. Sazonov
    15.09.2025 08:49

    По сравнению с qml это выглядит как-то монструозно.