連載
» 2017年11月08日 05時00分 公開

.NET TIPS:[WPF/UWP]列挙型をComboBoxにバインドするには?

ComboBoxに列挙型をバインドして、列挙値に応じたテキストを表示し、選択された項目を取得する方法を説明。カスタムコントロールを使う方法やUWPでの注意点も取り上げる。

[山本康彦,BluewaterSoft/Microsoft MVP for Windows Development]
「.NET TIPS」のインデックス

連載目次

 WPFアプリやUWPアプリなどでは、データを画面に表示するためにデータバインディングを使うのが一般的だ。TextBoxコントロールやSliderコントロールなど1つの値を表示するだけのコントロールならば、データバインディングは簡単だ。ComboBoxコントロールやListViewコントロールなど、複数の項目を表示してその中からエンドユーザーに選択してもらうようなコントロールでデータバインディングするのは、ちょっと難しくなる。

 本稿では、ComboBoxコントロールに列挙型のデータをバインドする方法を紹介する。特定のトピックをすぐに知りたいという方は以下のリンクを活用してほしい。

 なお、本稿に掲載したサンプルコードをそのまま試すにはVisual Studio 2017以降が必要である。掲載したサンプルコードに基づいて作成したWPFアプリの例を次の画像に示す。

サンプルコードの実施例(WPF) サンプルコードの実施例(WPF)
掲載したサンプルコードに基づいて作成したWPFアプリの例。
上のドロップダウンは、標準のComboBoxコントロールをそのまま使っている。下のものは、カスタムコントロールを作成した。
この例では、バインドしている列挙型の値(画面では「Enum値」と表示)はOne/Two/Threeであるが、表示する文字列は選択肢1/選択肢2/選択肢3と日本語にしている。

ComboBoxをそのまま使うには?

 列挙値と表示文字列のDictionaryコレクションをComboBoxコントロールのItemsSourceプロパティに、データクラス(=バインディングソース)の列挙型プロパティをSelectedValueプロパティにバインドすればよい(次の図)。

列挙型をComboBoxにバインドするときの考え方 列挙型をComboBoxにバインドするときの考え方
ドロップダウンリストには列挙型の一覧を表示したいので、右上に示した(Enum値をKey、表示文字列をValueとする)DictionaryコレクションをItemsSourceプロパティにバインドする。ドロップダウンリストに表示したいのはDictionaryコレクションのValueの方なので、DisplayMemberPathプロパティには"Value"と指定する。
エンドユーザーがドロップダウンリストで選択操作をすると、Dictionaryコレクションの要素が1つ選択される。そのKeyプロパティの方をデータクラスにバインドしたいので、SelectedValuePathプロパティに"Key"と指定する。これで、エンドユーザーが選択した項目の列挙値がSelectedValueプロパティに設定されるようになる。
左上のデータクラス(=バインディングソース)の列挙型プロパティをSelectedValueプロパティに双方向でバインドすれば、エンドユーザーの選択がデータクラスに反映されるようになる(後述するがUWPではここのバインドに工夫が必要だ)。

 なお、列挙値そのもの(上の図ではOne/Two/Three)をそのままドロップダウンに表示するのであれば、Dictionaryコレクションの代わりに、列挙値だけのコレクション(配列やListコレクションなど)を使ってもよい。その場合、WPFではObjectDataProviderクラスを利用すると、XAMLだけでドロップダウンの表示が完結する(この方法はUWPでは利用できない)。

 列挙値(上の図ではOne/Two/Three)とドロップダウンリストに表示したい文字列(上の図では選択肢1/選択肢2/選択肢3)は、日本語環境では異なっているのが一般的だ。そこで本稿では、上図のようにDictionaryコレクションを使う汎用的な方法だけを説明する。

列挙型SampleEnum

 それでは、上の図をWPFで実装する例を順を追って説明していこう。まずはバインドする列挙型の定義から(次のコード)。

namespace dotNetTips1207CS
{
  public enum SampleEnum
  {
    One = 1, Two, Three,
  }
}

Public Enum SampleEnum
  One = 1
  Two
  Three
End Enum

列挙型SampleEnum(上:C#、下:VB)

データクラスSampleData

 バインディングソースとなるのが、上の図の左側にあるクラスだ(次のコード)。このクラスのEnumValueプロパティを、後ほどComboBoxのSelectedValueプロパティにバインドする。双方向にバインドするため、INotifyPropertyChangedインタフェース(System.ComponentModel名前空間)を実装している。

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace dotNetTips1207CS
{
  public abstract class BindableBase : INotifyPropertyChanged
  {
    public event PropertyChangedEventHandler PropertyChanged;

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

    protected void SetProperty<T>(ref T storage, T value,
                                [CallerMemberName] string propertyName = null)
    {
      if (object.Equals(storage, value))
        return;
      storage = value;
      OnPropertyChanged(propertyName);
    }
  }

  public class SampleData : BindableBase
  {
    // 画面とバインドしたい列挙型のプロパティ
    private SampleEnum _enumValue;
    public SampleEnum EnumValue
    {
      get => _enumValue;
      set => SetProperty(ref _enumValue, value);
    }
  }
}

Imports System.ComponentModel
Imports System.Runtime.CompilerServices

Public Class BindableBase
  Implements INotifyPropertyChanged

  Public Event PropertyChanged As PropertyChangedEventHandler _
    Implements INotifyPropertyChanged.PropertyChanged

  Overridable Sub OnPropertyChanged(propertyName As String)
    RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(propertyName))
  End Sub

  Overridable Sub SetProperty(Of T)(ByRef storage As T, value As T,
                                <CallerMemberName> Optional propertyName As String = Nothing)
    If (Object.Equals(storage, value)) Then
      Return
    End If
    storage = value
    OnPropertyChanged(propertyName)
  End Sub
End Class

Public Class SampleData
  Inherits BindableBase

  ' 画面とバインドしたい列挙型のプロパティ
  Private _enumValue As SampleEnum
  Public Property EnumValue As SampleEnum
    Get
      Return _enumValue
    End Get
    Set(value As SampleEnum)
      SetProperty(_enumValue, value)
    End Set
  End Property
End Class

データクラスSampleData(上:C#、下:VB)
データの変化を画面へ通知するためのINotifyPropertyChangedインタフェースの実装は、汎用的な部分を親クラスBindableBaseとしてまとめておいた。
C#のコードではC# 7の新機能を幾つか使っている。その詳細は「特集:C# 7の新機能詳説:第2回 簡潔なコーディングのために」をご覧いただきたい。

列挙値と表示文字列のDictionary

 前掲の図の右側にあるDictionaryコレクションは、列挙値とそれに対応する表示文字列のテーブルだ。これは恐らくアプリの他の部分からも利用するであろうから、データクラスとは別の場所に置こう。ここでは次のコードに示すAppCommonDataクラスを作った。

using System.Collections.Generic;

namespace dotNetTips1207CS
{
  public class AppCommonData
  {
    // ComboBoxの一覧に表示するデータ
    public Dictionary<SampleEnum, string> SampleEnumNameDictionary { get; }
      = new Dictionary<SampleEnum, string>();

    public AppCommonData()
    {
      // 列挙値とその表示文字列のDictionaryを作る
      SampleEnumNameDictionary.Add(SampleEnum.One, "選択肢1");
      SampleEnumNameDictionary.Add(SampleEnum.Two, "選択肢2");
      SampleEnumNameDictionary.Add(SampleEnum.Three, "選択肢3");
    }
  }
}

Public Class AppCommonData

  ' ComboBoxの一覧に表示するデータ
  Public ReadOnly Property SampleEnumNameDictionary As Dictionary(Of SampleEnum, String) _
    = New Dictionary(Of SampleEnum, String)

  Public Sub New()
    ' 列挙値とその表示文字列のDictionaryを作る
    SampleEnumNameDictionary.Add(SampleEnum.One, "選択肢1")
    SampleEnumNameDictionary.Add(SampleEnum.Two, "選択肢2")
    SampleEnumNameDictionary.Add(SampleEnum.Three, "選択肢3")
  End Sub

End Class

列挙値と表示文字列のDictionaryを持つクラス(上:C#、下:VB)

 なお、列挙値をそのまま表示する(前掲の図でいえばOne/Two/Threeと表示する)のであれば、上のコードのコンストラクタを次のコードのように変更する。

public AppCommonData()
{
  // 列挙値をそのまま表示する場合
  SampleEnum[] enumvalues = Enum.GetValues(typeof(SampleEnum)) as SampleEnum[];
  foreach (var e in enumvalues)
    SampleEnumNameDictionary.Add(e, e.ToString());
}

Public Sub New()
  ' 列挙値をそのまま表示する場合
  Dim enumvalues() As SampleEnum = [Enum].GetValues(GetType(SampleEnum))
  For Each e In enumvalues
    SampleEnumNameDictionary.Add(e, e.ToString())
  Next
End Sub

列挙値をそのまま表示する場合(上:C#、下:VB)

ComboBoxとバインドする

 以上のパーツを画面のXAMLにまとめよう(次のコード)。これで完成である。ComboBoxコントロールのドロップダウンには[選択肢1][選択肢2][選択肢3]と表示され、いずれかを選択すると(データクラスのプロパティの変更を介して)TextBlockコントロールの表示が列挙値[One][Two][Three]の対応する値に変わる。

<Window x:Class="dotNetTips1207CS.MainWindow"
        ……省略…… >
  <Window.Resources>
    <local:SampleData x:Key="Sample" />
    <local:AppCommonData x:Key="CommonData" />
  </Window.Resources>
  <Grid DataContext="{StaticResource Sample}">
    <StackPanel ……省略…… >
      <TextBlock>選択されているEnum値=<Run
          Text="{Binding EnumValue}"/></TextBlock>
      <ComboBox ItemsSource="{Binding SampleEnumNameDictionary,
                              Source={StaticResource CommonData}}"
                DisplayMemberPath="Value"
                SelectedValue="{Binding EnumValue, Mode=TwoWay}"
                SelectedValuePath="Key"
        />
    </StackPanel>
  </Grid>
</Window>

WPFの画面にまとめた例(XAML)
WPFのプロジェクトを作ったときに自動生成された「MainWindow.xaml」ファイルをこのように書き換える。プロジェクトの名前空間は、(ここまでのコードも含めて)適宜読み替えてもらいたい。
前掲の図で説明したように、列挙値と表示文字列のテーブル(「CommonData」というキー名を付けたAppCommonDataクラスインスタンスのSampleEnumNameDictionaryプロパティ)をComboBoxのItemsSourceプロパティにバインドしている。テーブルの各要素のValueプロパティ(すなわち表示文字列の方)をドロップダウンリストに表示したいので、DisplayMemberPathプロパティには"Value"を指定する。SelectedValuePathプロパティに"Key"と指定したので、エンドユーザーが選択したときにSelectedValueプロパティはテーブルの要素のKeyプロパティ(すなわち列挙値の方)になる。そしてSelectedValueプロパティは双方向バインディング(Mode=TwoWay)でデータクラス(SampleDataクラスのインスタンスで「Sample」というキー名を付けている)のEnumValueプロパティにバインドされているので、エンドユーザーが選択するとデータクラスのEnumValueプロパティが変化する。

カスタムコントロールを使うには?

 上のXAMLコードは、ちょっと煩雑だ。ComboBoxコントロールのプロパティを4つも指定しなければならないし、そのうちの2つはデータバインディングだ。ComboBoxコントロールが1つや2つならまだしも、10個も20個もとなったらもう少し楽をしたくなるだろう。

 ComboBoxを継承して特定の列挙型専用のカスタムコントロールを作ってしまえば(次のコード)、1つのプロパティにデータバインディングするだけで済む。

using System;
using System.Collections.Generic;
using System.Reflection;
using System.Windows.Controls;

namespace dotNetTips1207CS
{
  // SampleEnum列挙型に特化したComboBox
  public class SampleEnumComboBox : EnumComboBox<SampleEnum>
  {
    // 列挙値とその表示文字列をペアにしたDictionaryを返すメソッド
    // (このオーバーライドを書かないときは、列挙値そのものが表示文字列になる)
    protected override Dictionary<SampleEnum, string> GetDictionary()
      => (new AppCommonData()).SampleEnumNameDictionary;
  }

  // 汎用的な親クラス
  public class EnumComboBox<TEnum> : ComboBox
    where TEnum : struct, IComparable, IFormattable, IConvertible
    // 注意:where TEnum : Enumとは書けない。
    //       仕方がないのでEnumクラスの継承元を列挙して代用する。
  {
    public EnumComboBox()
    {
#if DEBUG
      // TEnumが確かにEnum型であることのチェック
      if (!typeof(TEnum).GetTypeInfo().IsEnum)
        throw new ArgumentException("TEnum must be an enumerated type");
#endif

      // 列挙値とその表示文字列をペアにしたDictionaryを得る
      Dictionary<TEnum, string> items = GetDictionary();

      // 上のDictionaryを、このComboBoxに表示する
      this.ItemsSource = items;
      this.DisplayMemberPath = "Value"; // 一覧に表示するもの(表示文字列)
      this.SelectedValuePath = "Key"; // 選択されたもの(列挙値)
    }

    // 列挙値と表示文字列のDictionaryを得るメソッド
    // この既定の実装は、列挙値そのものを表示文字列とする
    // (必要に応じて継承先で上書きする)
    protected virtual Dictionary<TEnum, string> GetDictionary()
    {
      var items = new Dictionary<TEnum, string>();
      var values = (TEnum[])Enum.GetValues(typeof(TEnum));
      foreach (var v in values)
        items.Add(v, Enum.GetName(typeof(TEnum), v));
      return items;
    }
  }
}

Imports System.Reflection

' SampleEnum列挙型に特化したComboBox
Public Class SampleEnumComboBox
  Inherits EnumComboBox(Of SampleEnum)

  ' 列挙値とその表示文字列をペアにしたDictionaryを返すメソッド
  ' (このオーバーライドを書かないときは、列挙値そのものが表示文字列になる)
  Public Overrides Function GetDictionary() As Dictionary(Of SampleEnum, String)
    Return (New AppCommonData()).SampleEnumNameDictionary
  End Function
End Class

' 汎用的な親クラス
Public Class EnumComboBox(Of TEnum As {Structure, IComparable, IFormattable, IConvertible})
  Inherits ComboBox
  ' 注意:Of TEnum As Enumとは書けない。
  '       仕方がないのでEnumクラスの継承元を列挙して代用する。

  Public Sub New()
#If DEBUG Then
    ' TEnumが確かにEnum型であることのチェック
    If (Not GetType(TEnum).GetTypeInfo().IsEnum) Then
      Throw New ArgumentException("TEnum must be an enumerated type")
    End If
#End If

    ' 列挙値とその表示文字列をペアにしたDictionaryを得る
    Dim items As Dictionary(Of TEnum, String) = GetDictionary()

    ' 上のDictionaryを、このComboBoxに表示する
    Me.ItemsSource = items
    Me.DisplayMemberPath = "Value" ' 一覧に表示するもの(表示文字列)
    Me.SelectedValuePath = "Key" ' 選択されたもの(列挙値)
  End Sub

  ' 列挙値と表示文字列のDictionaryを得るメソッド
  ' この既定の実装は、列挙値そのものを表示文字列とする
  ' (必要に応じて継承先で上書きする)
  Overridable Function GetDictionary() As Dictionary(Of TEnum, String)
    Dim items = New Dictionary(Of TEnum, String)
    Dim values = [Enum].GetValues(GetType(TEnum))
    For Each v In values
      items.Add(v, [Enum].GetName(GetType(TEnum), v))
    Next
    Return items
  End Function
End Class

特定の列挙型に特化したWPFのComboBox(上:C#、下:VB)
前掲のコードではXAMLのプロパティ設定でやっていたところを、コントロールのコンストラクタに持ってきたような構造になっている。
このカスタムコントロールは列挙型ごとに作る必要がある。そこで汎用的なクラスをジェネリクスを使って作り、それを継承することで特定の列挙型に特化したコントロールにしている。

 このカスタムコントロールを使うと、XAMLのコードは簡潔に書ける(次のコード)。

<local:SampleEnumComboBox SelectedValue="{Binding EnumValue, Mode=TwoWay}" />

カスタムコントロールを使った場合のバインディング(XAML)
前掲のXAMLコードのComboBox要素(プロパティを4つ設定していた)を、このように書き換える。
ご覧の通り1行に収まるほど簡潔になった。

UWPアプリの注意点

 UWPアプリの場合、バインディングソースの列挙型プロパティをComboBoxコントロールのSelectedValueプロパティにバインドするとうまく動かない。バインディングソースに変更があったとき、一度は正常な値がSelectedValueプロパティにセットされるのだが、なぜか続けて二度目としてnullがセットされてしまうのだ。

 この不具合を回避するには、バリューコンバーターを介してSelectedIndexプロパティにバインドするか、または、カスタムコントロール内でSelectedValueプロパティの変化をトラップして対処する。本稿ではその詳細は述べないが、C#のサンプルコードを筆者のGitHubに上げてあるので参考にしていただければ幸いである(バリューコンバーターはEnumToIndexConverterクラスに、カスタムコントロールはEnumComboBoxクラスに実装してある)。

まとめ

 ListViewコントロールやComboBoxコントロールなどは、一般に2つのデータをバインドすることになる。すなわち、一覧表示するデータはItemsSourceプロパティに、エンドユーザーが選択した値はSelectedValueプロパティにバインドする。XAMLの記述が煩雑になるので、同じ選択肢を繰り返し記述する場合は本稿に示したようにカスタムコントロールを作ってしまうのも一案だ。

 なおUWPでは、ComboBoxコントロールに列挙型をバインドするときに工夫が必要になる。

「.NET TIPS」のインデックス

.NET TIPS

Copyright© 1999-2017 Digital Advantage Corp. All Rights Reserved.

@IT Special

- PR -

TechTargetジャパン

この記事に関連するホワイトペーパー

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。