WinUI 3(Windows App SDK)でVisualStateManagerが既定状態に戻らない原因と対処法:VisualStateGroupの落とし穴

WinUI 3(Windows App SDK)でカスタムコントロールを作り、ItemsRepeater/ItemsControl/ItemsPresenter で並べて状態に応じて色を切り替えると、「VisualState が一度しか反映されず、既定に戻らない」現象に遭遇することがあります。原因はテンプレート内の VisualStateGroup 設計にあるケースが多く、直し方もシンプルです。

目次

起きている症状を整理する

例として StepBarItem のようなステップ表示アイテムを想定します。各アイテムは状態によって BorderBrush や Foreground を切り替え、選択中だけ青、未選択は「枠=灰・文字=赤」のようにしたい、という要件です。

操作期待する見た目実際に起きる見た目開発者が確認した事実
起動直後先頭が青、他は灰/赤期待通り初期状態の適用は成功
Next次アイテムが青に切り替わる期待通りVisualStateManager.GoToState(...) が true
Prev前アイテムは灰/赤に戻る前アイテムが青のまま残るそれでも GoToState は true
別の色の状態へ別の色に変わる何度でも変わる「既定(空の VisualState)へ戻す」だけが失敗

このタイプの不具合は、コード側(C#)よりもテンプレート側(XAML)での状態定義の置き方が原因であることが多いです。特に「空の VisualState で既定に戻す」設計のときに起こりやすいです。

VisualStateManager の基本:状態切替は VisualStateGroup 内で排他的

VisualStateManager は「状態(VisualState)」を「グループ(VisualStateGroup)」で束ねます。重要なのは、状態切替が同一グループ内で排他的に動くことです。逆に言うと、別グループの状態は同時に成立します。

概念説明設計の意図
VisualStateGroup互いに排他的な状態の集合「選択/未選択」「PointerOver/Pressed」など、同時に成立しない軸をまとめる
別グループ同時に成立できる(重ね掛けされる)「選択状態」と「無効状態」など、独立な軸で分離する

ここを誤ると、「状態は切り替わっているのに、見た目の上書きだけ残る」という現象が起きます。今回の症状はまさにこれです。

原因:既定に戻す“空の VisualState”が、色変更の VisualState と同じグループにない

結論から言うと、既定に戻したいのに戻らない原因は次です。

  • 青にする状態(例:UnderWayInfo)が、ある VisualStateGroup(例:StepStateGroup)に入っている
  • 既定へ戻すための空の状態(例:WaitingInfo)が、別の VisualStateGroup に入っている(または別階層に置かれている)

このとき GoToState が true を返すのは不思議ではありません。「指定した名前の状態に遷移できた」という意味では成功しているからです。しかし見た目は、青にする側の Storyboard/Setter が“別グループで生きたまま”残り、結果として青が解除されません。

なぜ別グループだと解除されないのか(状態の残り方を可視化)

「WaitingInfo に遷移したのに、UnderWayInfo の青が残る」のは、実は VisualStateManager の仕様に忠実な動きです。別グループは同時に成立できるため、WaitingInfo を適用しても UnderWayInfo が終わったことにはなりません。

タイミングStepStateGroup の状態DefaultGroup の状態BorderBrush/Foreground に影響するのは?
起動直後UnderWayInfoWaitingInfoStepStateGroup 側(青)が勝つ
Prev(WaitingInfoへ)UnderWayInfo のままWaitingInfoStepStateGroup 側(青)が残り続ける

つまり、戻すには次のどちらかが必要です。

  • 同じ VisualStateGroup 内で UnderWayInfo → WaitingInfo に遷移させる
  • または、別グループ設計を維持するなら、各グループの「戻し先状態」へも明示的に遷移させる(呼び出しが二重になりがち)

誤った構造のイメージ

以下は典型的な「やってしまいがち」な配置です(説明用に簡略化しています)。

<VisualStateManager.VisualStateGroups>




...青にする...








 

これだと「WaitingInfo へ遷移」はできても、青の上書きは StepStateGroup 側で残り続けます。結果として、Prev で戻したつもりでも前アイテムが青のまま見えます。

解決策:空の VisualState と色を変える VisualState を同じ VisualStateGroup にまとめる

直し方はシンプルです。既定に戻す“空の VisualState”と、色を変える VisualState を同一グループへ入れます。

例えば、WaitingInfo(空)と UnderWayInfo(青)を同じグループにまとめます。

&lt;VisualStateManager.VisualStateGroups&gt;
  &lt;VisualStateGroup x:Name="StepStateGroup"&gt;

    &lt;!-- 既定に戻す状態(空でOK) --&gt;
    &lt;VisualState x:Name="WaitingInfo" /&gt;

    &lt;!-- 進行中(青に上書き) --&gt;
    &lt;VisualState x:Name="UnderWayInfo"&gt;
      &lt;Storyboard&gt;
        &lt;ObjectAnimationUsingKeyFrames
            Storyboard.TargetName="RootBorder"
            Storyboard.TargetProperty="BorderBrush"&gt;
          &lt;DiscreteObjectKeyFrame KeyTime="0"
              Value="{ThemeResource SystemControlHighlightAccentBrush}" /&gt;
        &lt;/ObjectAnimationUsingKeyFrames&gt;

        &lt;ObjectAnimationUsingKeyFrames
            Storyboard.TargetName="TitleText"
            Storyboard.TargetProperty="Foreground"&gt;
          &lt;DiscreteObjectKeyFrame KeyTime="0"
              Value="{ThemeResource SystemControlHighlightAccentBrush}" /&gt;
        &lt;/ObjectAnimationUsingKeyFrames&gt;
      &lt;/Storyboard&gt;
    &lt;/VisualState&gt;

  &lt;/VisualStateGroup&gt;
&lt;/VisualStateManager.VisualStateGroups&gt;

同一グループ内で UnderWayInfo → WaitingInfo へ遷移すれば、VisualStateManager は前の状態の上書きを解除(停止/置き換え)し、テンプレートの既定値へ戻せます。これで Prev での見た目の復帰が正しく反映されます。

「既定色」に確実に戻す:テンプレート初期値と Default 状態の置き場所

空の VisualState に戻したとき、最終的に表示されるのは「テンプレート側の初期値」です。つまり、戻したい“既定色”は次のどちらかに置く必要があります。

方法やることメリット注意点
テンプレート初期値で持つBorder/TextBlock の BorderBrush/Foreground を最初から灰/赤にする。既定状態は空。状態が少なくて済む。戻しが自然。初期値が「本当の既定」になる。後から仕様変更があると追跡しづらい。
既定状態で明示するWaitingInfo 側に Setters/Storyboard を置き、灰/赤を明示する。「この状態はこの見た目」という対応が明確。テンプレート初期値に依存しない。状態定義が少し増えるが、保守性は上がる。

サンプルが初期化直後に UnderWayInfo に入るような場合、空の状態へ戻すと“本当の初期値”(例:黒)に戻ってしまうことがあります。戻したい色が灰/赤なら、テンプレート初期値を灰/赤にするか、WaitingInfo で明示するのが安全です。

おすすめ:VisualState.Setters で「戻り先」も明示する

アニメーションが不要で「色を切り替えるだけ」なら、Storyboard より VisualState.Setters を使うと読みやすく、戻しも意図が伝わりやすいです。

&lt;VisualStateGroup x:Name="StepStateGroup"&gt;

  &lt;VisualState x:Name="WaitingInfo"&gt;
    &lt;VisualState.Setters&gt;
      &lt;Setter Target="RootBorder.BorderBrush" Value="DarkGray" /&gt;
      &lt;Setter Target="TitleText.Foreground" Value="Red" /&gt;
    &lt;/VisualState.Setters&gt;
  &lt;/VisualState&gt;

  &lt;VisualState x:Name="UnderWayInfo"&gt;
    &lt;VisualState.Setters&gt;
      &lt;Setter Target="RootBorder.BorderBrush"
              Value="{ThemeResource SystemControlHighlightAccentBrush}" /&gt;
      &lt;Setter Target="TitleText.Foreground"
              Value="{ThemeResource SystemControlHighlightAccentBrush}" /&gt;
    &lt;/VisualState.Setters&gt;
  &lt;/VisualState&gt;

&lt;/VisualStateGroup&gt;

「空の状態で戻す」設計でも動きますが、仕様が増えると既定色が分散しやすいので、運用では Setters で明示してしまうほうが事故が減ります。

補足:色を滑らかに変えたい場合

BorderBrush/Foreground をそのままアニメーションさせたい場合、Brush 自体ではなく SolidColorBrush の Color をアニメーションさせる構成にすると自然です。例えばテンプレート側にブラシを用意し、そのブラシの Color をアニメーションします(状態の競合を避けるためにも、やはり同一 VisualStateGroup にまとめるのが前提です)。

C# 側:GoToState の呼び出しタイミングと ItemsRepeater の再利用を意識する

テンプレートが適用される前に状態を変えようとすると、ターゲット要素がまだ存在せず反映されないことがあります。カスタムコントロールでは次の基本形にしておくと、ItemsRepeater/ItemsControl/ItemsPresenter 配下でも安定します。

public sealed class StepBarItem : Control
{
    public StepBarItem()
    {
        this.DefaultStyleKey = typeof(StepBarItem);
    }

    public StepState State
    {
        get => (StepState)GetValue(StateProperty);
        set => SetValue(StateProperty, value);
    }

    public static readonly DependencyProperty StateProperty =
        DependencyProperty.Register(
            nameof(State),
            typeof(StepState),
            typeof(StepBarItem),
            new PropertyMetadata(StepState.Waiting, OnStateChanged));

    protected override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        ApplyVisualState(useTransitions: false);
    }

    private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var self = (StepBarItem)d;
        self.ApplyVisualState(useTransitions: true);
    }

    private void ApplyVisualState(bool useTransitions)
    {
        // テンプレート未適用のタイミングでも呼ばれるため、GoToState は常に試みる
        var stateName = State switch
        {
            StepState.UnderWay => "UnderWayInfo",
            _ => "WaitingInfo",
        };

        VisualStateManager.GoToState(this, stateName, useTransitions);
    }
}

public enum StepState
{
    Waiting,
    UnderWay,
}

ポイントは次の通りです。

  • OnApplyTemplate で必ず現在の状態を適用する(初回表示・再利用で崩れにくい)
  • DependencyProperty の変更時にも適用する(データ更新に追従)
  • ItemsRepeater の要素再利用がある場合でも、表示タイミングで必ず正しい状態が再適用される

「GoToState は true なのに見た目が変わらない」系の不具合は、テンプレート未適用のタイミングや、別グループの上書きが残っているタイミングで起こりやすいので、まずはテンプレート適用後に必ず適用する形に寄せるのが定石です。

ItemsRepeater / ItemsControl での実装例:状態をデータに寄せる

StepBar のような UI は「今どのステップか」というデータが必ずあります。UI から状態を作るより、データ(ViewModel)から状態を流して UI はそれを表示するほうが、Prev/Next の齟齬が起きにくくなります。

&lt;ItemsRepeater ItemsSource="{x:Bind ViewModel.Steps}"&gt;
  &lt;ItemsRepeater.ItemTemplate&gt;
    &lt;DataTemplate x:DataType="local:StepVm"&gt;
      &lt;local:StepBarItem State="{x:Bind State}" /&gt;
    &lt;/DataTemplate&gt;
  &lt;/ItemsRepeater.ItemTemplate&gt;
&lt;/ItemsRepeater&gt;

UI 側で「Prev したからこの要素を WaitingInfo に…」と個別に命令するよりも、ViewModel で State を更新し、バインディングで反映する形にすると、要素の再利用があっても整合性が保ちやすいです。

「戻らない」以外にも効く:VisualStateGroup 設計のチェックリスト

チェック項目なぜ重要かよくある症状対処
既定状態と変更状態が同一 VisualStateGroup か排他的に切り替わらないと上書きが残る既定に戻らない同じグループへまとめる
別グループで同じプロパティを触っていないか独立軸のつもりが競合して上書きが読めなくなる状態によって色が“行ったり来たり”する同一プロパティは基本的に同一グループに寄せる
状態名がテンプレート側と一致しているか名前ミスは GoToState が false になるが、ログがないと見逃しやすい一部だけ変わらないVisualState 名を定数化、Live Visual Tree で確認
ターゲット要素(x:Name)がテンプレート内にあるかテンプレート外の要素は TargetName できないStoryboard は動くのに見た目が無反応TargetName をテンプレート内の要素に限定
ローカル値(コードで直接代入)を置いていないかローカル値はスタイル/Setter より強い状態を変えても一部の色だけ固定される色はテンプレート+VisualState に集約する

「GoToState が true」でも戻らないときの追加切り分け

今回の結論(グループの整理)が最頻ですが、念のため、同じ見え方になる別要因も押さえておくと復旧が早くなります。

  • 別の VisualStateGroup が同じプロパティを触っている:グループを分けたつもりでも、どちらも BorderBrush を変更していると競合します。状態が重なる設計になっていないか確認します。
  • Style/ThemeResource の参照先が意図と違う:灰/赤を ThemeResource にしている場合、テーマやアクセントカラーで見え方が変わります。まずは固定色で検証すると切り分けやすいです。
  • アニメーションで別プロパティを触っている:BorderBrush ではなく Border.BorderBrush では?など、TargetProperty の指定ミスで「変わっているのに別要素」になっていることがあります。
  • テンプレートが二重に適用されている:コントロールに Style を当てたつもりが別の Style が優先され、見ているテンプレートが違うケースがあります。

ただし「別の色の状態へは何度でも変わる」「空の状態へ戻すだけが失敗」という条件が揃う場合は、やはり VisualStateGroup の配置ミスが最有力です。

デバッグのコツ:どの VisualStateGroup が生きているかを目で追う

原因を早く確定するには、「今どのグループがどの状態か」を可視化するのが効果的です。特に ItemsRepeater のように要素数が多い場合は、推測で直すと時間が溶けます。

  • Live Visual Tree で該当アイテム(StepBarItem)を選び、テンプレートのルート要素まで辿る
  • VisualStateGroups を探し、目的の状態(WaitingInfo/UnderWayInfo)がどのグループにいるか確認する
  • 同じプロパティ(BorderBrush/Foreground)を触っている VisualState が複数グループに散っていないか見る

「戻らない」場合は、ほぼ例外なく“戻したい側のグループが、そもそも遷移していない”状態になっています。

左右に伸縮する“謎の余白”を消すチェックポイント

状態の話とは別軸ですが、StepBar のように横方向に並べる UI では、余白が“勝手に伸び縮みする”ように見えることがあります。原因はだいたい親コンテナ側の設定です。まずは次を順に確認してください。

疑う場所ありがちな設定起きること改善のヒント
ItemsRepeater の LayoutStackLayout / UniformGridLayout の既定挙動幅の割り当てや間隔が想定とズレるSpacing、ItemSpacing、MinItemWidth などを明示する
親コンテナの Padding/MarginGrid/Border の Padding が残っている左右に一定の余白が入るまずは親から順に Padding="0" で潰して切り分け
HorizontalAlignment/HorizontalContentAlignmentStretch が既定で効いているアイテムが伸び、余白が変動して見える固定幅にしたいなら HorizontalAlignment="Left"、伸ばすなら均等割りのレイアウトへ
ItemTemplate のルート要素ルートに Margin が入っているアイテム間隔が二重になるSpacing と Margin を混在させない(どちらかに統一)

ItemsRepeater の横並びで「等間隔にしたい」「余白は一定にしたい」場合は、Layout 側で明示したほうがブレません。例えば横並びのときは次のようにします。

&lt;ItemsRepeater ItemsSource="{x:Bind ViewModel.Steps}"&gt;
  &lt;ItemsRepeater.Layout&gt;
    &lt;StackLayout Orientation="Horizontal" Spacing="8" /&gt;
  &lt;/ItemsRepeater.Layout&gt;
&lt;/ItemsRepeater&gt;

余白の切り分けは、Live Visual Tree で「実際にどの要素が Margin/Padding を持っているか」を見るのが一番速いです。まずは目視で怪しい要素をクリックし、サイズと余白の出どころを特定してから調整してください。

まとめ:戻らないときは“状態”ではなく“グループ”を疑う

  • VisualState は 同一 VisualStateGroup 内で排他的に切り替わる
  • 既定へ戻すための空の VisualState を別グループに置くと、色変更側の上書きが残りやすい
  • 空の VisualState で戻すなら、戻したい既定色はテンプレート初期値か既定状態側で明示する
  • ItemsRepeater では要素再利用があるため、OnApplyTemplate とプロパティ変更時の再適用をセットで用意する

「GoToState は成功しているのに見た目だけ戻らない」というときほど、テンプレートの VisualStateGroup を見直すのが近道です。状態定義を整理しておくと、ステップUI以外(タブ、トグル、選択リストなど)でも同じ考え方で安定した見た目を作れます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次