WPF ComboBox の ItemsSource バインドパターンと選択値の取得方法

ItemsSource に渡すデータ構造によって、DisplayMemberPath・SelectedItem・SelectedValue の設定方法が変わる。文字列リスト・オブジェクトリスト・Enum などを扱う 5 つの実装パターンと選択の分岐点を整理する。

概要

WPF の ComboBox は ItemsSource に渡すデータ構造に応じて、表示内容の制御方法と選択値の取得方法が変わる。具体的には DisplayMemberPath、ItemTemplate、SelectedItem、SelectedValue、SelectedValuePath の組み合わせが、バインドするコレクションの型によって異なる。
本記事では代表的な実装パターンを整理し、それぞれの選択に関するプロパティの使い分けを示す。
—

前提・対象環境

本記事の表は、上記の環境で実際に計測したものである。
計測の条件は、それぞれの表の説明に書いた。
この環境で確認しているのは次の点である。


問題

ComboBox の ItemsSource に渡すコレクションの要素型が変わると、選択値の取得方法も変わるたとえば文字列リストを渡す場合と、ID と名称を持つオブジェクトのリストを渡す場合では、ViewModel にバインドするプロパティの型と設定が異なる。
この違いを把握していないと、選択しても値が反映されない、または初期値が表示されないといった問題が発生する。
—

原因・背景

ComboBox の選択関連プロパティは以下の 3 種類がある。

プロパティ 返す値 主な用途
SelectedItem ItemsSource の要素そのもの オブジェクト全体を ViewModel に渡す
SelectedValue SelectedValuePath で指定したプロパティ値 ID など特定フィールドだけを取得する
SelectedIndex 選択行のインデックス(0 始まり) 位置だけを管理する場合

文字列リストの場合、要素そのものが文字列であるため SelectedItem は string を返す。オブジェクトのリストを使い SelectedValuePath を指定すると、SelectedValue には指定プロパティの値が返る。
どのプロパティをバインドするかはデータ構造に依存するため、構造ごとに設定を合わせる必要がある。
—

同じ選択に対して 3 つのプロパティが何を返すかは、実際に選択して読み出せば確かめられる。

設定 SelectedItem SelectedValue SelectedIndex 表示
DisplayMemberPath なし / SelectedValuePath なし Item(20, beta) Item(20, beta) 1 型の名前(Item.ToString())
DisplayMemberPath=Name Item(20, beta) Item(20, beta) 1 beta
+ SelectedValuePath=Id Item(20, beta) 20 (Int32) 1 beta
SelectedValuePath=Name Item(20, beta) beta (String) 1 beta

.NET 10 / Windows 11 で、Id と Name を持つ 3 件のうち 2 件目を選択して測った結果。「表示」の列は、閉じた状態の ComboBox に表示されている文字列である。

SelectedValuePath を設定しないとき、SelectedValue は項目そのものを返す。 上の表では「SelectedValuePath で指定したプロパティ値」と説明したが、未設定時は SelectedItem と同じものが返る点に注意する。
SelectedValuePath=Id にすると Int32 の 20 になり、型まで変わる。ID だけを ViewModel に渡したい場合はこの設定が要る。

DisplayMemberPath は表示だけに効き、SelectedValue の中身には影響しない。この 2 つは独立している。


5 つの実装パターン

ItemsSource の要素型と、ViewModel に何を持たせたいかの組み合わせで、代表的な 5 パターンを順に見る。
要素型が同じでも、選択値の受け取り方や表示の作り方で構成が変わる。
表示が変わるものもあれば、表示は同じで SelectedValue に入る値だけが変わるもの(パターン B と C)もある。

3 つの ComboBox を並べた画面。文字列リストと DisplayMemberPath は Baker とだけ表示され、ItemTemplate を使ったものは 102 と Baker の 2 列で表示されている。
いずれも 2 件目を選択した状態。文字列リストと DisplayMemberPath は 1 つの文字列を表示するのに対し、ItemTemplate を使うと選択済みの表示にもテンプレートが適用され、複数フィールドを並べられる。

パターン A:文字列リスト

ItemsSource が ObservableCollection<string> のとき、DisplayMemberPath は不要であり、SelectedItem に string 型のプロパティをバインドするだけで機能する。

<ComboBox ItemsSource="{Binding Regions}"
          SelectedItem="{Binding SelectedRegion}" />

対応する ViewModel 側の実装は次のとおりである。

public ObservableCollection<string> Regions { get; } = new()
{
    "東北", "関東", "中部", "近畿", "九州"
};

private string? _selectedRegion;
public string? SelectedRegion
{
    get => _selectedRegion;
    set { _selectedRegion = value; OnPropertyChanged(); }
}

SelectedItem に string 以外の型(例: int)をバインドすると型の不一致でバインドエラーとなる。


パターン B:オブジェクトリスト + DisplayMemberPath + SelectedItem

ItemsSource が ObservableCollection<T> で、表示する文字列と選択して取得するオブジェクトが同じ型の場合、DisplayMemberPath で表示プロパティを指定し、SelectedItem にオブジェクト全体をバインドする。

<ComboBox ItemsSource="{Binding Departments}"
          DisplayMemberPath="Name"
          SelectedItem="{Binding SelectedDepartment}" />

上記の XAML では Department オブジェクトの Name を表示しつつ、選択結果として Department オブジェクト全体を SelectedDepartment にバインドしている。
対応するモデルと ViewModel の定義例は次のとおり。

public class Department
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

public ObservableCollection<Department> Departments { get; } = new()
{
    new Department { Id = 1, Name = "営業部" },
    new Department { Id = 2, Name = "開発部" },
    new Department { Id = 3, Name = "総務部" },
};

private Department? _selectedDepartment;
public Department? SelectedDepartment
{
    get => _selectedDepartment;
    set { _selectedDepartment = value; OnPropertyChanged(); }
}

選択後は SelectedDepartment.Id や SelectedDepartment.Name で任意のフィールドにアクセスできるViewModel にオブジェクト全体を保持するため、後から複数フィールドを参照しやすい。

パターン C:オブジェクトリスト + DisplayMemberPath + SelectedValuePath

表示は Name で行い、選択値として Id(数値や文字列のキー)だけを取得したい場合に使う。
SelectedValuePath に取得したいプロパティ名を指定し、SelectedValue にそのプロパティの型でバインドする。

<ComboBox ItemsSource="{Binding Departments}"
          DisplayMemberPath="Name"
          SelectedValuePath="Id"
          SelectedValue="{Binding SelectedDepartmentId}" />

対応する ViewModel 側では、選択された Id を保持するプロパティを次のように定義する。

private int _selectedDepartmentId;
public int SelectedDepartmentId
{
    get => _selectedDepartmentId;
    set { _selectedDepartmentId = value; OnPropertyChanged(); }
}

SelectedValue の型と SelectedValuePath で指定するプロパティの型が違っても、計測では選択は反映された。Id が int で、ソースが文字列型のプロパティ(値は "20")でも Id 20 の項目が選択され、別の項目を選ぶと文字列の "30" が書き戻された(後述の「注意点」の表)。ただし、宣言型が object のプロパティに "20" を入れた場合は、Int32 の 30 が書き戻され、値の型が変わった。ViewModel のプロパティはパスと同じ型にしておくほうが分かりやすい。
また SelectedItem と SelectedValue は同時に利用できるが、一方を変更するともう一方も自動で更新される。
—

パターン D:ItemTemplate を使ったカスタム表示

1 行の表示に複数フィールドを含めたい場合や、アイコン付きの選択肢を実装したい場合は ItemTemplate を使う。
DisplayMemberPath と ItemTemplate は同時に設定できない。両方を設定すると、コードでは InvalidOperationException、XAML では XamlParseException になった。
カスタム表示が必要なときは ItemTemplate だけを使い、DisplayMemberPath は外す。

<ComboBox ItemsSource="{Binding Employees}"
          SelectedItem="{Binding SelectedEmployee}">
    <ComboBox.ItemTemplate>
        <DataTemplate>
            <StackPanel Orientation="Horizontal">
                <TextBlock Text="{Binding Id}" Width="40" Foreground="Gray"/>
                <TextBlock Text="{Binding Name}" />
            </StackPanel>
        </DataTemplate>
    </ComboBox.ItemTemplate>
</ComboBox>

この XAML で参照する Employee モデルの定義例は次のとおりである。

public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

ItemTemplate を使う場合、ドロップダウンを閉じた状態(選択済み表示)と展開した一覧で異なるレイアウトを出したいときは、ItemTemplate の代わりに ItemContainerStyle と ContentTemplate を組み合わせる。


パターン E:Enum リスト

選択肢が固定の列挙体の場合、ObjectDataProvider を使って XAML から Enum 値を列挙する方法と、ViewModel でコレクションを用意する方法がある。
保守性を考えると ViewModel でコレクションを生成する方法が安全である。
ViewModel 側で Enum.GetValues を使って選択肢を生成する。

public enum Priority { 低, 中, 高 }

public IEnumerable<Priority> Priorities { get; }
    = (Priority[])Enum.GetValues(typeof(Priority));

private Priority _selectedPriority = Priority.中;
public Priority SelectedPriority
{
    get => _selectedPriority;
    set { _selectedPriority = value; OnPropertyChanged(); }
}

この ViewModel に対して、XAML 側では ItemsSource と SelectedItem を次のようにバインドする。

<ComboBox ItemsSource="{Binding Priorities}"
          SelectedItem="{Binding SelectedPriority}" />

SelectedItem の型は Priority(Enum 型)になる。列挙型の値には数値を返すプロパティが無いため、SelectedValuePath では数値を取り出せない。SelectedValuePath="value__" としても SelectedValue は null だった。数値が必要なら (int)SelectedPriority のようにキャストする。

選択の分岐点

どのパターンを使うかは、ItemsSource の要素型と、ViewModel に何を持たせたいかで決まる。

ViewModel 側のプロパティ型と ComboBox の設定が対応していれば、初期選択や選択変更の反映漏れは起きにくい。


パターン別の比較

パターン ItemsSource の型 選択値の取得 適するケース
A: 文字列リスト ObservableCollection<string> SelectedItem(string) 選択肢が単純なラベルのみ
B: オブジェクト + SelectedItem ObservableCollection<T> SelectedItem(T) 選択後に複数フィールドを参照する
C: オブジェクト + SelectedValuePath ObservableCollection<T> SelectedValue(指定プロパティ型) ID など特定フィールドだけ必要
D: ItemTemplate ObservableCollection<T> SelectedItem(T) 複数フィールドを 1 行に表示する
E: Enum IEnumerable<TEnum> SelectedItem(TEnum) 固定の列挙値から選択する

注意点

条件 計測値
DisplayMemberPath + ItemTemplate(コード) InvalidOperationException
DisplayMemberPath + ItemTemplate(XAML) XamlParseException(内側の例外 InvalidOperationException)
SelectedValuePath=Id(int)、ソースは string のプロパティで "20" 表示時: SelectedIndex 1、インデックス 2 を選んだ後のソース = 30(String)
SelectedValuePath=Id(int)、ソースは object のプロパティで "20" を保持 表示時: SelectedIndex 1、インデックス 2 を選んだ後のソース = 30(Int32)
列挙型の項目、SelectedValuePath=value__ SelectedItem High, SelectedValue null
ItemsSource より前に SelectedValue = 20(コード) SelectedIndex 1
SelectedValue を 30 にバインド、表示した後に ItemsSource を設定 SelectedIndex 2、ソース 30

.NET 10 / Windows 11 で、Id が 10・20・30 の 3 件を使って測った結果。


まとめ

ComboBox の実装パターンは ItemsSource に渡す型によって決まる。
単純値なら SelectedItem、オブジェクトなら全体が要るか特定フィールドだけで足りるかで SelectedItem と SelectedValue を選び分ける。

初期値が反映されない問題の多くは、SelectedItem に渡した値が ItemsSource のどの項目とも Equals で一致しないことか、ItemsSource に無い値を初期値にしていることに起因する。
前者は、SelectedValuePath で ID などの値を比べるか、ItemsSource の項目と同じインスタンスを渡すよう設計することで回避できる。後者は、SelectedValuePath を使っても一致する項目が無いため選択されない。初期値は ItemsSource にある値から選ぶ。