Первая форма WPF — XAML, стили и шаблоны
Как проще пройти материал
Держите разделение "данные отдельно, UI отдельно" с самого первого шага: сначала модель и ViewModel, потом XAML и стили.
Так код практикума сразу складывается в структуру, которую легко переносить в рабочий проект.
Практикум. Вы знаете C# и хотите закрепить XAML — декларативную разметку интерфейса. Здесь собираем полное WPF-приложение с нуля: форма ввода, список карточек, общие стили и шаблоны элементов. Обзор платформы — в 116.md; синтаксис XAML — в справочнике XAML.
Что получится
Окно "Заметки" с:
- Полем заголовка и выпадающим списком категорий.
- Кнопкой "Добавить", оформленной через ControlTemplate.
- Списком заметок, где каждая строка — DataTemplate (карточка с заголовком и меткой категории).
- Общими Style для
TextBoxи цветами вApp.xaml.
Логика — в ViewModel с привязками (Binding); разметка остаётся в XAML, без обработчиков Click в code-behind.
┌─────────────────────────────────────┐
│ Заметки │
├─────────────────────────────────────┤
│ Заголовок: [________________] │
│ Категория: [ Работа ▼ ] │
│ [ Добавить ] │
├─────────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ Купить молоко [Личное] │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ Созвон с командой [Работа] │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
Словарь терминов
| Термин | Простыми словами |
|---|---|
| XAML | XML-разметка, описывающая дерево UI-элементов. Компилируется вместе с проектом. |
| Code-behind | Файл *.xaml.cs с C# для окна; в MVVM здесь почти нет логики — только InitializeComponent(). |
| Binding | Связь свойства контрола с полем ViewModel: {Binding NewTitle}. |
| Style | Набор Setter и Trigger, переиспользуемый для одного типа контрола (TextBox, Button). |
| DataTemplate | Шаблон отображения одного элемента данных в ListBox, ItemsControl и т.п. |
| ControlTemplate | Шаблон внешнего вида самого контрола — как рисуется кнопка "изнутри". |
| ResourceDictionary | Словарь ресурсов (Style, кисти, шаблоны), обычно в App.xaml или отдельном файле. |
| MVVM | Model — данные, View — XAML, ViewModel — свойства и команды для привязок. |
Поиграйте с Grid и StackPanel в симуляторе ниже — те же панели используем в форме.
Шаг 0 — среда и создание проекта
Нужно: .NET SDK 8+, Windows, Visual Studio 2022 или VS Code с C# Dev Kit.
dotnet new wpf -n NoteKeeper -o NoteKeeper
cd NoteKeeper
dotnet run
Появится пустое окно — шаблон wpf создал App.xaml, MainWindow.xaml и точку входа.
Структура после шага 0:
NoteKeeper/
├── NoteKeeper.csproj
├── App.xaml
├── App.xaml.cs
├── MainWindow.xaml
└── MainWindow.xaml.cs
В NoteKeeper.csproj уже есть <UseWPF>true</UseWPF> — без этой строки XAML не соберётся.
Шаг 1 — модель данных
Создайте папку Models и файл Models/NoteItem.cs:
namespace NoteKeeper.Models;
public sealed class NoteItem
{
public required string Title { get; init; }
public required string Category { get; init; }
}
NoteItem — простой объект без логики. Список таких объектов покажем в ListBox; для каждого элемента WPF применит DataTemplate.
Шаг 2 — ViewModel и команда
Папка ViewModels, файл ViewModels/MainViewModel.cs:
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using NoteKeeper.Models;
namespace NoteKeeper.ViewModels;
public sealed class MainViewModel : INotifyPropertyChanged
{
private string _newTitle = string.Empty;
private string _selectedCategory = "Работа";
public string NewTitle
{
get => _newTitle;
set => SetProperty(ref _newTitle, value);
}
public string SelectedCategory
{
get => _selectedCategory;
set => SetProperty(ref _selectedCategory, value);
}
public ObservableCollection<string> Categories { get; } =
new(["Работа", "Личное", "Идеи"]);
public ObservableCollection<NoteItem> Notes { get; } = new();
public ICommand AddNoteCommand { get; }
public MainViewModel()
{
AddNoteCommand = new RelayCommand(AddNote, () => !string.IsNullOrWhiteSpace(NewTitle));
}
private void AddNote()
{
Notes.Add(new NoteItem
{
Title = NewTitle.Trim(),
Category = SelectedCategory,
});
NewTitle = string.Empty;
((RelayCommand)AddNoteCommand).RaiseCanExecuteChanged();
}
public event PropertyChangedEventHandler? PropertyChanged;
private void SetProperty<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
if (name == nameof(NewTitle))
((RelayCommand)AddNoteCommand).RaiseCanExecuteChanged();
}
}
internal sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public void RaiseCanExecuteChanged() =>
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
| Элемент | Зачем |
|---|---|
INotifyPropertyChanged | UI обновляется при изменении NewTitle и других свойств. |
ObservableCollection | ListBox узнаёт о добавлении и удалении элементов без ручного Refresh. |
RelayCommand | Минимальная реализация ICommand для кнопки "Добавить" без сторонних пакетов. |
CanExecute | Кнопка неактивна, пока заголовок пустой. |
Все изменения коллекции и свойств здесь идут из UI-потока. Для сетевых запросов используйте async/await — см. пример в 112.md.
Шаг 3 — ресурсы и стили в App.xaml
Глобальные кисти и стиль TextBox удобно держать в корне приложения. Замените содержимое App.xaml:
<Application x:Class="NoteKeeper.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- Кисти приложения -->
<SolidColorBrush x:Key="BrushBackground" Color="#F4F6F8"/>
<SolidColorBrush x:Key="BrushSurface" Color="#FFFFFF"/>
<SolidColorBrush x:Key="BrushBorder" Color="#D0D7DE"/>
<SolidColorBrush x:Key="BrushAccent" Color="#2563EB"/>
<SolidColorBrush x:Key="BrushAccentHover" Color="#1D4ED8"/>
<SolidColorBrush x:Key="BrushText" Color="#1F2937"/>
<SolidColorBrush x:Key="BrushMuted" Color="#6B7280"/>
<!-- Style — общий вид TextBox -->
<Style TargetType="TextBox">
<Setter Property="Padding" Value="10,8"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderBrush" Value="{StaticResource BrushBorder}"/>
<Setter Property="Background" Value="{StaticResource BrushSurface}"/>
<Setter Property="Foreground" Value="{StaticResource BrushText}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6"
Padding="{TemplateBinding Padding}">
<ScrollViewer x:Name="PART_ContentHost"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource BrushAccent}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Style + ControlTemplate — кнопка "Добавить" -->
<Style x:Key="AccentButtonStyle" TargetType="Button">
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="20,10"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Root"
Background="{StaticResource BrushAccent}"
CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background"
Value="{StaticResource BrushAccentHover}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Application.Resources>
</Application>
Style с TargetType="TextBox" применится ко всем TextBox в приложении. StaticResource BrushBorder ссылается на кисть из того же словаря. Trigger меняет рамку при фокусе.
ControlTemplate для TextBox — скруглённая рамка вокруг внутреннего PART_ContentHost (обязательная часть шаблона поля ввода).
Стиль AccentButtonStyle с ControlTemplate для кнопки мы разберём подробнее на шаге 5.
Шаг 4 — разметка формы MainWindow.xaml
Полностью замените MainWindow.xaml:
<Window x:Class="NoteKeeper.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:NoteKeeper.ViewModels"
Title="Заметки"
Height="520" Width="480"
MinHeight="400" MinWidth="400"
Background="{StaticResource BrushBackground}"
FontFamily="Segoe UI">
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Форма ввода -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="Заголовок" Foreground="{StaticResource BrushMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding NewTitle, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Text="Категория" Foreground="{StaticResource BrushMuted}" Margin="0,12,0,4"/>
<ComboBox ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}"
Padding="8,6" Margin="0,0,0,12"/>
<Button Content="Добавить"
Command="{Binding AddNoteCommand}"
HorizontalAlignment="Left"
Style="{StaticResource AccentButtonStyle}"/>
</StackPanel>
<TextBlock Grid.Row="1" Text="Список" FontWeight="SemiBold"
Foreground="{StaticResource BrushText}" Margin="0,0,0,8"/>
<!-- Список с DataTemplate -->
<ListBox Grid.Row="2"
ItemsSource="{Binding Notes}"
BorderThickness="0"
Background="Transparent"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="{StaticResource BrushSurface}"
BorderBrush="{StaticResource BrushBorder}"
BorderThickness="1"
CornerRadius="8"
Padding="12,10"
Margin="0,0,0,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Title}"
FontSize="14"
Foreground="{StaticResource BrushText}"
VerticalAlignment="Center"/>
<Border Grid.Column="1"
CornerRadius="12"
Padding="10,4"
VerticalAlignment="Center">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#E5E7EB"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Category}" Value="Работа">
<Setter Property="Background" Value="#DBEAFE"/>
</DataTrigger>
<DataTrigger Binding="{Binding Category}" Value="Личное">
<Setter Property="Background" Value="#D1FAE5"/>
</DataTrigger>
<DataTrigger Binding="{Binding Category}" Value="Идеи">
<Setter Property="Background" Value="#FEF3C7"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding Category}"
FontSize="12"
Foreground="{StaticResource BrushMuted}"/>
</Border>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Window>
Разбор разметки
| Фрагмент | Смысл |
|---|---|
Window.DataContext | ViewModel создаётся в XAML; окно — View в MVVM. |
Grid + RowDefinitions | Три зоны — форма, подпись, список на оставшуюся высоту (*). |
UpdateSourceTrigger=PropertyChanged | NewTitle обновляется при каждом символе — CanExecute кнопки реагирует сразу. |
Command="{Binding AddNoteCommand}" | Клик без Click= в code-behind. |
ListBox.ItemTemplate / DataTemplate | Для каждого NoteItem рисуется карточка; Binding Title внутри шаблона — свойство элемента списка. |
DataTrigger | Цвет бейджа категории зависит от строки Category. |
MainWindow.xaml.cs оставьте минимальным:
namespace NoteKeeper;
public partial class MainWindow
{
public MainWindow() => InitializeComponent();
}
Шаг 5 — ControlTemplate для кнопки "Добавить"
На шаге 3 мы уже добавили AccentButtonStyle в App.xaml. Разберём, как устроен этот фрагмент:
<Style x:Key="AccentButtonStyle" TargetType="Button">
<!-- Setters задают свойства кнопки -->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Root" Background="{StaticResource BrushAccent}" CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background"
Value="{StaticResource BrushAccentHover}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
| Понятие | Отличие |
|---|---|
Style (AccentButtonStyle) | Именованный набор свойств; подключается через Style="{StaticResource AccentButtonStyle}". |
| ControlTemplate | Заменяет визуальное дерево кнопки: Border + ContentPresenter вместо стандартной серой кнопки Windows. |
TemplateBinding Padding | Берёт Padding из свойств кнопки в Style. |
ContentPresenter | Место, куда подставляется Content="Добавить". |
DataTemplate описывает данные (карточку заметки). ControlTemplate описывает сам контрол (форму кнопки). Оба — виды шаблонов в WPF.
Шаг 6 — запуск и проверка
dotnet run
Проверьте:
- Кнопка "Добавить" серая и неактивна, пока поле заголовка пустое.
- После ввода текста кнопка активна; по нажатию строка появляется в списке, поле очищается.
- Бейдж категории меняет цвет при смене пункта в
ComboBox. - При наведении кнопка темнеет (
IsMouseOver).
В Visual Studio откройте Live Visual Tree (Отладка → Окна → Live Visual Tree) — увидите дерево XAML в рантайме.
Шаг 7 (опционально) — вынести стили в отдельный файл
В больших проектах ресурсы дробят на файлы. Создайте Styles/Buttons.xaml:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Сюда перенесите AccentButtonStyle целиком -->
</ResourceDictionary>
Подключите в App.xaml:
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Styles/Buttons.xaml"/>
</ResourceDictionary.MergedDictionaries>
<!-- кисти и Style для TextBox -->
</ResourceDictionary>
</Application.Resources>
MergedDictionaries объединяет несколько словарей — стили из Buttons.xaml видны во всём приложении.
Типичные сбои
| Симптом | Вероятная причина | Что проверить |
|---|---|---|
| Пустой список после клика | ItemsSource не привязан или ViewModel не в DataContext | ItemsSource="{Binding Notes}", блок Window.DataContext. |
| Поле не очищается | OneWay-привязка без INotifyPropertyChanged | SetProperty в ViewModel, UpdateSourceTrigger=PropertyChanged. |
| Кнопка всегда активна | Нет CanExecute или не вызывается RaiseCanExecuteChanged | Обработчик в SetProperty для NewTitle. |
StaticResource not found | Стиль объявлен ниже использования или в другом словаре без merge | Порядок в App.xaml, MergedDictionaries. |
Белый TextBox без скругления | Конфликт локального Style | Уберите дублирующий Style на конкретном TextBox. |
| Ошибка XAML при сборке | Опечатка в xmlns:vm | Namespace совпадает с namespace NoteKeeper.ViewModels. |
Что попробовать дальше
- Удаление заметки — кнопка в
DataTemplate, командаRemoveNoteCommandс параметром{Binding}. - Сохранение в JSON — при закрытии окна сериализуйте
Notesв файл; при старте загружайте (System.Text.Json). - CommunityToolkit.Mvvm — атрибуты
[ObservableProperty]и[RelayCommand]вместо ручного boilerplate. - Отдельные UserControl — форму ввода вынесите в
NoteFormView.xamlдля переиспользования. - Упаковка —
dotnet publish -c Release -r win-x64 --self-containedи MSIX по 117.md.
Связанные материалы
- XAML — справочник
- Разработка приложений для Windows (Microsoft Learn)
- Особенности разработки десктопных приложений — MVVM, потоки UI
- Windows Forms (WinForms) — альтернатива с конструктором форм
- Первая программа Electron с React — тот же формат практикума на JS-стеке
- Платформа .NET
Как превратить учебный пример в рабочий модуль
Следующий шаг по архитектуре
- Вынесите
MainViewModelв отдельную сборкуNoteKeeper.Application. - Добавьте сервис хранения заметок (
INoteStorage) и внедрение зависимости. - Подключите журналирование операций добавления, удаления и сохранения.
- Реализуйте валидацию через
INotifyDataErrorInfoдля полей формы.
Что это дает в реальном проекте
- UI остаётся простым и предсказуемым.
- Логику можно покрыть unit-тестами без запуска окна.
- Дальше проще добавить экспорт, синхронизацию и автосохранение.
Практика командной работы с XAML
Разделение ролей в проекте
- Разработчик UI ведет
ResourceDictionary, шаблоны и стили. - Разработчик доменной логики развивает ViewModel и сервисы.
- Общий контракт между ними проходит через свойства и команды.
Технические правила, которые снижают регрессии
- Не храните бизнес-логику в code-behind окна.
- Для каждого важного сценария держите unit-тест на ViewModel.
- Новые стили добавляйте через словари ресурсов и переиспользование.
- Проверяйте визуальные изменения на разных DPI и темах Windows.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Настоятельно рекомендую ознакомиться со главой, посвящённой созданию десктопных приложений на Python - 5.02. Графика и игры. Десктопное приложение — это композитная сущность, объединяющая код, ресурсы, метаданные, конфигурации и, зачастую, механизмы обновления, диагностики и интеграции с другими компонентами системы. Многопоточность, реактивность, ресурсы, отладка и прочее. WebView - встроенный браузер в приложениях. Electron — десктопные приложения на HTML, CSS и JavaScript с процессами main, preload и renderer. Windows Forms — платформа GUI для классических настольных приложений Windows на .NET; формы, контролы, события, привязка данных и визуальный конструктор Visual Studio. Платформа разработчика Windows — Windows SDK, Windows App SDK, WinUI 3, WPF, среда разработки, поддержка и обзор драйверов по документации Microsoft. Учётная запись разработчика, MSIX, Partner Center, сертификация и распространение приложений для Windows через Microsoft Store. Десктопное окно — Electron, Vite, React и безопасный IPC через preload; пошаговый разбор для новичков. Работа с графовыми структурами в коде - визуализация состояний узлов и отладка обходов графа на практике. Краткие итоги раздела "Десктопные приложения". Итоги раздела Десктопные приложения — вопросы для самопроверки в энциклопедии Вселенная IT.Архитектура десктопных приложений
Разработка приложений для настольных операционных систем
Особенности разработки десктопных приложений
WebView
Electron
Windows Forms (WinForms)
Разработка приложений для Windows (Microsoft Learn)
Microsoft Store и публикация Windows-приложений
Первая программа Electron с React
Работа с графовыми структурами в коде
Итоги
Чек-лист самопроверки