WPF で RadioButton を enum にバインドすると初期選択が表示されない問題と GroupName の役割

ViewModel が正しい列挙体の値を保持しているのにラジオボタンが未選択になるのは、GroupName を省いたことで別々の列挙体のボタンが 1 グループに統合されるためである。原因と解決策を実測で整理する。

概要

WPF で列挙体の値を選ばせる UI は、RadioButton.IsChecked をコンバーター経由で列挙体のプロパティにバインドして組むことが多い。
この構成では、ViewModel が正しい値を保持しているにもかかわらず、画面上では対応する RadioButton が選択されていない、という状態が発生する。
本記事では、この現象が GroupName の省略によるグループの統合に起因することを実測で示し、GroupName の指定方針と ConvertBack の戻り値の選び方、さらにコンバーター・ラッパープロパティ・添付ビヘイビア・選択コントロールへの置き換えという 4 方式の使い分けを整理する。


前提・対象環境


問題

印刷設定のダイアログを想定する。
印刷品質を表す Quality と、面付けを表す PageLayout の 2 つの列挙体があり、それぞれをラジオボタンで選ばせる。

public enum Quality
{
    Draft,
    Standard,
    Fine,
}

public enum PageLayout
{
    Single,
    Dual,
}

いずれも通常の列挙体であり、Flags 属性や明示的な値の指定は伴わない。
この 2 つが同じ画面に同居することが、後述する問題の前提条件になる。

ViewModel は 2 つの列挙体プロパティを持ち、初期値をそれぞれ Quality.StandardPageLayout.Single とする。
Quality.Standard は宣言順で 2 番目の値であり、初期選択が正しく反映されていれば Draft ではなく Standard が選択された状態で表示される。

public sealed class PrintSettingsViewModel : INotifyPropertyChanged
{
    private Quality _quality = Quality.Standard;
    private PageLayout _pageLayout = PageLayout.Single;

    public Quality Quality
    {
        get => _quality;
        set
        {
            if (_quality == value) return;
            _quality = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Quality)));
        }
    }

    public PageLayout PageLayout
    {
        get => _pageLayout;
        set
        {
            if (_pageLayout == value) return;
            _pageLayout = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(PageLayout)));
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
}

変更通知は 2 つのプロパティとも実装済みであり、通知漏れは本記事が扱う問題の原因ではない。
この点は、以降で原因を切り分けるうえでの前提となる。

列挙体の値と bool を相互変換するコンバーターを用意し、ConverterParameter に対象の値を渡してバインドする。
コンバーターの実装は「実装例」で示す。
ここでは、一般に正しいとされる、選択解除時に Binding.DoNothing を返す実装を使っている。
以下は 5 つのラジオボタンを 1 つの StackPanel にまとめた、実務でよく用いられる書き方である。

<StackPanel>
    <StackPanel.Resources>
        <local:EnumToBooleanConverter x:Key="EnumToBoolean" />
    </StackPanel.Resources>

    <RadioButton Content="Draft"
                 IsChecked="{Binding Quality, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:Quality.Draft}}" />
    <RadioButton Content="Standard"
                 IsChecked="{Binding Quality, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:Quality.Standard}}" />
    <RadioButton Content="Fine"
                 IsChecked="{Binding Quality, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:Quality.Fine}}" />

    <RadioButton Content="Single"
                 IsChecked="{Binding PageLayout, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:PageLayout.Single}}" />
    <RadioButton Content="Dual"
                 IsChecked="{Binding PageLayout, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:PageLayout.Dual}}" />
</StackPanel>

この XAML は構文としては正しく、バインディングエラーも出ない。
それにもかかわらず、起動直後に Single は選択された状態で表示されるのに対し、Quality 側は 3 つとも未選択のまま表示される。
ViewModel の QualityStandard を保持しており、画面と ViewModel が食い違う。

同じ ViewModel の値を表示する 2 つのラジオボタン群。GroupName を指定していない左側では Quality = Standard であるのに Draft・Standard・Fine のいずれも未選択で、GroupName を指定した右側では Standard が選択されている。
左列は本節の XAML、右列は後述の「実装例」の XAML に対応する。左右とも ViewModel の値は Quality = Standard / PageLayout = Single で、異なるのは GroupName の指定だけである。GroupName を省いた左側では、後から選択状態になった PageLayout 側のラジオボタンが Quality 側の選択を解除している。上段のラベルと下段の 2 行は比較のために加えた表示で、本文の XAML には含まれない。.NET 10 / Windows 11 で取得。

原因・背景

公式ドキュメントは、RadioButton のグループ化の方法として「親要素の内側に配置する」と「各 RadioButtonGroupName プロパティを設定する」の 2 通りを示している(RadioButton クラス)。
WPF の RadioButton.GroupName の既定値は空文字列である。
GroupName が空文字列のときは名前によるグループ化が行われず、代わりに論理ツリー上の親(FrameworkElement.Parent)の単位でグループ化される。
論理親が同じラジオボタンは 1 つのグループになり、論理親が異なれば別のグループになる。
上記の 5 つのラジオボタンは同じ StackPanel を論理親に持つため、バインド先のプロパティが別であっても 1 つのグループとして扱われる

グループ内のラジオボタンは相互排他になる。
初期化順は XAML の記述順であり、まず Quality 側の Standard が選択状態になり、続いて PageLayout 側の Single が選択状態になる。
Single が選択された時点で、同一グループとみなされている Standard がグループ機構によって解除される。

問題は、この解除がバインディングを通じてソース側へ伝わろうとする点にある。
ToggleButton.IsCheckedbool? 型の依存関係プロパティで、メタデータで既定の双方向バインディングが有効になっており、既定の UpdateSourceTriggerPropertyChanged である。
そのため解除は即座にソース更新として扱われ、コンバーターの ConvertBackfalse で呼び出される。
実測でも、Single の選択が確定した直後に ConvertBack(value: false, parameter: Quality.Standard) が 1 回呼ばれることを確認した。

GroupName を省いたときに解除がソースへ伝わる経路を示す 3 段構成の図。1 段目は、GroupName が空文字列のため 1 つの暗黙のグループができ、PageLayout.Single が選択されると Quality.Standard の IsChecked が true から false へ変わること、およびそのバインディングが TwoWay で UpdateSourceTrigger が PropertyChanged であること。2 段目は、その変化によって ConvertBack が value: false、parameter: Quality.Standard で呼ばれること。3 段目は、ConvertBack の戻り値 4 通りそれぞれの結果で、Binding.DoNothing と DependencyProperty.UnsetValue はいずれも Quality = Standard のまま IsChecked = false(後者には Validation.Errors が加わる)、parameter を返すと IsChecked = true に復元、例外を投げると NotImplementedException が UnhandledException となる。
グループの誤りが画面の食い違いに変わるまでの経路と、ConvertBack の戻り値による結果の違い。いずれの戻り値でも根本原因である GroupName の欠落は解消されない。parameter を返す実装だけは初期選択の欠落が現れないが、それは読み戻しによって症状が隠れているだけである。各結果は .NET 10 / Windows 11 上で実際に確認した。

以下では、本記事が前提としている Binding.DoNothing を返す実装の場合を追う。
残る 3 通りの戻り値は「注意点」で個別に扱う。

コンバーターが Binding.DoNothing を返す実装であれば、Binding.DoNothing フィールドの定義どおり値はソースへ転送されず、FallbackValue も既定値も使われない。
ソースへの書き込みが行われないため、書き込み後の読み戻しも起きない。
ターゲット側は解除されたまま残る。
結果として ViewModel の QualityStandard のまま保たれ、画面のラジオボタンだけが未選択の状態で取り残される。
これが冒頭で示した食い違いの原因である。

なお、同じ列挙体プロパティにバインドしたラジオボタン同士では、この ConvertBack(false) は通常発生しない
選択を切り替えると、まず選択された側の ConvertBack(true) でソースが更新され、その変更が Convert を通じて他のボタンへ伝わって false になる。
グループ機構が解除しようとした時点では既に false であり、値が変化しないためソース更新も起きない。
検証環境でも、同一プロパティ内の切り替えでは ConvertBacktrue で 1 回だけ呼ばれ、false では一度も呼ばれなかった。
ConvertBackfalse で呼ばれるのは、同じソースの同じプロパティにバインドしたボタン以外が、同じグループに混ざったときである。
別のプロパティにバインドしたボタンのほか、同じ名前のプロパティでもオブジェクトが異なるボタン、そもそもバインドしていないボタンがこれに当たる。
実行して確認したところ、一覧の各行が別の ViewModel の同名プロパティにバインドして全行に同じ GroupName を与えた構成でも、バインドしていないラジオボタンを 1 つ混ぜた構成でも ConvertBack(false) が発生した。


GroupName の有無だけを変えて、コンバーターの呼び出しとチェック状態を測った結果が次の図である。

GroupName の有無別に ConvertBack の呼び出し回数とチェック状態を測った表。GroupName の既定値は空文字列。GroupName を設定しない場合は ConvertBack が false で 1 回呼ばれ、チェックが Single だけになる。GroupName を設定すると、画面の初期化の間は呼び出しが 0 回で、Standard と Single の両方にチェックが残る。ソース側の値はどちらも Standard / Single のままである。
.NET 10 / Windows 11 で、同じ StackPanel の下に 2 組(QualityLayout)のラジオボタンを置いて測った結果。GroupName 属性の有無だけが 2 行の差である。

GroupName を設定しない行では、チェックが Single だけになっている。 別のプロパティにバインドしているにもかかわらず Standard が解除されており、これが「初期選択が表示されない」症状の実体である。
このとき ConvertBackfalse で 1 回呼ばれている。

注目すべきは、ソース側の値はどちらの行も Standard / Single のまま無傷である点である。
コンバーターが false に対して Binding.DoNothing を返しているため、ViewModel は壊れていない。
壊れているのは画面の表示だけであり、ViewModel をログに出しても原因にたどり着けない。

GroupName を設定した行では、画面の初期化の間に ConvertBack が 1 度も呼ばれず、両方のチェックが残る。
この行が測っているのは初期化までである。その後にユーザーが選択を変えれば、前述のとおり新しくチェックされたボタンで ConvertBack(true) が走る。


解決方法

根本原因は UI 側のグループ化にあるため、対処もグループ化で行う。

グループ化は論理親の単位で行われるため、列挙体のプロパティごとに親のパネルを分けても症状は解消する。
実行して確認したところ、「問題」の 5 つを Quality 用と PageLayout 用の 2 つの StackPanel に分けるだけで、GroupName を書かずに初期選択が両方とも表示された。
ただしこの方法はレイアウトの構造に依存し、後の変更でパネルをまとめ直すと再発する。
GroupBoxHeaderContentGrid の別セルのように、画面上は離れていても論理親が同じになる配置では、グループが分かれない(「注意点」参照)。
グループの境界を意図として明示できる GroupName の指定を推奨する。


実装例

コンバーターは、Convert で列挙体の値とパラメーターの一致を bool に変換し、ConvertBack では選択された場合にのみパラメーターを返す。
value にはボックス化された bool、または null が渡る。
value is true のパターンマッチは、null とボックス化された false をまとめて弾く。

public sealed class EnumToBooleanConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        => value?.Equals(parameter) == true;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => value is true ? parameter : Binding.DoNothing;
}

選択された側のバインディングで ConvertBackparameter(対象の列挙体の値)を返し、ViewModel のプロパティが更新される。
解除された側で ConvertBack が呼ばれた場合は Binding.DoNothing を返すため、ソースは更新されない。
グループを正しく分けていればこの呼び出しは起きないが、戻り値を誤らないことが後述の落とし穴を防ぐ。

XAML では、列挙体のプロパティごとに異なる GroupName を与える。
「問題」で示した XAML との差分は GroupName 属性の追加だけである。

<StackPanel>
    <StackPanel.Resources>
        <local:EnumToBooleanConverter x:Key="EnumToBoolean" />
    </StackPanel.Resources>

    <RadioButton Content="Draft" GroupName="quality"
                 IsChecked="{Binding Quality, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:Quality.Draft}}" />
    <RadioButton Content="Standard" GroupName="quality"
                 IsChecked="{Binding Quality, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:Quality.Standard}}" />
    <RadioButton Content="Fine" GroupName="quality"
                 IsChecked="{Binding Quality, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:Quality.Fine}}" />

    <RadioButton Content="Single" GroupName="pageLayout"
                 IsChecked="{Binding PageLayout, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:PageLayout.Single}}" />
    <RadioButton Content="Dual" GroupName="pageLayout"
                 IsChecked="{Binding PageLayout, Converter={StaticResource EnumToBoolean}, ConverterParameter={x:Static local:PageLayout.Dual}}" />
</StackPanel>

この状態で起動すると、StandardSingle の両方が初期選択として表示される。
Draft を選ぶと Quality だけが Draft へ変わり、PageLayout の選択は保たれる。


注意点


代替案・比較

方式 メリット デメリット 適するケース
コンバーター + ConverterParameter ViewModel に追加のプロパティが不要・選択肢が増えても XAML の追加だけで済む ConvertBack の戻り値を誤ると挙動が壊れる・ConverterParameter をバインドできない 選択肢が XAML に固定で並ぶ一般的な設定画面
列挙体の値ごとのラッパープロパティ コンバーターが不要で XAML が単純・ConvertBack の考慮が要らない 選択肢の数だけプロパティと変更通知が増える・列挙体の値を追加するたび ViewModel を直す 選択肢が 2〜3 個で固定され、ViewModel を単純に保ちたい
添付ビヘイビア(添付プロパティに列挙体の値を持たせる) XAML から列挙体を直接指定でき、ConvertBack の落とし穴が無い・項目ごとに異なる値をバインドできる 添付プロパティとイベント購読の実装が必要 同じパターンをアプリケーション全体で多用する
ListBox などの選択コントロールへ置き換え SelectedItem / SelectedValue で完結し、グループ化の問題自体が起きない ラジオボタンの見た目が要る場合は ItemContainerStyle の指定が必要 選択肢が動的、または数が多い

ラッパープロパティ方式では、「問題」で示した PrintSettingsViewModelQuality プロパティを次の形に置き換え、列挙体の値ごとに bool プロパティを追加する。
_quality フィールドと PropertyChanged の宣言は同じクラスのものをそのまま使う。
ラッパーのセッターは true のときだけ列挙体プロパティを更新し、解除時の false は無視する。
発火する通知が増えるため、PropertyChanged?.InvokeRaise ヘルパーにまとめている。

public Quality Quality
{
    get => _quality;
    set
    {
        if (_quality == value) return;
        _quality = value;
        Raise(nameof(Quality));
        Raise(nameof(IsDraft));
        Raise(nameof(IsStandard));
        Raise(nameof(IsFine));
    }
}

public bool IsDraft
{
    get => Quality == Quality.Draft;
    set { if (value) Quality = Quality.Draft; }
}

private void Raise(string propertyName)
    => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

IsStandardIsFineIsDraft と同じ形で定義する。
プロパティ名と列挙体名がどちらも Quality であるため、Quality.Draft は型のメンバーとして解決される。
発火漏れがあると、選択を切り替えても他のボタンの表示が古いまま残る。
列挙体の値を増やすたびにプロパティと発火の記述が増えるため、選択肢が少ない場合に向く。

添付ビヘイビア方式は、RadioButton に添付プロパティで対象の列挙体の値を持たせ、Checked イベントの発生時に ViewModel のプロパティへ書き戻す。
添付プロパティは依存関係プロパティであるため、ConverterParameter と異なり項目ごとに異なる値をバインドできる。

選択肢が動的に決まる場合は、ListBoxItemsSource に列挙体の値を渡し、SelectedItem を ViewModel にバインドする方式が扱いやすい。
選択値の取得方法の使い分けは、WPF ComboBox の ItemsSource バインドパターンと選択値の取得方法で整理している。


まとめ

ラジオボタンの初期選択が画面に出ないのは、バインディングやコンバーターの不備ではなく、GroupName を省いたことで別々の列挙体のラジオボタンが 1 つのグループに統合されたことが原因である。
選択基準は次のとおりである。

双方向バインディングがソースを更新するタイミングは UpdateSourceTrigger で決まり、IsChecked の既定は PropertyChanged である。
この既定値の違いが入力の反映タイミングにどう影響するかは、WPF TextBox の UpdateSourceTrigger で入力がソースへ反映されるタイミングを制御するで扱っている。