Перейти к основному содержимому

Первая форма WPF — XAML, стили и шаблоны

Разработчику Инженеру

Как проще пройти материал

Держите разделение "данные отдельно, UI отдельно" с самого первого шага: сначала модель и ViewModel, потом XAML и стили.
Так код практикума сразу складывается в структуру, которую легко переносить в рабочий проект.

Практикум. Вы знаете C# и хотите закрепить XAML — декларативную разметку интерфейса. Здесь собираем полное WPF-приложение с нуля: форма ввода, список карточек, общие стили и шаблоны элементов. Обзор платформы — в 116.md; синтаксис XAML — в справочнике XAML.


Что получится

Окно "Заметки" с:

  1. Полем заголовка и выпадающим списком категорий.
  2. Кнопкой "Добавить", оформленной через ControlTemplate.
  3. Списком заметок, где каждая строка — DataTemplate (карточка с заголовком и меткой категории).
  4. Общими Style для TextBox и цветами в App.xaml.

Логика — в ViewModel с привязками (Binding); разметка остаётся в XAML, без обработчиков Click в code-behind.

┌─────────────────────────────────────┐
│ Заметки │
├─────────────────────────────────────┤
│ Заголовок: [________________] │
│ Категория: [ Работа ▼ ] │
│ [ Добавить ] │
├─────────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ Купить молоко [Личное] │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ Созвон с командой [Работа] │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘

Словарь терминов

ТерминПростыми словами
XAMLXML-разметка, описывающая дерево 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 или отдельном файле.
MVVMModel — данные, 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);
}
ЭлементЗачем
INotifyPropertyChangedUI обновляется при изменении NewTitle и других свойств.
ObservableCollectionListBox узнаёт о добавлении и удалении элементов без ручного Refresh.
RelayCommandМинимальная реализация ICommand для кнопки "Добавить" без сторонних пакетов.
CanExecuteКнопка неактивна, пока заголовок пустой.
Поток UI

Все изменения коллекции и свойств здесь идут из 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.DataContextViewModel создаётся в XAML; окно — View в MVVM.
Grid + RowDefinitionsТри зоны — форма, подпись, список на оставшуюся высоту (*).
UpdateSourceTrigger=PropertyChangedNewTitle обновляется при каждом символе — 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

Проверьте:

  1. Кнопка "Добавить" серая и неактивна, пока поле заголовка пустое.
  2. После ввода текста кнопка активна; по нажатию строка появляется в списке, поле очищается.
  3. Бейдж категории меняет цвет при смене пункта в ComboBox.
  4. При наведении кнопка темнеет (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 не в DataContextItemsSource="{Binding Notes}", блок Window.DataContext.
Поле не очищаетсяOneWay-привязка без INotifyPropertyChangedSetProperty в ViewModel, UpdateSourceTrigger=PropertyChanged.
Кнопка всегда активнаНет CanExecute или не вызывается RaiseCanExecuteChangedОбработчик в SetProperty для NewTitle.
StaticResource not foundСтиль объявлен ниже использования или в другом словаре без mergeПорядок в App.xaml, MergedDictionaries.
Белый TextBox без скругленияКонфликт локального StyleУберите дублирующий Style на конкретном TextBox.
Ошибка XAML при сборкеОпечатка в xmlns:vmNamespace совпадает с namespace NoteKeeper.ViewModels.

Что попробовать дальше

  1. Удаление заметки — кнопка в DataTemplate, команда RemoveNoteCommand с параметром {Binding}.
  2. Сохранение в JSON — при закрытии окна сериализуйте Notes в файл; при старте загружайте (System.Text.Json).
  3. CommunityToolkit.Mvvm — атрибуты [ObservableProperty] и [RelayCommand] вместо ручного boilerplate.
  4. Отдельные UserControl — форму ввода вынесите в NoteFormView.xaml для переиспользования.
  5. Упаковкаdotnet publish -c Release -r win-x64 --self-contained и MSIX по 117.md.

Связанные материалы


Как превратить учебный пример в рабочий модуль

Следующий шаг по архитектуре

  1. Вынесите MainViewModel в отдельную сборку NoteKeeper.Application.
  2. Добавьте сервис хранения заметок (INoteStorage) и внедрение зависимости.
  3. Подключите журналирование операций добавления, удаления и сохранения.
  4. Реализуйте валидацию через INotifyDataErrorInfo для полей формы.

Что это дает в реальном проекте

  • UI остаётся простым и предсказуемым.
  • Логику можно покрыть unit-тестами без запуска окна.
  • Дальше проще добавить экспорт, синхронизацию и автосохранение.

Практика командной работы с XAML

Разделение ролей в проекте

  • Разработчик UI ведет ResourceDictionary, шаблоны и стили.
  • Разработчик доменной логики развивает ViewModel и сервисы.
  • Общий контракт между ними проходит через свойства и команды.

Технические правила, которые снижают регрессии

  1. Не храните бизнес-логику в code-behind окна.
  2. Для каждого важного сценария держите unit-тест на ViewModel.
  3. Новые стили добавляйте через словари ресурсов и переиспользование.
  4. Проверяйте визуальные изменения на разных DPI и темах Windows.

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").