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 に影響するのは? |
|---|---|---|---|
| 起動直後 | UnderWayInfo | WaitingInfo | StepStateGroup 側(青)が勝つ |
| Prev(WaitingInfoへ) | UnderWayInfo のまま | WaitingInfo | StepStateGroup 側(青)が残り続ける |
つまり、戻すには次のどちらかが必要です。
- 同じ VisualStateGroup 内で UnderWayInfo → WaitingInfo に遷移させる
- または、別グループ設計を維持するなら、各グループの「戻し先状態」へも明示的に遷移させる(呼び出しが二重になりがち)
誤った構造のイメージ
以下は典型的な「やってしまいがち」な配置です(説明用に簡略化しています)。
<VisualStateManager.VisualStateGroups>
...青にする...
これだと「WaitingInfo へ遷移」はできても、青の上書きは StepStateGroup 側で残り続けます。結果として、Prev で戻したつもりでも前アイテムが青のまま見えます。
解決策:空の VisualState と色を変える VisualState を同じ VisualStateGroup にまとめる
直し方はシンプルです。既定に戻す“空の VisualState”と、色を変える VisualState を同一グループへ入れます。
例えば、WaitingInfo(空)と UnderWayInfo(青)を同じグループにまとめます。
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="StepStateGroup">
<!-- 既定に戻す状態(空でOK) -->
<VisualState x:Name="WaitingInfo" />
<!-- 進行中(青に上書き) -->
<VisualState x:Name="UnderWayInfo">
<Storyboard>
<ObjectAnimationUsingKeyFrames
Storyboard.TargetName="RootBorder"
Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0"
Value="{ThemeResource SystemControlHighlightAccentBrush}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames
Storyboard.TargetName="TitleText"
Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0"
Value="{ThemeResource SystemControlHighlightAccentBrush}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
同一グループ内で UnderWayInfo → WaitingInfo へ遷移すれば、VisualStateManager は前の状態の上書きを解除(停止/置き換え)し、テンプレートの既定値へ戻せます。これで Prev での見た目の復帰が正しく反映されます。
「既定色」に確実に戻す:テンプレート初期値と Default 状態の置き場所
空の VisualState に戻したとき、最終的に表示されるのは「テンプレート側の初期値」です。つまり、戻したい“既定色”は次のどちらかに置く必要があります。
| 方法 | やること | メリット | 注意点 |
|---|---|---|---|
| テンプレート初期値で持つ | Border/TextBlock の BorderBrush/Foreground を最初から灰/赤にする。既定状態は空。 | 状態が少なくて済む。戻しが自然。 | 初期値が「本当の既定」になる。後から仕様変更があると追跡しづらい。 |
| 既定状態で明示する | WaitingInfo 側に Setters/Storyboard を置き、灰/赤を明示する。 | 「この状態はこの見た目」という対応が明確。テンプレート初期値に依存しない。 | 状態定義が少し増えるが、保守性は上がる。 |
サンプルが初期化直後に UnderWayInfo に入るような場合、空の状態へ戻すと“本当の初期値”(例:黒)に戻ってしまうことがあります。戻したい色が灰/赤なら、テンプレート初期値を灰/赤にするか、WaitingInfo で明示するのが安全です。
おすすめ:VisualState.Setters で「戻り先」も明示する
アニメーションが不要で「色を切り替えるだけ」なら、Storyboard より VisualState.Setters を使うと読みやすく、戻しも意図が伝わりやすいです。
<VisualStateGroup x:Name="StepStateGroup">
<VisualState x:Name="WaitingInfo">
<VisualState.Setters>
<Setter Target="RootBorder.BorderBrush" Value="DarkGray" />
<Setter Target="TitleText.Foreground" Value="Red" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="UnderWayInfo">
<VisualState.Setters>
<Setter Target="RootBorder.BorderBrush"
Value="{ThemeResource SystemControlHighlightAccentBrush}" />
<Setter Target="TitleText.Foreground"
Value="{ThemeResource SystemControlHighlightAccentBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
「空の状態で戻す」設計でも動きますが、仕様が増えると既定色が分散しやすいので、運用では 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 の齟齬が起きにくくなります。
<ItemsRepeater ItemsSource="{x:Bind ViewModel.Steps}">
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="local:StepVm">
<local:StepBarItem State="{x:Bind State}" />
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
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 の Layout | StackLayout / UniformGridLayout の既定挙動 | 幅の割り当てや間隔が想定とズレる | Spacing、ItemSpacing、MinItemWidth などを明示する |
| 親コンテナの Padding/Margin | Grid/Border の Padding が残っている | 左右に一定の余白が入る | まずは親から順に Padding="0" で潰して切り分け |
| HorizontalAlignment/HorizontalContentAlignment | Stretch が既定で効いている | アイテムが伸び、余白が変動して見える | 固定幅にしたいなら HorizontalAlignment="Left"、伸ばすなら均等割りのレイアウトへ |
| ItemTemplate のルート要素 | ルートに Margin が入っている | アイテム間隔が二重になる | Spacing と Margin を混在させない(どちらかに統一) |
ItemsRepeater の横並びで「等間隔にしたい」「余白は一定にしたい」場合は、Layout 側で明示したほうがブレません。例えば横並びのときは次のようにします。
<ItemsRepeater ItemsSource="{x:Bind ViewModel.Steps}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Horizontal" Spacing="8" />
</ItemsRepeater.Layout>
</ItemsRepeater>
余白の切り分けは、Live Visual Tree で「実際にどの要素が Margin/Padding を持っているか」を見るのが一番速いです。まずは目視で怪しい要素をクリックし、サイズと余白の出どころを特定してから調整してください。
まとめ:戻らないときは“状態”ではなく“グループ”を疑う
- VisualState は 同一 VisualStateGroup 内で排他的に切り替わる
- 既定へ戻すための空の VisualState を別グループに置くと、色変更側の上書きが残りやすい
- 空の VisualState で戻すなら、戻したい既定色はテンプレート初期値か既定状態側で明示する
- ItemsRepeater では要素再利用があるため、OnApplyTemplate とプロパティ変更時の再適用をセットで用意する
「GoToState は成功しているのに見た目だけ戻らない」というときほど、テンプレートの VisualStateGroup を見直すのが近道です。状態定義を整理しておくと、ステップUI以外(タブ、トグル、選択リストなど)でも同じ考え方で安定した見た目を作れます。

コメント