Относительно недавно мне поставили задачу — разработать достаточно простое 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. Vedomir
      15.09.2025 08:49

      Зашел написать про Avalonia, а про нее уже написали. У нее не только Linux в поддерживаемых платформах, но и куча всего еще от MacOS до VR-очков, пусть это и не очень актуально для бизнес приложений в РФ.

      А еще вспомнилась старая статья про серьезные проблемы с производительностью WPF, которую я перевел аж 10 лет назад - Глубокое погружение в систему рендеринга WPF, тема, возможно, уже не настолько актуальная с ростом производительности железа, но могущая снова всплыть в случае переезда на отечественные процессоры.


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

        Если будет переезд на отечественные процы - там, наверняка, не будет винды (и, как следствия, WPF). Вопрос производительности будет интересен скорее в плане связки Avalonia и отечественной ОС на базе Linux на этих компах.
        А в целом вы правы. Как-то делал на фринлансе проект по моделированию автоматизации склада. Требовалось отрисовать схему склада и передвижение по нему роботов. Использовал WPF + SharpDX. Ресурсов приложение отъедало много...


    1. fe_nik_s
      15.09.2025 08:49

      Однако, если требуется написать программу, которая взаимодействует с камерой, у AvaloniaUI начинаются проблемы


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

        А какие? У меня есть работа с камерой в приложении. Если будет переезд на Linux я как раз рассматриваю использование AvaloniaUI.


        1. fe_nik_s
          15.09.2025 08:49

          Я не нашел как можно это просто сделать. Кто-то мучается через использование элемента фреймворка MAUI issue 12956 (что скорее всего не работает на Linux, так как MAUI его не поддерживает) или использует сторонние библиотеки, что тоже не очень кросплатформенно(минус мобилки). Да и сами разработчики не хотят это реализовать. Я, имея опыт с MAUI, попробовал бы Flutter


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

            Ясно, спасибо! Я как раз FlashCap у себя сейчас использую.


  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 это выглядит как-то монструозно.


    1. YegorP
      15.09.2025 08:49

      Когда это зарождалось (20 лет назад), XML ещё был силён, а JSON вспоминали преимущественно вместе с JS.


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

      Согласен, технология устарела, но все еще много где используется. Даже WinForms еще можно встретить. Так что лет 10 еще WPF будет актуален, я думаю.


      1. Sazonov
        15.09.2025 08:49

        Главный недостаток wpf на мой взгляд, это отсутствие возможности полноценной кросс-платформенной разработки.

        Но в целом да. Помню как-то давно слышал такую фразу про всякие современные технологии: то, что устарело (к примеру) для геймдева как раз дозревает для того чтобы начать использовать в энтерпрайзе.


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

          Да, из-за отсутствия кросс-платформенности придется переехать на Avalonia UI или какой-нибудь другой фреймворк, если будет принято решение отказаться от Windows.