DEV Community

Cover image for Why WPF Style Triggers and DataTriggers Do Not Apply — Dependency Property Value Precedence
s-iguchi09
s-iguchi09

Posted on Edited on Originally published at s-iguchi09.github.io

Why WPF Style Triggers and DataTriggers Do Not Apply — Dependency Property Value Precedence

Originally published at s-iguchi09.github.io.
A Japanese version is also available.

Overview

A Trigger or DataTrigger declared in Style.Triggers sometimes has no visible effect even though its condition is met.
The common assumption is a broken binding or a type mismatch in the trigger condition, but a frequent cause is that the trigger fires correctly and its value is simply outranked by a higher-precedence input.
This article explains the cause in terms of dependency property value precedence, shows how to repair markup that carries a local value, and gives criteria for choosing among the available fixes.


Prerequisites / Environment

  • Framework: .NET 6 or later / WPF (the precedence rules are identical on WPF for .NET Framework 4.x)
  • Language: C#
  • Target feature: Trigger / DataTrigger / MultiTrigger declared in Style.Triggers
  • Default theme: Aero2 (the Fluent theme available from .NET 9 differs from what is described below in both the standard control colors and the structure of the default templates)
  • Architecture: applicable to both MVVM and code-behind

Problem

Consider a Style with a DataTrigger that changes the background of a frame according to a validation state.

<Window.Resources>
    <Style x:Key="StatusBox" TargetType="Border">
        <Style.Triggers>
            <DataTrigger Binding="{Binding HasError}" Value="True">
                <Setter Property="Background" Value="#FFD4D4" />
            </DataTrigger>
        </Style.Triggers>
    </Style>
</Window.Resources>

<Border Style="{StaticResource StatusBox}" Background="White">
    <TextBlock Text="HasError = True" />
</Border>
Enter fullscreen mode Exit fullscreen mode

When HasError becomes true, the background of this Border stays White.
The binding resolves correctly and no binding error appears in the Output window (for reading those messages, see Reading WPF Binding Errors and Diagnosing Them with the Output Window).
The same result occurs with a property trigger such as Trigger Property="IsMouseOver", so the trigger type is not the cause.


Cause / Background

A WPF dependency property can receive values from several inputs: local values, styles, templates, and inheritance.
Which one becomes the effective value is decided by dependency property value precedence, and a higher-precedence input silences every lower one.

The order is as follows, highest precedence first.

Rank Source of the value Example
1 Property system coercion CoerceValueCallback
2 Active animations, or animations with a Hold behavior Storyboard
3 Local value A XAML attribute or property element, SetValue, or a Binding / StaticResource / DynamicResource written on the element
4 TemplatedParent template property values Elements created by a ControlTemplate or DataTemplate
5 Implicit styles Applies to the Style property only
6 Style triggers Style.Triggers
7 Template triggers ControlTemplate.Triggers / DataTemplate.Triggers
8 Style setter values A Setter directly under Style
9 Default (theme) styles Theme style triggers, then theme style setters
10 Inheritance Inheritable properties such as FontSize
11 Default value from dependency property metadata The default value in PropertyMetadata

The problem lies entirely in the gap between rank 3 and rank 6.
A value written as a XAML attribute, such as Background="White", is a local value at rank 3 and therefore outranks a style trigger at rank 6.
The trigger condition is evaluated and its Setter is applied to that lower rank, but the effective value remains the local value, so nothing changes on screen.

The part that is easy to miss is that a Binding or a DynamicResource written directly on the element also counts as a local value.
Writing Background="{Binding NormalBrush}" only defers evaluation of the value; its precedence is still rank 3, and a style trigger cannot win against it.

A Setter directly under Style, on the other hand, sits at rank 8, below the trigger at rank 6.
Supplying the default through a setter rather than a local value therefore restores the intended relationship.


This precedence can be confirmed by displaying the elements and reading DependencyPropertyHelper.GetValueSource.
The figure below records the result under varying conditions.

Figure: A table of the effective Border.Background and where the value came from. With a local value it stays white at Local; once the default moves to a Setter the trigger color applies at StyleTrigger; with the trigger unmet it is white at Style; and after ClearValue removes the local value the trigger color applies at StyleTrigger.

Measured on .NET 10 / Windows 11. HasError varies per row: it is False on the trigger not met row and True on the others. The value in parentheses is the BaseValueSource returned by DependencyPropertyHelper.GetValueSource.

The diagram is rendered in the original article.

On the row where the value does not change, BaseValueSource is Local. That is why the trigger value never replaces it.
Moving the default into the Setter changes the source to StyleTrigger, and the trigger color becomes the effective value.
Clearing the local value with ClearValue produces the same result, which confirms the local value is the cause.


Solution

Remove the local value from the target element and move the default into a Setter inside the Style.
The default then comes from rank 8 and the conditional value from rank 6, so the trigger wins whenever its condition holds.

Only the property that the trigger writes to is affected.
Setting unrelated properties such as Margin or Width as local values on the element causes no interference.


Implementation

The following markup places two Border elements one above the other under the same style: one keeps its local value, the other takes its default from the setter.
Both reference the same StatusBox style, and the relevant difference is whether Background is present as a local value (the Margin on the lower one only separates the two vertically and has no bearing on the trigger).

<Window.Resources>
    <Style x:Key="StatusBox" TargetType="Border">
        <Setter Property="Background" Value="White" />
        <Setter Property="BorderBrush" Value="#9AA4B2" />
        <Setter Property="BorderThickness" Value="1" />
        <Setter Property="Padding" Value="18,6" />
        <Style.Triggers>
            <DataTrigger Binding="{Binding HasError}" Value="True">
                <Setter Property="Background" Value="#FFD4D4" />
            </DataTrigger>
        </Style.Triggers>
    </Style>
</Window.Resources>

<StackPanel>
    <!-- The local value remains, so the trigger background is not applied -->
    <Border Style="{StaticResource StatusBox}" Background="White">
        <TextBlock Text="HasError = True" />
    </Border>

    <!-- The default moved into the setter, so the trigger background is applied -->
    <Border Style="{StaticResource StatusBox}" Margin="0,12,0,0">
        <TextBlock Text="HasError = True" />
    </Border>
</StackPanel>
Enter fullscreen mode Exit fullscreen mode

The HasError used in the trigger condition is a property on the view model assigned to DataContext.
It implements INotifyPropertyChanged so that runtime changes reach the trigger.

public sealed class ValidationViewModel : INotifyPropertyChanged
{
    private bool _hasError;

    public bool HasError
    {
        get => _hasError;
        set
        {
            if (_hasError == value)
            {
                return;
            }

            _hasError = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasError)));
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
}
Enter fullscreen mode Exit fullscreen mode

Assigning this view model to the DataContext of the Window propagates changes of HasError to the DataTrigger.
With a plain property that raises no change notification, the trigger is never re-evaluated when the value changes.

Rendering this markup with HasError set to true shows the difference directly.

Two Border elements sharing one style. The upper one, whose Background is set as a local value, stays white, while the lower one takes the pale red from the DataTrigger.
The result with HasError set to True. The upper border keeps Background as a local value and does not pick up the trigger color; the lower one takes its default from the Setter, so the trigger color is applied. The labels on the left were added to the figure to show how the two Border declarations differ (captured on .NET 10 / Windows 11).

Precedence also depends on how a value is assigned from code-behind.
The three statements below all target Background, but each stores the value at a different rank.

// Becomes a local value, so style triggers no longer affect Background on this element
border.Background = Brushes.White;

// Changes the effective value without writing a local value (a trigger can still take over)
border.SetCurrentValue(Border.BackgroundProperty, Brushes.White);

// Removes an existing local value, restoring the setter or trigger value
border.ClearValue(Border.BackgroundProperty);
Enter fullscreen mode Exit fullscreen mode

SetCurrentValue is a special assignment that does not appear in the precedence list: it changes the current value without overwriting the source of the value.
It suits cases where a temporary value is needed without discarding an existing binding or trigger.
It only avoids creating a local value, however, and does not remove one that is already set.
While a local value remains on the target property the effective value does not change, so it has to be removed with ClearValue first.
ClearValue removes only the local value, so whichever remaining input ranks highest — a theme style, for instance — becomes the effective value.


Notes