Введение

Пример использования цветных векторных Bootstrap Icons
Пример использования цветных векторных Bootstrap Icons

В мире разработки приложений, будь то веб или десктоп, использование айконок является неотъемлемой частью пользовательского интерфейса. Векторные айконки предпочтительнее растровых, так как они масштабируются без потери качества. Одной из популярных коллекций векторных айконок является Bootstrap Icons, содержащая более 2000 готовых айконок. Хотя коллекция Bootstrap Icons доступна как npm-пакет bootstrap-icons и ориентирована на веб-разработку, её можно эффективно использовать в десктопных приложениях.

Создадим с нуля контрол BootstrapIcon для удобного использования двухцветных векторных айконок в приложениях на Avalonia/WPF. Сами изображения, в основном берем из SVG-файлов библиотеки bootstrap-icons, отсюда и название нашего контрола.

Туториал ориентирован на разработчиков, знакомых с Avalonia на базовом уровне. Основной упор в реализации контрола делается на Avalonia. Вариант для WPF, надеюсь, будет полезен для тех, кто переходит с WPF на Avalonia.

? Полученные контролы BootstrapIcon для Avalonia и WPF с примерами использования размещены на GitHub.

? Продолжение следует...
Планируется публикация ещё пары туториалов, в которых будет пошаговое руководство для создания главного меню приложения и аналога ToolBar с использованием BootstrapIcon.

Содержание руководства

Концепция и структура

Прежде чем погрузиться в пошаговую реализацию контрола BootstrapIcon, важно понять, что лежит в основе нашего подхода. В этом разделе мы рассмотрим:

  • причины выбора коллекции Bootstrap Icons в качестве источника иконок;

  • существенные аспекты реализации;

  • создание прототипа контрола;

  • реализацию хранения данных об айконках в кросс-платформенной библиотеке;

  • общую структуру нашего решения для поддержки Avalonia и WPF.

Почему именно Bootstrap Icons?

В мире векторных айконок существует множество отличных коллекций, таких как Google Fonts Icons, Feather Icons, Font Awesome и другие. Однако мы используем именно Bootstrap Icons по нескольким причинам:

  • Полнота и стиль: Коллекция содержит более 2000 айконок, охватывающих большинство областей применения; выдержана в едином современном стиле.

  • Лицензия: Bootstrap Icons распространяются под лицензией MIT, что позволяет свободно использовать их в личных и коммерческих проектах.

  • Единообразие дизайна: Если вы уже используете фреймворк Bootstrap и его айконки для фронтенда веб-приложений, выбор Bootstrap Icons для приложений Avalonia/WPF позволит добиться единообразия в дизайне между разными платформами.

  • Особенности SVG-структуры: простота и логичность использования элементов SVG:

    • На момент написания статьи большинство (2070 из 2074) Bootstrap Icons используют для отрисовки исключительно элементы <path> (не задействуют <circle>, <rect>), кроме того, не применяют атрибуты вроде stroke-width. Это значительно упрощает обработку SVG-файла: мы можем просто взять строку из атрибута d элемента <path> и скопировать в определение айконки.

    • Многие Bootstrap Icons состоят из нескольких логических частей (например, рамка и значок внутри рамки), каждая из которых определена отдельным элементом <path> в исходном SVG. Это является ключевым преимуществом, так как дает возможность назначать индивидуальный цвет каждому такому фрагменту изображения с минимальными затратами.

  • Возможность автоматизации: Особенности структуры SVG-файлов Bootstrap Icons также упрощают создание средств автоматизации процесса определения набора айконок приложения. Например, можно разработать генератор, который по JSON-файлу с перечнем иконок и их цветами создаёт код перечисления BootstrapSymbol (о нём — ниже).

В приложениях Avalonia/WPF для эффективного рендеринга векторных айконок предпочтительнее работать не с целыми SVG-файлами, а с объектами типа StreamGeometry, которые можно создать из строк атрибута d элемента <path>. Особенности структуры SVG-файлов bootstrap-icons делают процесс определения набора айконок приложения достаточно простым, в том числе для автоматизации.

Подход к реализации

Программную реализацию BootstrapIcon основываем на исходных кодах PathIcon - штатного контрола фреймворка Avalonia. Штатный PathIcon прекрасно справляется с отображением одного StreamGeometry (векторного изображения, упакованного в один path). Наш BootstrapIcon расширяет эти возможности, позволяя отображать до двух StreamGeometry с независимым управлением цветом для каждого.

Определения двухцветных векторных айконок помещаем не в ресурсы (xaml/axaml-файлы), а в библиотеку классов, которая не зависит от фреймворка Avalonia или WPF.

В итоге, из штатного PathIcon создаем свой BootstrapIcon для удобного отображения двухцветных векторных айконок на основе данных из SVG-файлов популярной коллекции bootstrap-icons. Если не хватает айконок из этой коллекции, можно добавить свои определения или позаимствовать из других источников (после приведения к стандартному размеру).

Для начала создадим прототип нашего контрола, чтобы лучше понимать, что мы собираемся сделать.

Создание прототипа

Для понимания принципов работы BootstrapIcon, создадим прототип — тестовое приложение на Avalonia, демонстрирующее отображение иконки тремя способами:

  1. <PathIcon> без указания цвета (используется цвет по умолчанию).

  2. <PathIcon> с заданным цветом заливки.

  3. <Viewbox> с двумя элементами <Path>, каждый из которых имеет свой цвет, — это будет основа нашего будущего контрола BootstrapIcon.

Убедитесь, что у вас установлены .NET SDK 8.0 (или новее) и шаблоны Avalonia. Если шаблоны Avalonia отсутствуют, установите их с помощью команды:

dotnet new install Avalonia.Templates

Создайте новое приложение Avalonia в любом каталоге при помощи команды:

dotnet new avalonia.app -n TryIcon -f net8.0

Реализацию прототипа поместим в MainWindow.axaml, где отобразим айконку "database-x" тремя различными способами:

  1. Подготовка геометрии: На сайте Bootstrap Icons возьмем из SVG-файла айконки строковое значение атрибутов d каждого из двух элементов <path>.

  2. Определение ресурсов: В <Window.Resources> создадим:

    • Два ресурса <StreamGeometry x:Key="PrimaryGeometry"/> и <StreamGeometry x:Key="SecondaryGeometry"/> для геометрии каждой части двухцветной айконки (значениями из атрибутов path.d).

    • Ресурс <StreamGeometry x:Key="IconGeometry"/> как результат объединения этих двух строк геометрии (через пробел) для отображения монохромной айконки штатным PathIcon.

    • Три ресурса <SolidColorBrush> (с ключами IconBrush, PrimaryForeground, SecondaryForeground) для задания цветов.

  3. Размещение элементов: В корневом элементе окна создадим <StackPanel> с тремя визуальными элементами:

    • Два PathIcon, ссылающихся на объединенную IconGeometry (один без цвета, второй с IconBrush).

    • Один <Viewbox>, который станет нашим прототипом BootstrapIcon.

Структура <Viewbox> для прототипа BootstrapIcon:

<Viewbox Width="32" Height="32" Stretch="Uniform">
    <Panel>
        <Path Width="16" Height="16" Stretch="None"
              Data="{StaticResource PrimaryGeometry}"
              Fill="{StaticResource PrimaryForeground}"/>
        <Path Width="16" Height="16" Stretch="None"
              Data="{StaticResource SecondaryGeometry}"
              Fill="{StaticResource SecondaryForeground}"/>
    </Panel>
</Viewbox>

Здесь два элемента <Path> размещены внутри <Panel>, позволяя им накладываться друг на друга. Первый <Path> отображает геометрию из свойства PrimaryGeometry и заливается цветом из PrimaryForeground. Второй <Path> аналогично использует SecondaryGeometry и SecondaryForeground.

Оба элемента <Path> имеют фиксированные размеры 16x16 (исходный размер айконок Bootstrap) с запретом масштабирования фрагмента изображения (Stretch="None"). Элемент <Viewbox> обеспечивает масштабирование <Panel> до размеров, заданных для самого контрола BootstrapIcon (Stretch="Uniform").

Полный код MainWindow.axaml находится ниже.

скрытый текст MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
        x:Class="TryIcon.MainWindow"
        Title="TryIcon">
    <Window.Resources>
        <StreamGeometry x:Key="IconGeometry">M12.096 6.223A5 5 0 0 0 13 5.698V7c0 .289-.213.654-.753 1.007a4.5 4.5 0 0 1 1.753.25V4c0-1.007-.875-1.755-1.904-2.223C11.022 1.289 9.573 1 8 1s-3.022.289-4.096.777C2.875 2.245 2 2.993 2 4v9c0 1.007.875 1.755 1.904 2.223C4.978 15.71 6.427 16 8 16c.536 0 1.058-.034 1.555-.097a4.5 4.5 0 0 1-.813-.927Q8.378 15 8 15c-1.464 0-2.766-.27-3.682-.687C3.356 13.875 3 13.373 3 13v-1.302c.271.202.58.378.904.525C4.978 12.71 6.427 13 8 13h.027a4.6 4.6 0 0 1 0-1H8c-1.464 0-2.766-.27-3.682-.687C3.356 10.875 3 10.373 3 10V8.698c.271.202.58.378.904.525C4.978 9.71 6.427 10 8 10q.393 0 .774-.024a4.5 4.5 0 0 1 1.102-1.132C9.298 8.944 8.666 9 8 9c-1.464 0-2.766-.27-3.682-.687C3.356 7.875 3 7.373 3 7V5.698c.271.202.58.378.904.525C4.978 6.711 6.427 7 8 7s3.022-.289 4.096-.777M3 4c0-.374.356-.875 1.318-1.313C5.234 2.271 6.536 2 8 2s2.766.27 3.682.687C12.644 3.125 13 3.627 13 4c0 .374-.356.875-1.318 1.313C10.766 5.729 9.464 6 8 6s-2.766-.27-3.682-.687C3.356 4.875 3 4.373 3 4 M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m-.646-4.854.646.647.646-.647a.5.5 0 0 1 .708.708l-.647.646.647.646a.5.5 0 0 1-.708.708l-.646-.647-.646.647a.5.5 0 0 1-.708-.708l.647-.646-.647-.646a.5.5 0 0 1 .708-.708</StreamGeometry>
        <StreamGeometry x:Key="PrimaryGeometry">M12.096 6.223A5 5 0 0 0 13 5.698V7c0 .289-.213.654-.753 1.007a4.5 4.5 0 0 1 1.753.25V4c0-1.007-.875-1.755-1.904-2.223C11.022 1.289 9.573 1 8 1s-3.022.289-4.096.777C2.875 2.245 2 2.993 2 4v9c0 1.007.875 1.755 1.904 2.223C4.978 15.71 6.427 16 8 16c.536 0 1.058-.034 1.555-.097a4.5 4.5 0 0 1-.813-.927Q8.378 15 8 15c-1.464 0-2.766-.27-3.682-.687C3.356 13.875 3 13.373 3 13v-1.302c.271.202.58.378.904.525C4.978 12.71 6.427 13 8 13h.027a4.6 4.6 0 0 1 0-1H8c-1.464 0-2.766-.27-3.682-.687C3.356 10.875 3 10.373 3 10V8.698c.271.202.58.378.904.525C4.978 9.71 6.427 10 8 10q.393 0 .774-.024a4.5 4.5 0 0 1 1.102-1.132C9.298 8.944 8.666 9 8 9c-1.464 0-2.766-.27-3.682-.687C3.356 7.875 3 7.373 3 7V5.698c.271.202.58.378.904.525C4.978 6.711 6.427 7 8 7s3.022-.289 4.096-.777M3 4c0-.374.356-.875 1.318-1.313C5.234 2.271 6.536 2 8 2s2.766.27 3.682.687C12.644 3.125 13 3.627 13 4c0 .374-.356.875-1.318 1.313C10.766 5.729 9.464 6 8 6s-2.766-.27-3.682-.687C3.356 4.875 3 4.373 3 4</StreamGeometry>
        <StreamGeometry x:Key="SecondaryGeometry">M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m-.646-4.854.646.647.646-.647a.5.5 0 0 1 .708.708l-.647.646.647.646a.5.5 0 0 1-.708.708l-.646-.647-.646.647a.5.5 0 0 1-.708-.708l.647-.646-.647-.646a.5.5 0 0 1 .708-.708</StreamGeometry>
        <SolidColorBrush x:Key="IconBrush" Color="#7c3aed"/>
        <SolidColorBrush x:Key="PrimaryForeground" Color="#7c3aed"/>
        <SolidColorBrush x:Key="SecondaryForeground" Color="Red"/>
    </Window.Resources>
    <StackPanel Orientation="Horizontal" VerticalAlignment="Top" Spacing="8" Margin="4">
        <PathIcon Width="32" Height="32"
                  Data="{StaticResource IconGeometry}"/>
        <PathIcon Width="32" Height="32"
                  Data="{StaticResource IconGeometry}"
                  Foreground="{StaticResource IconBrush}" />
        <Viewbox Width="32" Height="32" Stretch="Uniform">
            <Panel>
                <Path Width="16" Height="16" Stretch="None"
                      Data="{StaticResource PrimaryGeometry}"
                      Fill="{StaticResource PrimaryForeground}"/>
                <Path Width="16" Height="16" Stretch="None"
                      Data="{StaticResource SecondaryGeometry}"
                      Fill="{StaticResource SecondaryForeground}"/>
            </Panel>
        </Viewbox>
    </StackPanel>
</Window>

Запустив приложение, вы увидите айконку в трех вариантах: два монохромных и один двухцветный.

Этот прототип демонстрирует возможность рендеринга двухцветной векторной айконки стандартными средствами Avalonia, определяя ее геометрию и цвета частей как ресурсы. Однако, при таком подходе для каждой айконки требуется до четырех отдельных значений в словаре ресурсов. Это усложняет использование и управление айконками, если они не представлены как единый элемент.

В следующем разделе мы представим альтернативный подход, позволяющий определять айконки не как XAML-ресурсы.

Перечисление BootstrapSymbol

Чтобы удобно работать с айконками в коде как с именованными сущностями (своего рода символами), мы вводим специальное перечисление BootstrapSymbol. Каждое значение этого перечисления соответствует одной айконке нашего приложения (взятой из коллекции Bootstrap Icons или определенной дополнительно).

Для хранения данных об айконке (символе) мы используем атрибуты SymbolPath, связанные со значениями BootstrapSymbol. Этот подход позволяет держать определение айконки (ее имя в коде, геометрию и цвет фрагментов изображения) в общей библиотеке классов.

Пример определения айконок в BootstrapSymbol:

public enum BootstrapSymbol
{
...
    // window-sidebar
    [SymbolPath("M2.5 4...", 0xffffc107)]
    [SymbolPath("M2 1...z", 0xffffc107)]
    WindowSidebar,
    // funnel
    [SymbolPath("M1.5 1.5...z", KnownColor.RoyalBlue)]
    Funnel,
    // sort-alpha-down
    [SymbolPath("M10.082 5.629z", KnownColor.RoyalBlue)]
    [SymbolPath("M12.96 14H9.028...z", KnownColor.RoyalBlue)]
    SortAlphaDown,
...
}

Каждый атрибут SymbolPath хранит строку геометрии (значение атрибута d из SVG-элемента <path>) и опционально цвет. Для задания цвета используется uint (трактуется как ARGB) или значение из System.Drawing.KnownColor (набор цветов этого системного enum во многом совпадает с названиями HTML-цветов: Aqua, Aquamarine, Gray, Cyan, DarkBlue, Blue и т.д.).

Количество атрибутов SymbolPath для значения перечисления, как правило, соответствует количеству элементов <path> в исходном SVG-файле айконки. Цвет фрагмента изображения может задаваться для первого и второго атрибута SymbolPath (для остальных используется цвет второго атрибута).

Вспомогательная логика в SharedLib (реализованная через функцию расширения, как мы увидим далее) позволяет по значению BootstrapSymbol получить все связанные с ним атрибуты SymbolPath и извлечь из них необходимые данные для отрисовки. Именно эту логику и будет использовать наш контрол BootstrapIcon.

Структура нашего решения

Наше решение будет состоять из трех проектов для использования кода айконок:

  • SharedLib: Кросс-платформенная (не зависящая от Avalonia/WPF) библиотека, содержащая определения айконок в виде перечисления BootstrapSymbol и вспомогательный код для работы с ними.

  • TryAvalonia: Проект приложения на Avalonia UI, содержащий реализацию контрола BootstrapIcon для Avalonia и примеры его использования. Он ссылается на SharedLib.

  • TryWpf: Проект приложения на WPF, содержащий реализацию контрола BootstrapIcon для WPF и примеры его использования. Он также ссылается на SharedLib.

Такая структура позволяет централизованно хранить определения айконок в SharedLib и использовать их в UI-специфичных реализациях контрола в проектах TryAvalonia и TryWpf.

❕️ В реальных приложениях контролы типа BootstrapIcon правильнее размещать в специальных библиотеках (например, SharedLib.Ava и SharedLib.Wpf), но мы упростили структуру тестового решения, чтобы сократить описание.

Шаг 1: Создание решения и проектов

Убедитесь, что у вас установлены .NET SDK (рекомендуется .NET 8.0 или новее) и шаблоны Avalonia для dotnet new. Если шаблоны Avalonia не установлены, выполните команду:

dotnet new install Avalonia.Templates

Затем выполните следующий скрипт в терминале: он создаст директорию AppIcons, настроит решение и добавит в него три проекта с необходимыми зависимостями:

скрытый текст скрипта
# Создаем корневую директорию, переходим в нее и создаем решение
mkdir AppIcons
cd AppIcons
dotnet new sln -n AppIcons

# Создаем общую библиотеку SharedLib, включаем в решение
dotnet new classlib -n SharedLib -f net8.0
dotnet sln add SharedLib/SharedLib.csproj

# Создаем проект для Avalonia, добавляем ссылку на SharedLib, включаем в решение
dotnet new avalonia.app -n TryAvalonia -f net8.0
dotnet add TryAvalonia/TryAvalonia.csproj reference SharedLib/SharedLib.csproj
dotnet sln add TryAvalonia/TryAvalonia.csproj

# Создаем проект для WPF (требуется Windows), добавляем ссылку на SharedLib, включаем в решение
dotnet new wpf -n TryWpf -f net8.0
dotnet add TryWpf/TryWpf.csproj reference SharedLib/SharedLib.csproj
dotnet sln add TryWpf/TryWpf.csproj

dotnet new -f net8.0 # можете убрать этот параметр или указать другую версию .NET

После выполнения скрипта у вас будет готова структура решения AppIcons.sln с тремя проектами.

Далее можно проверить корректность создания тестовых проектов, запустив их из терминала:

# Запуск Avalonia приложения
dotnet run --project TryAvalonia/TryAvalonia.csproj

# Запуск WPF приложения (требуется Windows)
dotnet run --project TryWpf/TryWpf.csproj

Шаг 2: Определение айконок в SharedLib

На этом шаге реализуем кросс-платформенную библиотеку SharedLib для централизованного определения набора айконок, которые используют приложения Avalonia/WPF. В проекте создадим папку Controls с тремя файлами:

  • SymbolPathAttribute.cs: Атрибут для связывания геометрии (path.d из SVG) и цвета с элементами BootstrapSymbol.

  • BootstrapSymbol.cs: Перечисление, содержащее определения всех используемых айконок с помощью атрибутов SymbolPath.

  • BootstrapSymbolExtensions.cs: Методы расширения для BootstrapSymbol, облегчающие доступ к данным атрибутов SymbolPath из контролов BootstrapIcon.

Класс SymbolPathAttribute

Класс SymbolPathAttribute предназначен для определения строки геометрии одного SVG <path> и опционально его цвета (в форматах uint ARGB или System.Drawing.KnownColor). Если цвет в SymbolPath не указан, то для отображения геометрии будет использован системный цвет для текстовых элементов по умолчанию.

скрытый текст фрагмента SymbolPathAttribute.cs
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)]
internal class SymbolPathAttribute : Attribute
{
    // Получает SVG path data для отрисовки айконки.
    public string PathData { get; init; }
    // Получает цвет заливки для SVG path.
    public Color FillColor { get; init; }

    // Инициализирует атрибут только с path data (без цвета)
    public SymbolPathAttribute(string pathData)
    {
        PathData = pathData;
        FillColor = Color.Empty;
    }
    // Инициализирует атрибут с path data и цветом в формате ARGB uint.
    public SymbolPathAttribute(string pathData, uint ardb)
    {
        PathData = pathData;
        FillColor = Color.FromArgb((int)ardb);
    }
    // Инициализирует атрибут с path data и цветом из System.Drawing.KnownColor.
    public SymbolPathAttribute(string pathData, KnownColor knownColor)
    {
        PathData = pathData;
        FillColor = Color.FromKnownColor(knownColor);
    }
}

Наличие [AttributeUsage(AllowMultiple = true)] позволяет применять SymbolPath несколько раз к конкретному значению BootstrapSymbol. Это необходимо для айконок, состоящих из нескольких <path>. При ручном определении айконок рекомендуется на каждый <path> из SVG-файла создавать соответствующий SymbolPath (даже если их больше двух), учитывая следующую логику формирования данных для контрола BootstrapIcon:

  • PrimaryGeometry: используем геометрию из первого SymbolPath;

  • PrimaryForeground: используем цвет из первого SymbolPath;

  • SecondaryGeometry: используем геометрию из остальных SymbolPath (после слияния строк через пробел);

  • SecondaryForeground: используем цвет из второго SymbolPath (цвет остальных игнорируется).

Перечисление BootstrapSymbol

Перечисление BootstrapSymbol в проекте SharedLib определяет все айконки, которые использует приложение. Каждое его значение аннотируется одним или несколькими атрибутами SymbolPath, хранящими геометрию (path.d), взятую из исходных SVG-файлов bootstrap-icons или определенную вручную. Для первого и второго SymbolPath можно определить цвет.

Например, рассмотрим SVG-файл айконки file-earmark-arrow-down:

<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-arrow-down" viewBox="0 0 16 16">
  <path d="M8.5 6.5a.5.5 0 0 0-1 0v3.793L6.354 9.146a.5.5 0 1 0-.708.708l2 2a.5.5 0 0 0 .708 0l2-2a.5.5 0 0 0-.708-.708L8.5 10.293z"/>
  <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
</svg>

В этом SVG два элемента <path>. Соответственно, в нашем перечислении для значения FileEarmarkArrowDown мы добавим два атрибута SymbolPath, скопировав содержимое атрибутов d.

  // Export, file-earmark-arrow-down
  [SymbolPath("M8.5 6.5a.5.5 0 0 0-1 0v3.793L6.354 9.146a.5.5 0 1 0-.708.708l2 2a.5.5 0 0 0 .708 0l2-2a.5.5 0 0 0-.708-.708L8.5 10.293z", 0xff0d6efd)]
  [SymbolPath("M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z", KnownColor.Green)]
  FileEarmarkArrowDown,

Мы также можем добавить собственные айконки (custom1, custom2), определив их геометрию с помощью редактора SVG-путей (например, используя svg-path-editor). При необходимости определяем цвет фрагментов изображения.

  // custom1
  [SymbolPath("M3,3 8,6 13,3 10,8 13,13 8,10 3,13 6,8z", KnownColor.Red)]
  Custom1,
  // custom2
  [SymbolPath("M0,0H16V16H0z", KnownColor.Green)]
  [SymbolPath("M5,3 13,11 11,13 3,5z M13,5 5,13 3,11 11,3z", KnownColor.Red)]
  Custom2,

Таким образом, перечисление BootstrapSymbol содержит определения всех необходимых для приложения айконок в достаточно компактном и обозримом виде. Обычное приложение редко использует большое количество уникальных айконок, поэтому создание и модификация BootstrapSymbol даже в ручном режиме, задача не из самых сложных.

? Автоматизация.
Когда мне надоело копировать строки из SVG-файлов в код своего приложения, написал Node.js-скрипт, который упрощает этот процесс. Генератор кода перечисления BootstrapSymbol оформил и опубликовал как npm-пакет: path-icons. Можете воспользоваться или автоматизировать этот процесс самостоятельно.

Класс BootstrapSymbolExtensions

Класс BootstrapSymbolExtensions содержит вспомогательные методы для работы с перечислением BootstrapSymbol. Его основная задача — извлекать данные из атрибутов SymbolPath в формате, необходимом контролу BootstrapIcon.

Класс предоставляет два ключевых метода:

  • GetGeometryPath(): Объединяет PathData всех атрибутов в одну строку геометрии (полезно для монохромного PathIcon).

  • GetGeometryDefinition(): Извлекает данные из атрибутов и возвращает кортеж (string primaryPath, uint primaryArgb, string secondaryPath, uint secondaryArgb), реализуя логику выбора данных для первичной и вторичной частей, как требуется контролу BootstrapIcon.

Данные иконок (строки геометрии и цвета в формате string, uint), получаемые через GetGeometryDefinition, являются кросс-платформенными. Контрол BootstrapIcon для конкретного фреймворка преобразует их в свои типы:

  • Avalonia.Media.Geometry, Avalonia.Media.Color (для Avalonia);

  • System.Windows.Media.Geometry, System.Windows.Media.Color (для WPF).

Полный код классов библиотеки SharedLib на GitHub:

На этом Шаг 2 завершен. Мы создали кросс-платформенную библиотеку SharedLib для определения айконок в виде перечисления BootstrapSymbol, а также с функциями доступа к данным айконки (символа). Теперь можем перейти к реализации контрола BootstrapIcon для Avalonia.

Шаг 3: Создание BootstrapIcon для Avalonia

Пример использования контрола BootstrapIcon для Avalonia
Пример использования контрола BootstrapIcon для Avalonia

На этом этапе мы реализуем контрол BootstrapIcon непосредственно в проекте TryAvalonia. Мы создадим его "с нуля", постепенно добавляя необходимую функциональность, используя как основу исходный код штатного контрола PathIcon.

Структура раздела

  • Шаг 3.1. Создание копии PathIcon

  • Шаг 3.2. Переход на использование BootstrapSymbol

    • Добавление новых свойств

    • Модификация шаблона

    • Конвертация данных

    • Кэширование данных

    • Логика обработки Symbol

Шаг 3.1. Создание копии PathIcon

Сначала создадим базовые файлы для нашего контрола и сделаем его функциональной копией стандартного PathIcon. В проекте TryAvalonia создайте новую папку с именем Controls, перейдите в нее в терминале и выполните команду:

dotnet new avalonia.templatedcontrol -n BootstrapIcon -p:n TryAvalonia.Controls

Эта команда создаст в папке Controls два файла BootstrapIcon.axaml и BootstrapIcon.axaml.cs, используя namespace TryAvalonia.Controls.

Если вы предпочитаете работать через Rider, Visual Studio или VS Code, используйте соответствующие шаблоны "Avalonia Templated Control", указав папку Controls и имя BootstrapIcon. Затем надо изменить namespace на TryAvalonia.Controls (по умолчанию, будет TryAvalonia).

Теперь необходимо включить тему BootstrapIcon.axaml в ресурсы приложения. Откройте файл App.axaml в корне проекта TryAvalonia и добавьте ссылку на файл темы в секцию <Application.Resources>:

скрытый текст App.xaml
<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="TryAvalonia.App"
             RequestedThemeVariant="Default">
  <Application.Resources>
    <ResourceDictionary>
      <ResourceDictionary.MergedDictionaries>
        <ResourceInclude Source="/Controls/BootstrapIcon.axaml"/>
      </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
  </Application.Resources>

  <Application.Styles>
    <FluentTheme />
  </Application.Styles>
</Application>

Проверьте, что проект компилируется без ошибок и что контрол BootstrapIcon отображается в дизайнере файла BootstrapIcon.axaml (на этом этапе вы должны увидеть стандартный текст "Templated Control").

Теперь превратим наш контрол BootstrapIcon в полный аналог штатного контрола PathIcon, перенеся необходимый исходный код из репозитория Avalonia. Это даст нам рабочую основу для дальнейших доработок.

Исходный код стандартного PathIcon (Avalonia):

После переноса кода из PathIcon получим BootstrapIcon.axaml.cs с учетом переименования. Используйте этот код для проверки того, что выполнено:

скрытый текст BootstrapIcon.axaml.cs
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;

namespace TryAvalonia.Controls;

public class BootstrapIcon : IconElement
{
    static BootstrapIcon()
    {
        AffectsRender<BootstrapIcon>(DataProperty);
    }

    public static readonly StyledProperty<Geometry?> DataProperty =
        AvaloniaProperty.Register<BootstrapIcon, Geometry?>(nameof(Data));
    public Geometry? Data
    {
        get => GetValue(DataProperty);
        set => SetValue(DataProperty, value);
    }
} 

После переноса кода из PathIcon получим BootstrapIcon.axaml с учетом переименования, использования xmlns:ui="using:TryAvalonia" и замены тестовой геометрии. Используйте этот код для проверки того, что сделали на этом шаге:

скрытый текст BootstrapIcon.axaml
<ResourceDictionary xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:ui="using:TryAvalonia.Controls">

  <Design.PreviewWith>
    <StackPanel Width="32" Spacing="10">
      <StackPanel.Resources>
        <StreamGeometry x:Key="test_geometry">M3,3 8,6 13,3 10,8 13,13 8,10 3,13 6,8z</StreamGeometry>
      </StackPanel.Resources>      
      <StackPanel Background="{DynamicResource SystemRegionBrush}">
        <ui:BootstrapIcon Data="{StaticResource test_geometry}" />
      </StackPanel>
    </StackPanel>
  </Design.PreviewWith>
 
  <ControlTheme x:Key="{x:Type ui:BootstrapIcon}" TargetType="ui:BootstrapIcon">
    <Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
    <Setter Property="Background" Value="Transparent" />
    <Setter Property="Height" Value="{DynamicResource IconElementThemeHeight}" />
    <Setter Property="Width" Value="{DynamicResource IconElementThemeWidth}" />
    <Setter Property="Template">
      <ControlTemplate>
        <Border Background="{TemplateBinding Background}">
          <Viewbox Height="{TemplateBinding Height}" Width="{TemplateBinding Width}">
            <Path Fill="{TemplateBinding Foreground}" Data="{TemplateBinding Data}" Stretch="Uniform" />
          </Viewbox>
        </Border>
      </ControlTemplate>
    </Setter>
  </ControlTheme>
</ResourceDictionary>

После этих изменений контрол BootstrapIcon по функциональности станет копией PathIcon. Снова проверьте компиляцию и отображение в дизайнере. Теперь контрол способен отображать простую геометрию, заданную через свойство Data.

Шаг 3.2. Переход на использование BootstrapSymbol

В текущей реализации (PathIcon-копии) геометрия задается через одно свойство Data (типа Geometry), а цвет — через унаследованный Foreground (типа Brush). Наш BootstrapIcon должен уметь отображать двухцветную айконку, значение которой задается через основное свойство Symbol (типа BootstrapSymbol из SharedLib). Значение из перечисления BootstrapSymbol определяет геометрии и цвета фрагментов изображения.

Объем кода BootstrapIcon после всех доработок значительно увеличится, но логика реализации достаточно простая и мы её реализуем в следующей последовательности:

  1. Добавление свойства Symbol и производных от него внутренних свойств для двух частей геометрии и цветов.

  2. Модификация шаблона (BootstrapIcon.axaml) для использования новых внутренних свойств (вместо свойства Data).

  3. Реализация конвертации данных: получение данных для Symbol, парсинг, прописывание во внутренние свойства.

  4. Реализация механизма кэширования данных.

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

Далее по шагам опишем эту трансформацию.

Добавление новых свойств

На этом шаге изменим файл BootstrapIcon.axaml.cs: добавим основное свойство Symbol и четыре производных от него (именно эти свойства будут использоваться для отрисовки). Устаревшее свойство Data пока не удаляем (иначе будем получать ошибку компиляции до обновления шаблона BootstrapIcon.axaml). В статическом конструкторе меняем параметр у вызова функции AffectsRender<BootstrapIcon>():

скрытый текст фрагмента BootstrapIcon.axaml.cs (свойства)
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using SharedLib.Controls;

namespace TryAvalonia.Controls;

public class BootstrapIcon : IconElement
{
    static BootstrapIcon()
    {
        AffectsRender<BootstrapIcon>(SymbolProperty);
    }
    //...
    #region // Properties
    public static readonly StyledProperty<BootstrapSymbol> SymbolProperty =
        AvaloniaProperty.Register<BootstrapIcon, BootstrapSymbol>(nameof(Symbol), BootstrapSymbol.None);
    public BootstrapSymbol Symbol
    {
        get => GetValue(SymbolProperty);
        set => SetValue(SymbolProperty, value);
    }

    // Производные внутренние свойства для отрисовки
    internal static readonly StyledProperty<Geometry?> PrimaryGeometryProperty =
        AvaloniaProperty.Register<BootstrapIcon, Geometry?>(nameof(PrimaryGeometry));
    internal Geometry? PrimaryGeometry { get => GetValue(PrimaryGeometryProperty); set => SetValue(PrimaryGeometryProperty, value); }

    internal static readonly StyledProperty<IBrush?> PrimaryForegroundProperty =
        AvaloniaProperty.Register<BootstrapIcon, IBrush?>(nameof(PrimaryForeground));
    internal IBrush? PrimaryForeground { get => GetValue(PrimaryForegroundProperty); set => SetValue(PrimaryForegroundProperty, value); }

    internal static readonly StyledProperty<Geometry?> SecondaryGeometryProperty =
        AvaloniaProperty.Register<BootstrapIcon, Geometry?>(nameof(SecondaryGeometry));
    internal Geometry? SecondaryGeometry { get => GetValue(SecondaryGeometryProperty); set => SetValue(SecondaryGeometryProperty, value); }

    internal static readonly StyledProperty<IBrush?> SecondaryForegroundProperty =
        AvaloniaProperty.Register<BootstrapIcon, IBrush?>(nameof(SecondaryForeground));
    internal IBrush? SecondaryForeground { get => GetValue(SecondaryForegroundProperty); set => SetValue(SecondaryForegroundProperty, value); }
    #endregion
    // ...
}

Теперь класс имеет необходимые свойства, но еще не обновляет их на основе Symbol.

Модификация шаблона

Измените шаблон контрола в BootstrapIcon.axaml, чтобы он использовал новые внутренние свойства и отображал два элемента Path.

Для начала заменим <Design.PreviewWith>, чтобы визуально проверять корректность работы XAML-шаблона в дизайн-режиме. Можно напрямую прописать PrimaryGeometry и PrimaryForeground для целей тестирования.

<Design.PreviewWith>
  <StackPanel Margin="4" Spacing="8" Orientation="Horizontal">
     <ui:BootstrapIcon PrimaryGeometry="M3,3 8,6 13,3 10,8 13,13 8,10 3,13 6,8z"
                       PrimaryForeground="BlueViolet"/>
     <ui:BootstrapIcon Symbol="Bootstrap"/>
     <ui:BootstrapIcon Symbol="PathIcons" />
     <ui:BootstrapIcon Symbol="Custom1" Width="32" Height="32" />
     <ui:BootstrapIcon Symbol="Custom2" Width="32" Height="32" />
  </StackPanel>
</Design.PreviewWith>

Добавим в словарь ресурсов BootstrapIcon.axaml определение значения BootstrapIconSize (этот значение будет использоваться в сеттерах <ControlTheme> и позволит централизованно управлять размером контрола). Текущее значение BootstrapIconSize задает размер BootstrapIcon по умолчанию.

  <x:Double x:Key="BootstrapIconSize">20</x:Double>

BootstrapIconSize - это замена определений IconElementThemeWidth и IconElementThemeHeight из общих ресурсов Avalonia, которые делает то же самое для PathIcon.

Изменим блок сеттеров в <ControlTheme> для свойств BootstrapIcon: сеттер Foreground заменим на два сеттера PrimaryForeground и SecondaryForeground, а вместо {DynamicResource IconElementThemeWidth} и {DynamicResource IconElementThemeHeight} теперь используем {DynamicResource BootstrapIconSize}:

<ControlTheme x:Key="{x:Type ui:BootstrapIcon}" TargetType="ui:BootstrapIcon">
  <Setter Property="PrimaryForeground" Value="{DynamicResource TextControlForeground}" />
  <Setter Property="SecondaryForeground" Value="{DynamicResource TextControlForeground}" />
  <Setter Property="Background" Value="Transparent" />
  <Setter Property="Height" Value="{DynamicResource BootstrapIconSize}" />
  <Setter Property="Width" Value="{DynamicResource BootstrapIconSize}" />
  ...
</ControlTheme>

Использование {DynamicResource TextControlForeground} означает, что в случае неопределенного цвета для фрагментов изображения (PrimaryForeground, SecondaryForeground) будет использован системный цвет для текстовых контролов (поведение BootstrapIcon аналогично PathIcon).

Заменим содержимое <ControlTemplate>:

<ControlTheme x:Key="{x:Type ui:BootstrapIcon}" TargetType="ui:BootstrapIcon">
  ...
  <Setter Property="Template">
    <ControlTemplate>
      <Border Background="{TemplateBinding Background}">
        <Viewbox Stretch="Uniform">
          <Panel>
            <Path Width="16" Height="16" Stretch="None"
                  Fill="{TemplateBinding PrimaryForeground}"
                  Data="{TemplateBinding PrimaryGeometry}" />
            <Path Width="16" Height="16" Stretch="None"
                  Fill="{TemplateBinding SecondaryForeground}"
                  Data="{TemplateBinding SecondaryGeometry}"/>
          </Panel>
        </Viewbox>
      </Border>
    </ControlTemplate>
  </Setter>
  ...
</ControlTheme>

Как работает содержимое <Viewbox> мы уже описывали во "Введении" при создании прототипа BootstrapIcon.

Добавим ещё в <ControlTheme> триггер на изменение стиля для :disabled, чтобы отличать запрещенные элементы интерфейса от нормальных (уменьшаем Opacity).

<ControlTheme x:Key="{x:Type ui:BootstrapIcon}" TargetType="ui:BootstrapIcon">
    ...
    <!-- Style to reduce opacity of the icon's panel when the control is disabled. -->
    <Style Selector="^:disabled /template/ Panel">
      <Setter Property="Opacity" Value="0.3" />
    </Style>
</ControlTheme>

В классе BootstrapIcon.axaml.cs удалите свойство DataProperty и его обертку Data, т.к. шаблон уже не использует это свойство.

Теперь шаблон готов отображать две части айконки, как только внутренние свойства будут заполнены логикой класса. Разработка шаблона завершена, больше изменений в файле BootstrapIcon.axaml не потребуется.

Конвертация данных

Вспомним про функцию расширения GetGeometryDefinition(), которая по текущему значению Symbol (имеющему тип BootstrapSymbol) может извлечь данные из атрибутов и вернуть кортеж (string primaryPath, uint primaryArgb, string secondaryPath, uint secondaryArgb). Нам надо обеспечить конвертацию этих данных в удобную форму для хранения (кэширования) и отображения (прописывания во внутренние свойства).

Для этого в класс BootstrapIcon добавьте структуру SymbolParsed, которая будет хранить распарсенные данные с UI-специфичными типами Geometry и Brush. Именно в эту структуру конвертируются полученные данные Symbol для отображения, сохраняются в кэш, передаются в качестве параметров внутренних функций:

скрытый текст фрагмента BootstrapIcon.axaml.cs (структура SymbolParsed)
public class BootstrapIcon : IconElement
{
    // ...
    private readonly struct SymbolParsed(Geometry? primaryGeo, Brush? primaryBrush, Geometry? secondaryGeo, Brush? secondaryBrush)
    {
        public Geometry? PrimaryGeometry { get; } = primaryGeo;
        public Brush? PrimaryForeground { get; } = primaryBrush;
        public Geometry? SecondaryGeometry { get; } = secondaryGeo;
        public Brush? SecondaryForeground { get; } = secondaryBrush;

        // An empty SymbolParsed instance for error cases.
        public static SymbolParsed Empty => new(null, null, null, null);
    }
}

В класс BootstrapIcon добавьте пару функций конвертации: uint в Brush и BootstrapSymbol в структуру SymbolParsed.

скрытый текст фрагмента BootstrapIcon.axaml.cs (конвертация)
public class BootstrapIcon : IconElement
{
    // ...
    // Converts an ARGB uint value into a SolidColorBrush, or null if the value is 0.
    private static SolidColorBrush? CreateBrushFromArgb(uint ardb)
        => ardb == 0 ? null : new SolidColorBrush(ardb);

    // Converts a BootstrapSymbol into parsed geometry and brush data for rendering.
    private static SymbolParsed CreateSymbolParsed(BootstrapSymbol symb)
    {
        try
        {
            var (primaryPath, primaryArgb, secondaryPath, secondaryArgb) = symb.GetGeometryDefinition();
            Geometry? primaryGeo = Geometry.Parse(primaryPath ?? "");
            Brush? primaryBrush = CreateBrushFromArgb(primaryArgb);
            Geometry? secondaryGeo = Geometry.Parse(secondaryPath ?? "");
            Brush? secondaryBrush = CreateBrushFromArgb(secondaryArgb);

            // Return parsed data as a SymbolParsed struct.
            return new SymbolParsed(primaryGeo, primaryBrush, secondaryGeo, secondaryBrush);
        }
        catch
        {
            // Return empty data if parsing fails.
            return SymbolParsed.Empty;
        }
    }
    //...
}

Осталось добавить в класс BootstrapIcon простую функцию, которая применяет (конвертирует) значение SymbolParsed во внутренние свойства PrimaryGeometry, PrimaryForeground, SecondaryGeometry, SecondaryForeground.

скрытый текст фрагмента BootstrapIcon.axaml.cs (применение свойств)
public class BootstrapIcon : IconElement
{
    //...
    private void ApplySymbolData(SymbolParsed data)
    {
        PrimaryGeometry = data.PrimaryGeometry;
        SecondaryGeometry = data.SecondaryGeometry;

        if (data.PrimaryForeground != null)
        {
            PrimaryForeground = data.PrimaryForeground;
        }
        else
        {
            ClearValue(PrimaryForegroundProperty);
        }
        if (data.SecondaryForeground != null)
        {
            SecondaryForeground = data.SecondaryForeground;
        }
        else
        {
            ClearValue(SecondaryForegroundProperty);
        }
    }
    //...
}

Обратите внимание на использование ClearValue() для свойств контрола. Если цвет фрагмента изображения не определен (например, в атрибуте SymbolPath не был указан цвет, или указан цвет с ARGB=0), то вместо прописывания null в свойства PrimaryForeground и SecondaryForeground мы их очищаем (делаем неопределенными). В этом случае, Avalonia при отрисовке будет использовать системный цвет для текстовых контролов (значение {DynamicResource TextControlForeground}).

Кэширование данных

Чтобы избежать повторного парсинга геометрии и создания кистей каждый раз при использовании одного и того же значения Symbol, реализуем простой механизм кэширования. Для этого в класс BootstrapIcon достаточно добавить статическое поле словаря для хранения кэша:

private static readonly Dictionary<BootstrapSymbol, SymbolParsed> _symbolDataCache = [];

Этот статический словарь _symbolDataCache будет хранить структуру SymbolParsed для каждого значения BootstrapSymbol, с которым мы уже работали, обеспечивая быстрый доступ к уже готовым объектам Geometry и Brush. Механизм доступа к данным кэша обычный, например, используя _symbolDataCache.TryGetValue().

Логика обработки Symbol

Реализуем основную логику класса BootstrapIcon.axaml.cs: обработку изменения свойства Symbol, получение данных из SharedLib (через локальный кэш), преобразование их в формат Avalonia и обновление внутренних свойств.

Для этого в статическом конструкторе класса настройте подписку на событие изменения SymbolProperty и реализуйте логику получения данных (с проверкой кэша) и вызовом вспомогательных методов для парсинга и применения данных в функции OnSymbolChanged():

скрытый текст фрагмента BootstrapIcon.axaml.cs (изменение свойств)
public class BootstrapIcon : IconElement
{
    // A cache to store parsed symbol data for each BootstrapSymbol to improve performance.
    private static readonly Dictionary<BootstrapSymbol, SymbolParsed> _symbolDataCache = [];

    // Static constructor to set up property change handling for the Symbol property.
    static BootstrapIcon()
    {
        AffectsRender<BootstrapIcon>(SymbolProperty);
        SymbolProperty.Changed.AddClassHandler<BootstrapIcon>(OnSymbolChanged);
    }

    // Handles changes to the Symbol property, updating the control's geometries and colors.
    private static void OnSymbolChanged(BootstrapIcon icon, AvaloniaPropertyChangedEventArgs e)
    {
        var symb = e.GetNewValue<BootstrapSymbol>();
        if (!_symbolDataCache.TryGetValue(symb, out var pathData))
        {
            // Parse and cache new symbol data
            pathData = CreateSymbolParsed(symb);
            _symbolDataCache.Add(symb, pathData);
        }
        icon.ApplySymbolData(pathData);
    }
    //...
}

После реализации этих методов ваш класс BootstrapIcon.axaml.cs будет содержать всю логику, необходимую для загрузки, кэширования и применения данных иконок из SharedLib, которые затем будут отображены шаблоном.

Ссылки на полные исходники BootstrapIcon для Avalonia:

На этом Шаг 3 завершен. Мы полностью реализовали контрол BootstrapIcon для Avalonia. Теперь можем перейти к его использованию в интерфейсе приложения (Шаг 4).

Шаг 4: Использование BootstrapIcon для Avalonia

На этом шаге мы продемонстрируем использование контрола BootstrapIcon в интерфейсе нашего Avalonia приложения (TryAvalonia). Для этого мы модифицируем файл главного окна MainWindow.axaml, чтобы создать простой псевдотулбар с кнопками, использующими наши цветные айконки.

Начнем со стандартной структуры окна Avalonia. Добавим пространство имен xmlns:ui="using:TryAvalonia.Controls". Создадим базовый <DockPanel> с верхней областью для кнопок <StackPanel Classes="toolbar"> и центральной областью <TextBlock>:

скрытый текст MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:ui="using:TryAvalonia.Controls"
        Width="650" Height="150"
        mc:Ignorable="d" x:Class="TryAvalonia.MainWindow" Title="TryAvalonia">
  <DockPanel>
    <StackPanel DockPanel.Dock="Top" Classes="toolbar" Orientation="Horizontal">
      <!-- место размещения кнопок-->
    </StackPanel>
    <TextBlock Text="Welcome to Avalonia!" VerticalAlignment="Center" HorizontalAlignment="Center"/>
  </DockPanel>
</Window>

Внутри <StackPanel> с DockPanel.Dock="Top" мы будем размещать наши кнопки содержащие <ui:BootstrapIcon> и <TextBlock>. Пример определения одной такой кнопки:

<Button>
    <StackPanel>
        <ui:BootstrapIcon Symbol="SortAlphaDown" />
        <TextBlock Text="Sort" />
    </StackPanel>
</Button>

Здесь мы используем контрол <ui:BootstrapIcon> и задаем его свойство Symbol значением из нашего перечисления BootstrapSymbol (например, "SortAlphaDown"). Благодаря логике контрола, он получит геометрию и цвета для данной айконки из SharedLib и отобразит ее.

Можно задать свои размеры ui:BootstrapIcon по умолчанию (например, 24x24), для этого достаточно переопределить значение BootstrapIconSize в ресурсах окна:

<Window.Resources>
    <x:Double x:Key="BootstrapIconSize">24</x:Double>
</Window.Resources>

Для придания кнопкам вида панели инструментов мы можем задать для всех единый стиль прямо в окне:

<Window.Styles>
  <Style Selector="StackPanel.toolbar">
    <Setter Property="Background" Value="#EEF5FD"/>
  </Style>
  <Style Selector="StackPanel.toolbar Button">
    <Setter Property="Background" Value="Transparent"/>
    <Setter Property="MinWidth" Value="60"/>
  </Style>
  <Style Selector="StackPanel.toolbar Button TextBlock">
    <Setter Property="HorizontalAlignment" Value="Center"/>
    <Setter Property="FontSize" Value="12"/>
  </Style>
</Window.Styles>

Теперь, собрав все эти части вместе и добавив больше кнопок по примеру выше в верхний StackPanel, вы получите что-то похожее на тулбар с разнообразными цветными айконками BootstrapIcon.

Полный код AppIcons/TryAvalonia/MainWindow.axaml на GitHub.

Запустите проект TryAvalonia, чтобы увидеть результат.

На этом Шаг 4 завершен. Мы успешно использовали наш контрол в Avalonia приложении.

Шаг 5: Создание BootstrapIcon для WPF

Пример использования контрола BootstrapIcon для WPF
Пример использования контрола BootstrapIcon для WPF

На этом этапе мы реализуем контрол BootstrapIcon для WPF-приложения в проекте TryWpf.

Структура раздела

  • Шаг 5.1. Создание заготовки для BootstrapIcon

  • Шаг 5.2. Адаптация класса BootstrapIcon для WPF

  • Шаг 5.3. Адаптация темы BootstrapIcon для WPF

Создавать BootstrapIcon для WPF полностью "с нуля" не будем, адаптируем созданный BootstrapIcon для Avalonia с учетом особенностей реализации пользовательских контролов для WPF. Как и в Avalonia-версии, наш контрол будет использовать определения айконок из кросс-платформенной библиотеки SharedLib.

Шаг 5.1. Создание заготовки для BootstrapIcon

В отличие от Avalonia, WPF не предоставляет удобный шаблон через dotnet CLI для создания пустого пользовательского контрола с темой, а также не имеет штатного аналога PathIcon.

Поэтому создадим необходимые файлы вручную. В проекте TryWpf создайте новую папку Controls и в ней два файла: BootstrapIcon.cs и BootstrapIcon.xaml.

скрытый текст BootstrapIcon.cs
using System.Windows.Controls;

namespace TryWpf.Controls;
public class BootstrapIcon : Control
{
    DefaultStyleKeyProperty.OverrideMetadata(typeof(BootstrapIcon), new FrameworkPropertyMetadata(typeof(BootstrapIcon)));
}
скрытый текст BootstrapIcon.xaml
<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:ui="clr-namespace:TryWpf.Controls">

    <Style TargetType="{x:Type ui:BootstrapIcon}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type ui:BootstrapIcon}">
                    <TextBlock Text="BootstrapIcon WPF Placeholder" />
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

Теперь необходимо включить стиль нашего контрола, определенный в BootstrapIcon.xaml, в ресурсы приложения. Откройте файл App.xaml в корне проекта TryWpf и добавьте ссылку на файл в секцию <Application.Resources>:

скрытый текст App.xaml
<Application x:Class="TryWpf.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:TryWpf"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/TryWpf;component/Controls/BootstrapIcon.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

Проверьте, что проект компилируется без ошибок. На данном этапе у вас есть пустой WPF контрол с базовым стилем и шаблоном-заглушкой.

Шаг 5.2. Адаптация класса BootstrapIcon для WPF

Переносим и адаптируем логику класса BootstrapIcon.axaml.cs (из Шага 3) для использования в WPF. Основные отличия в классе BootstrapIcon.cs для WPF будут связаны с моделью свойств и системой рендеринга, наиболее существенные из них:

  • Класс наследуется от System.Windows.Controls.Control (вместо IconElement).

  • Для определения свойств используется System.Windows.DependencyProperty (вместо StyledProperty).

  • Регистрация и подписка на изменения свойств реализуется по-другому.

Логика получения данных из SharedLib через GetGeometryDefinition() и кэширования аналогична Avalonia-версии.

Шаг 5.3. Адаптация темы BootstrapIcon для WPF

Адаптируем XAML-тему BootstrapIcon.axaml (из Шага 3) для использования в WPF. Ключевые моменты адаптации BootstrapIcon.xaml:

  • Используется <Style> вместо <ControlTheme>.

  • Используется <Grid> вместо <Panel>.

  • Используется <ControlTemplate.Triggers> вместо <Style Selector="...">.

В целом, XAML-структура и принципы привязки данных очень схожи, но требуется адаптация синтаксиса и специфичных для WPF элементов/свойств.

Ссылки на полные исходники BootstrapIcon для WPF:

На этом Шаг 5 завершен. Мы подготовили и адаптировали код контрола BootstrapIcon для использования в WPF-приложении. В следующем шаге продемонстрируем его использование.

Шаг 6: Использование BootstrapIcon для WPF

На этом шаге продемонстрируем использование контрола BootstrapIcon в интерфейсе WPF-приложения TryWpf. Для этого модифицируем файл главного окна MainWindow.xaml, задействовав штатный ToolBar.

Добавьте в корневой элемент окна пространство имен нашего контрола: xmlns:ui="clr-namespace:TryWpf.Controls".

Внутри <DockPanel> добавляем <ToolBarTray DockPanel.Dock="Top"> с единственным <ToolBar>. На этом тулбаре мы будем размещать наши кнопки с экземпляром <ui:BootstrapIcon> и <TextBlock> для подписи. Кроме того (в отличии от Avalonia), у нас появилась возможность использовать <Separator>. Пример определения одной такой кнопки и разделителя:

<Button>
    <StackPanel>
        <ui:BootstrapIcon Symbol="WindowSidebar" />
        <TextBlock Text="Sidebar" />
    </StackPanel>
</Button>
<Separator/>
скрытый текст MainWindow.xaml (пример с одной кнопкой и разделителем)
<Window x:Class="TryWpf.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:ui="clr-namespace:TryWpf.Controls"
        xmlns:sys="clr-namespace:System;assembly=System.Runtime"
        mc:Ignorable="d"
        Title="TryWpf" Height="150" Width="600">
    <DockPanel>
        <ToolBarTray DockPanel.Dock="Top" IsLocked="True">
            <ToolBar>
                <Button>
                    <StackPanel>
                        <ui:BootstrapIcon Symbol="WindowSidebar"/>
                        <TextBlock Text="Sidebar"/>
                    </StackPanel>
                </Button>
                <Separator/>
            </ToolBar>
        </ToolBarTray>
        <TextBlock Text="Welcome to Wpf!" VerticalAlignment="Center" HorizontalAlignment="Center"/>
    </DockPanel>
</Window>

Добавьте нужный набор кнопок на тулбар, определите подходящие стили. Здесь размер айконок также можно менять при помощи значения ресурса <sys:Double x:Key="BootstrapIconSize">32</sys:Double>.

Полный код AppIcons/TryWpf/MainWindow.xaml на GitHub.

Запустите проект TryWpf, чтобы увидеть результат.

На этом Шаг 6 завершен. Мы успешно использовали наш контрол BootstrapIcon в WPF-приложении.

Заключение

В этом туториале мы прошли путь от выбора коллекции векторных иконок Bootstrap Icons до реализации готового к использованию контрола BootstrapIcon для Avalonia и WPF. Мы разработали кросс-платформенную библиотеку SharedLib для централизованного хранения определений иконок и реализовали UI-специфические контролы, способные отображать двухцветные векторные иконки на основе этих данных.

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

Этот туториал поможет вам создавать более привлекательные и функциональные пользовательские интерфейсы в ваших приложениях на Avalonia/WPF.

? Полезные ссылки

  • Исходный код AvaloniaUI (версия 11.3.0) на GitHub: ссылка

  • Официальный сайт Bootstrap Icons: icons.getbootstrap.com

  • Онлайн-редактор SVG-путей: SvgPathEditor

  • Генератор кода перечисления BootstrapSymbol (npm пакет): path-icons

  • Исходный код примера из этого туториала на GitHub: ссылка

? Продолжение следует...
Не удаляйте созданные тестовые проекты, они могут пригодится. В следующих туториалах будет продолжена их доработка: будут добавлены примеры использования BootstrapIcon в главном меню приложения и аналоге ToolBar.

Комментарии (10)


  1. Ydav359
    09.06.2025 07:15

    Супер, но разве хранить геометрию в атрибуте не излишне? Это же рефлексия для извлечения. Просто вопрос, если что


    1. edward_nsk Автор
      09.06.2025 07:15

      Это один из вариантов хранения в C#-коде, когда всё в одном месте (обозримость, легкая правка). Для меня было главное - уйти от ресурсов в XAML и как-то автоматизировать включение фрагментов SVG-файла в свой код. Возможно, не самый оптимальный вариант. Что можете предложить взамен?


      1. Ydav359
        09.06.2025 07:15

        Эти вопросы я больше для себя задаю, чтобы лучше понимать тему, спасибо за ответ!


  1. xtraroman
    09.06.2025 07:15

    Мы решали подобную задачку в EMXControls. Сделали сборку для векторных иконок. На source generator создаем из названий файлов специальный класс свойства которого называются так же как иконки. За счет этого работают подсказки в VisualStudio и невозможно использовать иконку которой нет в наборе. В коде получается вот так

    Eremex.AvaloniaUI.Icons.Basic.Question

    в xaml

    Glyph="{x:Static icons:Basic.Question}"

    для раскраски иконок используем css

    Как нибудь напишем статью об этом.


    1. edward_nsk Автор
      09.06.2025 07:15

      для раскраски иконок используем css

      А это как сделать в Avalonia?


      1. xtraroman
        09.06.2025 07:15

        посмотрите этот пример

        https://github.com/wieslawsoltes/Svg.Skia/tree/master/samples/AvaloniaSvgSkiaStylingSample

        Svg.Css - это attached inherited свойство. Работает на поддерево.


        1. edward_nsk Автор
          09.06.2025 07:15

          Svg.Css - это attached inherited свойство. Работает на поддерево.

          Спасибо, посмотрю как они это реализовали. Только для нашего случая, наверно, будет тяжеловато. И по рендерингу, и по программной реализации.


  1. edward_nsk Автор
    09.06.2025 07:15

    Пример подсказок Visual Studio
    Пример подсказок Visual Studio

    Согласен, при помощи класса тоже хороший вариант. Для моей реализации подсказки в дизайнере Visual Studio тоже работают, причем без {x:Static} и, что удивительно, даже namespace не надо указывать.

    upd: Компилятор тоже не даст указать несуществующую айконку.


  1. Colt045
    09.06.2025 07:15

    На момент написания статьи большинство (2070 из 2074) Bootstrap Icons используют для отрисовки исключительно элементы <path> (не задействуют <circle><rect>)

    Если кому интересно, на сейчас из 2078 иконок, богопротивные элементы circle и rect есть в следующих файлах:

    align-bottom
    align-top
    circle-fill
    dice-1

    ПыСы Извините, немного вопрос не по окладу: а почему "айконка", а не "иконка"?


    1. edward_nsk Автор
      09.06.2025 07:15

      Абсолютно точно, именно эти 4 штуки мне кровь портили.
      Поэтому я в своем генераторе их переопределяю.

      {
        "align-bottom": [
          "M6 1h4a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z",
          "M1.5 14a.5.5 0 0 0 0 1zm13 1a.5.5 0 0 0 0-1zm-13 0h13v-1h-13z"
        ],
        "align-top": [
          "M6 15h4a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H6a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1z",
          "M1.5 2a.5.5 0 0 1 0-1zm13-1a.5.5 0 0 1 0 1zm-13 0h13v1h-13z"
        ],
        "circle-fill": [
          "M16 8A8 8 0 1 1 0 8A8 8 0 1 1 16 8Z"
        ],
        "dice-1": [
          "M9.5 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z",
          "M13 1a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2zM3 0a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V3a3 3 0 0 0-3-3z"
        ]
      }

      Результат работы генератора:

      > path-icons@0.0.13 update-bi D:\Git\path-icons
      > node build/update-bi-json.mjs --verbose
      
      Starting Bootstrap Icons JSON build...
      Found 2078 SVG file(s) to process from D:\Git\path-icons\node_modules\bootstrap-icons\icons
        WARN: Skipping align-bottom.svg. Contains disallowed element type: <rect>.
        WARN: Skipping align-top.svg. Contains disallowed element type: <rect>.
        WARN: Skipping circle-fill.svg. Contains disallowed element type: <circle>.
        WARN: Skipping dice-1.svg. Contains disallowed element type: <circle>.
      
      --- Build Summary ---
      Total SVG files found: 2078
      - 2074 successfully processed and included
      - 4 skipped (invalid size/content/no paths/error)
      
      Path Count Distribution (for processed icons):
      - 1217 icon(s) with 1 path
      - 753 icon(s) with 2 paths
      - 92 icon(s) with 3 paths
      - 9 icon(s) with 4 paths
      - 3 icon(s) with 5 paths
      
      Output JSON written to: D:\Git\path-icons\src\bi.json
      Build completed: 112.916ms
      

      PS: простите за мой француcкий, проверявший меня на ошибки и опечатки ИИ не ругался.