WPF: Bindowanie Enum'a do ComboBox'a

31.05.2011

Podczas moich ostatnich zmagań z WPFem trafiłem na ciekawy problem. Chodzi o bindowanie typu Enum do kontrolki ComboBox. Czyli w combo ma pojawiać się lista dostępnych wartości enum'a, a po wybraniu jednej z nich ma uaktualnić się podpięta do comboboxa właściowość ViewModelu. Dodatkowo sprawa komplikuje się gdy nazwy poszczególnych wartości enum'a są niezbyt czytelne dla śmiertelnego użytkownika programu (np. zamiast ładnej opisowej nazwy "To jest pierwsza specjalna opcja programu", "To jest druga specjalna opcja programu" są wartości "Foo" i "Bar"). Pseudokod:

<ComboBox DataContext={Binding FooViewModel} ItemsSource={Binding ...} SelectedItem="{Binding SelectedFoo}/>

// ViewModel bez INotifyPropertyChange żeby nie zaciemniać
public class FooViewModel
{
    public EnumFooType SelectedFoo
    {
        get;
        set;
    }
}

public enum EnumFooType
{
    Foo = 0,
    Bar = 1
}

Tylko co wstawić w miejsce "..."? Pierwszy odruch, prawie bezwarunkowy (nad czym ubolewam, bo to symptom postępującego odmóżdżenia) to przeszukanie google.com i stackoverflow.com. Wynik? Znalazłem takie rozwiązanie:

http://www.ageektrapped.com/blog/the-missing-net-7-displaying-enums-in-wpf/

Wiedziałem, że problem tego typu traktuje się konwerterem przy bindowaniu właściwości modelu, ale mając w ręku gotowe rozwiązanie (teoretycznie) nie chciałem tracić czasu na własną implementację. Niestety przy pierwszym uruchomieniu kod wysypał się błędem o niezgodności typów i chcąc nie chcąc został zmuszony, żeby coś z tym fantem zrobić. Po bliższych oględzinach doszedłem do wniosku, że można by to zrobić trochę inaczej (prościej):

[AttributeUsage(AttributeTargets.Field)]
public sealed class DisplayStringAttribute : Attribute
{
    private readonly string _text;
    public string Text
    {
        get { return _text; }
    }

    public string ResourceKey { get; set; }

    public Type ResourceType { get; set; }

    public DisplayStringAttribute(string text)
    {
        _text = text;
    }

    public DisplayStringAttribute()
    {
    }
}

Tak wygląda "opisany" enum:

public enum LanguageSkill
{
    [DisplayString(ResourceType = typeof(AppStrings), ResourceKey = "Podstawowa")]
    Basic = 0,
    [DisplayString(ResourceType = typeof(AppStrings), ResourceKey = "Komunikatywna")]
    Communicative = 1,
    [DisplayString(ResourceType = typeof(AppStrings), ResourceKey = "Dobra")]
    Good = 2,
    // można też stosować zwykły tekst
    [DisplayString("bardzo dobra")]
    VeryGood = 3
}

Poprawiony kod konwertera:

public class EnumDisplayer : IValueConverter
{
    private IDictionary<object, string> _displayValues = new Dictionary<object, string>();
    private IDictionary<string, object> _reverseValues = new Dictionary<string, object>();
    private Type _type;

    private void FillDictionaries(Type type)
    {
        var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static).Where(x => x.GetCustomAttributes(typeof(DisplayStringAttribute), false).Any());
        _displayValues = fields.ToDictionary(
            x => x.GetValue(null),
            x => GetDisplayName(x.GetCustomAttributes(typeof(DisplayStringAttribute), false).First()));
        _reverseValues = _displayValues.Keys.ToDictionary(x => _displayValues[x], x => x);
    }

    private string GetDisplayName(object displayAttribute)
    {
        var attribute = displayAttribute as DisplayStringAttribute;

        if(string.IsNullOrEmpty(attribute.Text) &&
            (attribute.ResourceType == null || string.IsNullOrEmpty(attribute.ResourceKey)))
        {
            throw new ArgumentNullException("Text and ResourceType/ResourceKey properties are null/empty");
        }

        if (!string.IsNullOrEmpty(attribute.Text))
        {
            return attribute.Text;
        }

        return new ResourceManager(attribute.ResourceType).GetString(attribute.ResourceKey);
    }

    public Type Type
    {
        get { return _type; }
        set
        {
            if (!value.IsEnum)
                throw new ArgumentException("parameter is not an Enumermated type", "value");
            _type = value;
            FillDictionaries(_type);
        }
    }

    public string[] DisplayNames
    {
        get
        {
            if (Type == null)
            {
                return new string[0];
            }

            return _displayValues.Values.ToArray();
        }
    }

    #region IValueConverter Members

    object IValueConverter.Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if(!_displayValues.ContainsKey(value))
        {
            throw new KeyNotFoundException(string.Format("unknown display name for value {0}", value));
        }

        return _displayValues[value];
    }

    object IValueConverter.ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (!_reverseValues.ContainsKey((string) value))
        {
            throw new KeyNotFoundException(string.Format("unknown value for display name {0}", value));
        }

        return _reverseValues[(string)value];
    }

    #endregion
}

I przykład zastosowania:

<cnv:EnumDisplayer Type="{x:Type Model:LanguageSkill}" x:Key="languageSkills" />

<ComboBox ItemsSource="{Binding Source={StaticResource languageSkills}, Path=DisplayNames}" SelectedItem="{Binding Guardian.GermanLanguageSkill, Converter={StaticResource languageSkills}}"></ComboBox>

Wyrzuciłem z oryginalnego rozwiązania możliwość definiowania tekstowych odpowiedników wartości enum'a w XAMLu. Nie jest to moim zdaniem dobry pomysł. W przypadku wprowadzania zmian (dodawania/usuwania wartości enum'a) trzeba dokonywać zmian w dwóch (definicja enum'a i konwertera w XAMLu) zamiast w jednym pliku (tylko definicja enum'a).