Desktop UI

Triggers та Visual State Manager у WPF

Property Triggers, DataTrigger, MultiTrigger, EventTrigger та Visual State Manager — повний посібник з реактивної стилізації у WPF. Порівняння зі стилями і Avalonia-підходом.
Нові терміни у цій статті:Property Trigger, DataTrigger, MultiTrigger, MultiDataTrigger, EventTrigger, VisualStateManager (VSM), VisualStateGroup, VisualState, GoToState(), VisualTransition, Style.Triggers, ControlTemplate.Triggers.

Чому одних Setter-ів недостатньо

У статтях 27–28 ми навчились застосовувати стилі та шаблони — але ці стилі завжди статичні: Background="#4F46E5" — і навіки синій. Реальний UI живий: кнопка темніє при наведенні, банер з'являється коли форма невалідна, панель змінює колір коли тасок завершено. Для такої реактивної поведінки у WPF існує механізм Triggers.

Trigger — це умова: «якщо значення певної властивості дорівнює X — застосуй ці Setter-и. Якщо умова перестала виконуватися — автоматично відкоти зміни назад». Це «автоматичне відкочування» є принциповою особливістю Trigger: ви не пишете логіку «прибрати стиль» — WPF робить це сам, коли умова вже не виконується.

У WPF є чотири типи Trigger:

ТипДе використовуєтьсяУмова
TriggerStyle.Triggers, ControlTemplate.TriggersВластивість DependencyProperty контролу
DataTriggerStyle.Triggers, DataTemplate.TriggersBinding-значення (із ViewModel, DataContext)
MultiTriggerStyle.TriggersКілька Trigger-умов одночасно (AND)
MultiDataTriggerStyle.TriggersКілька DataTrigger-умов одночасно (AND)
EventTriggerStyle.Triggers, ControlTemplate.TriggersПодія (RoutedEvent), запускає Storyboard

Кожен тип вирішує свою задачу. Розберемо їх по черзі.


Property Trigger: реакція на DependencyProperty

Trigger — найпростіший і найчастіше вживаний тип. Він слідкує за значенням DependencyProperty контролу і застосовує Setter-и, коли це значення збігається з Value.

Анатомія Property Trigger

<Style TargetType="Button">
    <Setter Property="Background" Value="#4F46E5"/>
    <Setter Property="Foreground" Value="White"/>

    <Style.Triggers>
        <!-- Коли IsMouseOver = True: темніший фон -->
        <Trigger Property="IsMouseOver" Value="True">
            <Setter Property="Background" Value="#3730A3"/>
        </Trigger>

        <!-- Коли IsPressed = True: ще темніший -->
        <Trigger Property="IsPressed" Value="True">
            <Setter Property="Background" Value="#312E81"/>
        </Trigger>

        <!-- Коли IsEnabled = False: прозорий -->
        <Trigger Property="IsEnabled" Value="False">
            <Setter Property="Opacity" Value="0.5"/>
        </Trigger>
    </Style.Triggers>
</Style>

Зверніть на принципово важливу деталь: у цьому прикладі ми не пишемо «коли IsMouseOver = False — повернути фон назад». WPF робить це автоматично. Коли умова перестає виконуватися, всі Setter-и Trigger автоматично відкочуються до базового значення. Це ключова різниця між Trigger і ручним обробником події.

Style.Triggers vs ControlTemplate.Triggers

Trigger можна розміщувати у двох місцях:

Style.Triggers — змінює властивості самого контролу (ті, що задані через Setter у стилі або безпосередньо). Доступ тільки до властивостей хост-контролу.

ControlTemplate.Triggers — всередині ControlTemplate. Може змінювати властивості Named елементів шаблону через TargetName. Більш потужний, але вимагає написання (або модифікації) шаблону.

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Превью використовує Avalonia. У реальному WPF Style.Triggers дають ідентичний результат. Важлива деталь: IsPressed спрацьовує лише якщо натиснути і тримати мишу безпосередньо над кнопкою — при відведенні миші під час натиснення IsPressed стає False.

Пріоритет при конфлікті Trigger-ів

Якщо кілька Trigger-ів застосовують одну й ту саму властивість — останній у списку виграє. Тому порядок Trigger-ів у Style.Triggers важливий: більш специфічні умови мають йти після менш специфічних. IsPressed варто розміщувати після IsMouseOver, тому що IsPressed=True завжди супроводжується IsMouseOver=True — і якщо IsMouseOver стоїть пізніше, він перекриє IsPressed.

<!-- ✅ Правильний порядок: спочатку менш специфічне -->
<Trigger Property="IsMouseOver" Value="True">
    <Setter Property="Background" Value="#3730A3"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">    <!-- Цей виграє при натисканні -->
    <Setter Property="Background" Value="#312E81"/>
</Trigger>

<!-- ❌ Неправильний порядок: IsPressed буде перекрито IsMouseOver -->
<Trigger Property="IsPressed" Value="True">
    <Setter Property="Background" Value="#312E81"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">  <!-- Перекриє IsPressed! -->
    <Setter Property="Background" Value="#3730A3"/>
</Trigger>

DataTrigger: реакція на дані ViewModel

DataTrigger — найважливіший тип для MVVM-архітектури. На відміну від Trigger, що слідкує за DependencyProperty контролу, DataTrigger стежить за значенням Binding. Це означає, що умовою може бути будь-яка властивість ViewModel, результат конвертера, або навіть статичне значення через {x:Static}.

Синтаксис: <DataTrigger Binding="{Binding PropertyName}" Value="TargetValue">. Коли значення Binding дорівнює Value — Setter-и застосовуються. Як тільки значення змінюється — відкочуються.

DataTrigger для статусної картки

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Превью симулює список через статичні елементи. У реальному MVVM-додатку ItemsControl прив'язується до ObservableCollection<TaskViewModel>, а DataTrigger реагує на властивість IsCompleted з ViewModel через INPC. Важливо: DataTriggerне потребує INotifyPropertyChanged безпосередньо — достатньо, щоб Binding оновлювався при зміні.

DataTrigger у Style vs DataTemplate

DataTrigger дуже часто застосовується у двох контекстах:

  • Style.Triggers на Border, TextBlock тощо — стилізація елементів у шаблоні.
  • DataTemplate.Triggers — на весь DataTemplate. Рідше, але корисно для зміни вигляду всього рядка.

MultiTrigger: кілька умов одночасно

MultiTrigger — це AND-комбінація кількох Trigger.Conditions. Setter-и застосовуються лише тоді, коли всі умови виконуються одночасно.

Приклад: кнопка стає gold лише якщо вона Selected і IsMouseOver

<Style TargetType="ListBoxItem">
    <Style.Triggers>
        <MultiTrigger>
            <MultiTrigger.Conditions>
                <Condition Property="IsSelected"  Value="True"/>
                <Condition Property="IsMouseOver" Value="True"/>
            </MultiTrigger.Conditions>
            <Setter Property="Background" Value="#FEF3C7"/>    <!-- Жовтий -->
            <Setter Property="Foreground" Value="#92400E"/>
        </MultiTrigger>

        <!-- Звичайне виділення (тільки IsSelected) -->
        <Trigger Property="IsSelected" Value="True">
            <Setter Property="Background" Value="#EEF2FF"/>
            <Setter Property="Foreground" Value="#3730A3"/>
        </Trigger>
    </Style.Triggers>
</Style>
Порядок у цьому прикладі знову важливий: MultiTrigger (AND-умова — більш специфічна) стоїть перед звичайним Trigger. Але оскільки умови не перетинаються (MultiTrigger = Is_Selected AND IsMouseOver, а Trigger = лише IsSelected) — порядок тут менш критичний. Однак для надійності — завжди ставте складніші умови перед простішими.

MultiDataTrigger: кілька Binding-умов

MultiDataTrigger аналогічна MultiTrigger, але умови задаються через DataTrigger.Binding:

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...


EventTrigger: реакція на подію

EventTrigger — особливий вид тригера. Він не задає умову (значення = X), а реагує на RoutedEvent. Єдина дія, яку він може виконати — запустити Storyboard (анімацію). Setter-ів у EventTrigger немає.

У цій статті ми лише познайомимося з синтаксисом — детально анімації розглядаються у Блоці 11.

<Style TargetType="Button">
    <Style.Triggers>
        <!-- Коли відбувається подія MouseEnter: запустити анімацію -->
        <EventTrigger RoutedEvent="MouseEnter">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="Opacity"
                                     To="0.7" Duration="0:0:0.15"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>

        <EventTrigger RoutedEvent="MouseLeave">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="Opacity"
                                     To="1.0" Duration="0:0:0.15"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Style.Triggers>
</Style>
EventTrigger не має автоматичного відкочування, на відміну від Trigger. Він просто запускає анімацію при події. Щоб повернути стан — потрібен окремий EventTrigger на зворотну подію (наприклад, MouseLeave). Це потребує парної обробки кожного переходу.

Visual State Manager: сучасна альтернатива

Visual State Manager (VSM) — це більш структурований і масштабований підхід до управління станами контролу, запозичений з Silverlight і прийнятий у WPF. Замість набору розрізнених Trigger-ів VSM описує явні поіменовані стани контролу та переходи між ними.

Концепція VSM

У VSM контрол має групи станів (VisualStateGroup), де кожна група містить взаємовиключні стани (VisualState). Наприклад, група CommonStates може містити стани Normal, MouseOver, Pressed, Disabled. Контрол може перебувати одночасно в одному стані з кожної групи.

Кожен VisualState містить Storyboard, що опискує як виглядає контрол у цьому стані. Коли клас контролу викликає VisualStateManager.GoToState(this, "MouseOver", useTransitions: true) — VSM плавно переводить контрол із поточного стану у новий.

Структура VSM у ControlTemplate

<ControlTemplate TargetType="Button">
    <Border x:Name="RootBorder"
            Background="{TemplateBinding Background}"
            CornerRadius="8"
            Padding="{TemplateBinding Padding}">

        <VisualStateManager.VisualStateGroups>

            <!-- Група CommonStates: взаємовиключні стани Normal/MouseOver/Pressed -->
            <VisualStateGroup x:Name="CommonStates">

                <VisualState x:Name="Normal"/>  <!-- Стан за замовчуванням: нічого не анімується -->

                <VisualState x:Name="MouseOver">
                    <Storyboard>
                        <ColorAnimation
                            Storyboard.TargetName="RootBorder"
                            Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                            To="#3730A3" Duration="0:0:0.15"/>
                    </Storyboard>
                </VisualState>

                <VisualState x:Name="Pressed">
                    <Storyboard>
                        <ColorAnimation
                            Storyboard.TargetName="RootBorder"
                            Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                            To="#312E81" Duration="0:0:0.1"/>
                    </Storyboard>
                </VisualState>

                <VisualState x:Name="Disabled">
                    <Storyboard>
                        <DoubleAnimation
                            Storyboard.TargetName="RootBorder"
                            Storyboard.TargetProperty="Opacity"
                            To="0.5" Duration="0:0:0.1"/>
                    </Storyboard>
                </VisualState>

                <!-- Переходи між станами: плавний вхід до MouseOver -->
                <VisualStateGroup.Transitions>
                    <VisualTransition To="MouseOver"  GeneratedDuration="0:0:0.15"/>
                    <VisualTransition To="Normal"     GeneratedDuration="0:0:0.2"/>
                    <VisualTransition To="Pressed"    GeneratedDuration="0:0:0.05"/>
                </VisualStateGroup.Transitions>

            </VisualStateGroup>

        </VisualStateManager.VisualStateGroups>

        <ContentPresenter HorizontalAlignment="Center"
                          VerticalAlignment="Center"/>
    </Border>
</ControlTemplate>

GoToState з C# (для кастомних контролів)

У стандартних контролах WPF VSM викликається внутрішньо (клас Button сам викликає GoToState). Для кастомних контролів потрібно викликати вручну:

public class MyCustomButton : Button
{
    protected override void OnMouseEnter(MouseEventArgs e)
    {
        base.OnMouseEnter(e);
        VisualStateManager.GoToState(this, "MouseOver", useTransitions: true);
    }

    protected override void OnMouseLeave(MouseEventArgs e)
    {
        base.OnMouseLeave(e);
        VisualStateManager.GoToState(this, "Normal", useTransitions: true);
    }
}

Triggers vs VSM: порівняльна таблиця

АспектTriggersVisual State Manager
ПростотаВищий поріг входу (менше коду)Більше XAML, але більш явна структура
АнімаціяEventTrigger + Storyboard (складніше)VisualState.Storyboard + VisualTransition (простіше)
ВзаємовиключністьРучне управління пріоритетомГарантована: лише один стан в групі
МасштабованістьСкладно для багатьох станівЛегко — кожен стан незалежний
Де застосовуєтьсяStyle, DataTemplate, ControlTemplateТільки ControlTemplate
Аналог у AvaloniaНемає прямого аналогуПсевдокласи у ControlTheme (^:pointerover)
РекомендаціяДля простих умов у Style/DataTemplateДля ControlTemplate із анімаціями
Для більшості простих сценаріїв (hover-ефект, видимість, зміна кольору за даними) — DataTrigger та Trigger у Style.Triggers є достатніми і значно лаконічнішими. VSM варто використовувати коли: (1) потрібні плавні анімації між станами, (2) стан контролу змінюється з C#, (3) ви пишете повноцінний кастомний контрол, сумісний із темами.

Практика: Toggle-кнопка зі зміною тексту та кольору

Реалізуємо Toggle-кнопку — ToggleButton, що при натисканні змінює свій стан IsChecked між True і False. Через Trigger ми змінимо і текст, і колір залежно від стану. Це класичний патерн для UI типу «Підписатись / Відписатись», «Увімкнути / Вимкнути».

Loading Avalonia WebAssembly...

Downloading .NET runtime (10MB)...

Зверніть на Content у Setter всередині Trigger: ця можливість є важливою і часто недооціненою. Trigger може змінювати не лише кольори та стани — він може повністю замінити вміст (Content) контролу. Для ToggleButton з двома станами це надзвичайно зручно: один контрол, один стиль, два потоки відображення — без жодного C#.


Практичні завдання


Підсумок

Що ми вивчили у цій статті

Triggers завершують тріаду механізмів стилізації WPF: Style (статичні значення) → Trigger (умовні значення) → ControlTemplate (повна заміна вигляду).

Property Trigger (<Trigger Property="..." Value="...">) — слідкує за DependencyProperty. При збігу — застосовує Setter-и. При розбіжності — автоматично відкочує. Порядок важливий: специфічніші умови після загальніших.

DataTrigger (<DataTrigger Binding="{Binding ...}" Value="...">) — умова на будь-яке Binding-значення. Ключовий інструмент MVVM-стилізації. Без жодного C# — UI реагує на властивості ViewModel.

MultiTrigger та MultiDataTrigger — AND-комбінація умов. Setter-и спрацьовують лише при виконанні всіх умов одночасно.

EventTrigger — реагує на RoutedEvent, запускає Storyboard. Немає автоматичного відкочення. Повний розгляд — у Блоці 11 (Анімації).

Visual State Manager (VSM) — структурована система явних іменованих станів із Storyboard-анімаціями та VisualTransition. Гарантує взаємовиключність станів у групі. Кращий вибір для анімованих кастомних контролів. Реалізується через VisualStateGroups у ControlTemplate.

Triggers vs VSM: Triggers — простіші, менше коду для базових сценаріїв. VSM — масштабованіший, із вбудованою підтримкою анімацій між станами.

Avalonia-аналог: ControlTheme із CSS Pseudo-class Selectors (^:pointerover, ^:pressed) — замінює і Trigger, і базовий VSM одночасно, у значно лаконічнішому синтаксисі.

Що далі

Наступна стаття — Data Binding у деталях: OneWay, TwoWay, OneWayToSource, OneTime, Converter, StringFormat, UpdateSourceTrigger, FallbackValue. Data Binding — серцевина MVVM, і без глибокого розуміння цих механізмів неможливо будувати складні реактивні інтерфейси.