WPF で Style の Trigger・DataTrigger が効かない原因と依存関係プロパティの値優先順位

Style のトリガーを書いても外観が変わらない原因の多くは、XAML の属性で直接指定したローカル値がスタイルトリガーより優先されることにある。値優先順位の仕組みと、Setter への移行・SetCurrentValue・ClearValue による解決方法を整理する。

概要

WPF で Style.Triggers に定義した Trigger や DataTrigger が、条件を満たしているにもかかわらず外観へ反映されないことがある。
バインディングの誤りやトリガー条件の型不一致を疑いがちだが、トリガーは正しく作動しており、より優先順位の高い値に上書きされているだけ、というのがよくある原因である。
本記事では、依存関係プロパティの値優先順位を軸にこの現象の原因を説明し、ローカル値を持つコードの直し方と、状況別の選択基準を整理する。


前提・対象環境

本記事の表は、上記の環境で実効値と、その BaseValueSource を読み出して得たものである。
この環境で確認しているのは次の点である。


問題

入力の検証状態に応じて枠の背景色を変えるため、Style に DataTrigger を定義したとする。

<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <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>
</Window>

HasError が true になっても、この Border の背景は White のまま変わらない。
バインディングは正しく解決されており、出力ウィンドウにバインディングエラーも現れない(バインディングエラーの読み方は WPF バインディングエラーの読み方と出力ウィンドウを使った原因特定 を参照)。
同じことは Trigger Property="IsMouseOver" のようなプロパティトリガーでも起きるため、原因はトリガーの種類ではない。


原因・背景

WPF の依存関係プロパティは、ローカル値・スタイル・テンプレート・継承など複数の入力元から値を与えられる。
どれを実効値とするかは依存関係プロパティの値優先順位で決まり、上位の入力元があれば下位の値は無視される。

優先順位は次のとおりである(上ほど優先度が高い)。

順位 値の出どころ 具体例
1 プロパティシステムの強制(coercion) CoerceValueCallback
2 実行中のアニメーション、Hold 動作のアニメーション Storyboard
3 ローカル値 XAML の属性・プロパティ要素、SetValue、要素に直接書いた Binding / StaticResource / DynamicResource
4 TemplatedParent のテンプレート由来の値 ControlTemplate / DataTemplate が生成した要素
5 暗黙スタイル Style プロパティにのみ適用される
6 スタイルのトリガー Style.Triggers
7 テンプレートのトリガー ControlTemplate.Triggers / DataTemplate.Triggers
8 スタイルの Setter Style 直下の Setter
9 既定(テーマ)スタイル テーマスタイルのトリガー、次いで Setter
10 継承 FontSize などの継承プロパティ
11 依存関係プロパティのメタデータ既定値 PropertyMetadata の既定値

問題の本質はこの表の 3 と 6 の位置関係にある。
Background="White" のように XAML の属性で直接書いた値はローカル値(3)となり、スタイルのトリガー(6)より上位に位置する。
そのためトリガーの条件が成立して Setter が評価されても、実効値はローカル値のままとなり、画面上は何も変化しない。

見落としやすいのは、要素に直接書いた Binding や DynamicResource もローカル値と同じ優先順位で扱われる点である。
Background="{Binding NormalBrush}" と書いた場合も、値が遅延評価されるだけで優先順位はローカル値であり、スタイルのトリガーは勝てない。

一方、Style 直下の Setter(8)はトリガー(6)より下位である。
したがって既定値をローカル値ではなく Setter として与えれば、条件成立時にトリガーが優先されるようになる。


この優先順位は、実際に表示して DependencyPropertyHelper.GetValueSource を読めば確かめられる。
条件を変えて測った結果が次の表である。

構成 実効値(BaseValueSource)
ローカルの Background + トリガー #FFFFFFFF (Local)
Setter の既定値 + トリガー #FFFFD4D4 (StyleTrigger)
Setter の既定値、トリガーの条件を満たさない #FFFFFFFF (Style)
ローカルの Background、続けて ClearValue #FFFFD4D4 (StyleTrigger)

.NET 10 / Windows 11 で Border.Background を測った結果。HasError は行ごとに変えており、「トリガーの条件を満たさない」の行だけ False、他の行は True である。括弧内は DependencyPropertyHelper.GetValueSource が返す BaseValueSource である。

値が変わらない行では BaseValueSource が Local になっている。 これがトリガーの値に置き換わらない理由である。
既定値を Setter へ移すと StyleTrigger に変わり、トリガーの色が実効値になる。
ClearValue でローカル値を取り除いた場合も同じ結果になるため、原因がローカル値であることが確かめられる。


解決方法

対象要素からローカル値を取り除き、既定値を Style の Setter へ移す。
これで「既定値は Setter(8)、条件成立時はトリガー(6)」という上下関係が成立し、トリガーが期待どおり反映される。

トリガーが設定するプロパティと同じプロパティだけが対象である。
Margin や Width など、トリガーが触らないプロパティを要素側にローカル値として書くことは問題にならない。


実装例

次の XAML は、ローカル値を残した Border と、既定値を Setter へ移した Border を同じスタイルで並べたものである。
どちらも同一の StatusBox スタイルを参照しており、動作に関係する違いは Background をローカル値として持つかどうかだけである(下段の Margin は 2 つを縦に離して配置するためのもので、トリガーの挙動には関係しない)。

<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <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>
        <!-- ローカル値が残っているため、トリガーの背景色が反映されない -->
        <Border Style="{StaticResource StatusBox}" Background="White">
            <TextBlock Text="HasError = True" />
        </Border>

        <!-- 既定値を Setter に移したため、トリガーの背景色が反映される -->
        <Border Style="{StaticResource StatusBox}" Margin="0,12,0,0">
            <TextBlock Text="HasError = True" />
        </Border>
    </StackPanel>
</Window>

トリガーの条件に使う HasError は、DataContext に設定した ViewModel のプロパティである。
実行中の変更をトリガーへ伝えるため、INotifyPropertyChanged を実装する。

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;
}

この ViewModel を Window の DataContext に設定すると、HasError の変更が DataTrigger へ伝わる。
変更通知を実装しない単純なプロパティにすると、値を変えてもトリガーは再評価されない。

HasError を true にした状態で表示すると、両者の差がそのまま描画に現れる。

同じスタイルを適用した 2 つの Border。Background をローカル値として指定した上段は白のまま、Setter に任せた下段は DataTrigger の淡い赤に変わっている。
HasError が True の状態で表示した結果。上段は Background をローカル値として持つためトリガーの色に変わらず、下段は既定値を Setter に移したためトリガーの色が反映されている。左側のラベルは 2 つの Border の記述の違いを示すために図へ付加したものである(.NET 10 / Windows 11 で生成)。

コードビハインドから値を操作する場合も、代入方法によって優先順位が変わる。
次の 3 行は、いずれも Background を扱うが、値の格納先が異なる。

// ローカル値になるため、以後この要素ではスタイルのトリガーが Background に効かなくなる
border.Background = Brushes.White;

// ローカル値を書き込まずに実効値だけを変える(トリガーが作動すればトリガーの値になる)
border.SetCurrentValue(Border.BackgroundProperty, Brushes.White);

// 既に設定されているローカル値を取り除き、Setter やトリガーの値へ戻す
border.ClearValue(Border.BackgroundProperty);

SetCurrentValue は優先順位の一覧に現れない特別な代入で、値の出どころを上書きせずに現在の値だけを変更する。
既存のバインディングやトリガーを壊さずに一時的な値を入れたい場合に適する。
ただしローカル値を作らないだけであり、既に設定されているローカル値を取り除く効果は無い。
ローカル値があるプロパティに SetCurrentValue を呼ぶと、実効値そのものは変わるが、値の出どころはローカル値のままである。そのため、後からトリガーが作動してもトリガーの値にはならない(下の表)。トリガーを効かせたいなら、先に ClearValue でローカル値を取り除く必要がある。

条件 計測値
ローカル値 Red: SetCurrentValue(White) の直後 #FFFFFFFF (Local)
ローカル値 Red: SetCurrentValue(White)、続けて Tag = on #FFFFFFFF (Local)
ローカル値なし: SetCurrentValue(White) の直後 #FFFFFFFF (Default)
ローカル値なし: SetCurrentValue(White)、続けて Tag = on #FF008000 (StyleTrigger)
TextBox.Text を OneWay でバインド、続けてコードで Text = "typed" バインドは外れる、source.Value = from source
TextBox.Text を TwoWay でバインド、続けてコードで Text = "typed" バインドは残る、source.Value = from source
Style を明示した Button(Foreground だけ): Template 設定あり(DefaultStyle)

.NET 10 / Windows 11 で実測。トリガーは Tag が on のとき Background を緑にするスタイルのトリガーである。TextBox.Text の既定の UpdateSourceTrigger は LostFocus のため、TwoWay でも代入した時点ではソースは変わっていない。

ClearValue はローカル値のみを取り除くため、テーマスタイルなど他の入力元が残っていればその値が実効値となる。


注意点


代替案・比較

方法 メリット デメリット 適するケース
既定値を Style の Setter へ移す XAML だけで完結し、優先順位の逆転が起きない 要素ごとに異なる既定値を持たせにくい 通常はこれを第一候補とする
SetCurrentValue で値を変更する ローカル値を作らないためトリガーが生き続ける コードビハインドが必要 実行時に一時的な値を入れる場合
ClearValue でローカル値を除去する 既存の XAML を書き換えずに済む バインディングも解除される。呼び出し時機の管理が要る 実行時に付いたローカル値を取り除く場合
ControlTemplate を差し替える テンプレートが固定している外観まで制御できる 記述量が多く、テーマの更新に追随しない 既定テンプレートが外観を固定している場合
トリガーの EnterActions でアニメーションを開始する 優先順位 2 のためローカル値があっても上書きできる 停止・巻き戻しの管理が必要になる。{StaticResource} などで共有した Freeze されていないブラシの Color をアニメーションすると、同じブラシを参照する他の要素にも影響する 状態変化に遷移演出を伴わせる場合

まとめ

Style のトリガーが効かない原因としてよくあるのは、トリガーの記述ミスではなく、対象プロパティにローカル値が設定されていることである。
ローカル値は優先順位 3、スタイルのトリガーは 6、スタイルの Setter は 8 であり、この順序を把握していれば現象は説明できる。

選択の基準は次のとおりである。

まず対象プロパティにローカル値が無いことを確認し、既定値はすべて Setter に置く設計にしておくことで、トリガーが効かない問題は設計段階で回避できる。