65.9K
CodeProject 正在变化。 阅读更多。
Home

发现 WPF IValueConverter 的局限性

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.65/5 (10投票s)

2011年12月14日

CPOL

13分钟阅读

viewsIcon

31223

downloadIcon

252

发现 WPF IValueConverter 的局限性。

引言

我尝试以不支持的方式使用值转换器。尽管该解决方案在无需在ViewModel中硬编码视图以了解枚举详情的情况下,支持绑定到ViewModel的单个属性以进行RadioButton选择方面做得并非完美,但在大多数情况下有效,并且充分展示了IValueConverter接口的局限性。我在此项目上的工作让我深入了解了值转换器的许多局限性,并创造了一些可能有用的东西。其中一些经验应该对其他开发者有所帮助。

历史

我通过使用值转换器创建了一种处理ComboBox枚举的方式,该方式使用了ViewModel中相同的属性来处理ItemsSourceSelectedItem,效果相当不错。我只需要该属性(枚举)来更改值,而无需为选项列表设置另一个属性。ComboBox工作得很好,但对于相同的功能,一个更友好的UI是使用单选按钮。选择单选按钮的原因是它能让用户更快地选择选项,并且选项一目了然。缺点是单选按钮通常会占用更多空间,而且可以。

当单选按钮通常在WPF视图中使用时,必须定义每个RadioButton,包括标题;这可能会导致维护噩梦。当单选按钮代表枚举选项时,将选择与正确的枚举关联也存在问题,因此可以避免编程正确选项时的一些麻烦。

ComboBox更容易处理,因为结果是一个易于与枚举关联的单一值。为代表枚举的ComboBox创建值转换器也更容易,并且枚举的DisplayAttribute可用于ComboBox内的文本显示(请参阅我的文章使用DescriptionAttribute绑定枚举到ComboBox)。

我希望创造一种更好的方式来让用户选择选项,并且在成功处理ComboBox值后,我决定看看是否有好的方法来构建一个包含RadioButton控件的ListBox的值转换器。我想通过仅使用可以在窗体上定义的WPF基本控件来实现,而不是使用更复杂的ControlTemplate。设计将围绕值转换器构建。然而,微软实现值转换器的方式基本上破坏了我创建由值转换器驱动的、由枚举驱动的通用单选按钮控件的努力。不过,该实现可以用于大多数单选按钮的使用场景,只是需要特别注意。

实现

该控件的XAML相当直接,使用了一个带有RadioButton DataTemplateListBox。但是有一个陷阱。

<ListBox ItemsSource="{Binding SampleEnum,
            Converter={StaticResource EnumDrivenCheckBoxConverter}}"
         SelectedItem="{Binding SampleEnum,
            Converter={StaticResource EnumDrivenCheckBoxConverter},Mode=TwoWay}"
         BorderThickness="0">
  <ListBox.ItemsPanel>
    <ItemsPanelTemplate>
      <StackPanel Orientation="Vertical"/>
    </ItemsPanelTemplate>
  </ListBox.ItemsPanel>
  <ListBox.Resources>
    <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}"
                     Color="#00000000" />
  </ListBox.Resources>
  <ListBox.ItemTemplate>
    <DataTemplate>
      <Grid>
        <RadioButton IsChecked="{Binding IsChecked}" Content="{Binding Text}" />
        <Border Background="#01000000" HorizontalAlignment="Stretch"
          VerticalAlignment="Stretch"/>
      </Grid>
    </DataTemplate>
  </ListBox.ItemTemplate>
</ListBox>

一个很大的陷阱是,DataTemplate中的CheckBox必须被某些东西覆盖,以防止CheckBox的操作干扰ListBoxItem的选择。没有这个覆盖(我使用了一个带有几乎透明Background的边框),就无法选择ListBoxItem;当尝试选择一个项目时,CheckBox会被选中/取消选中。这不是期望的功能。

另一个陷阱是选中的ListBoxItem的背景。我希望它表现得像一组单选按钮,而不是一个包含单选按钮的ListBoxRadioButton的选择是识别哪个RadioButton被选中的唯一因素,而ListBoxItem的高亮是不必要的。

我在这里的代码中添加了ItemsPanel,主要是为了展示如果标准的ListBox不合适的话该如何做。我还去掉了ListBox的边框,因为我希望它看起来像一组单选按钮,而不是一个包含单选按钮的列表。

现在,将其转换为一个Style(不包括ItemsPanel),我们得到以下内容:

<Style  x:Key="CheckBoxListBox" TargetType="ListBox">
  <Style.Resources>
    <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}"
      Color="#00000000" />
    <DataTemplate x:Key="CheckBoxItem">
      <Grid>
        <RadioButton IsChecked="{Binding IsChecked}" Content="{Binding Text}" />
        <Border Background="#01000000" HorizontalAlignment="
          Stretch" VerticalAlignment="Stretch"/>
      </Grid>
    </DataTemplate>
  </Style.Resources>
  <Setter Property="ItemsSource" Value="{Binding Path=DataContext,
    RelativeSource={RelativeSource Self},
    Converter={StaticResource EnumDrivenCheckBoxConverter}}"/>
  <Setter Property="SelectedItem" Value="{Binding Path=DataContext,
    RelativeSource={RelativeSource Self},
    Converter={StaticResource EnumDrivenCheckBoxConverter}, Mode=TwoWay}"/>
  <Setter Property="ListBox.ItemTemplate" Value="{StaticResource CheckBoxItem}"/>
  <Setter Property="BorderThickness" Value="0"/>
</Style>

实际上很简单,因为不需要ControlTemplate。在Style中包含一个Resources元素是不需要ControlTemplate的原因之一,尽管也可以将Resources定义在Style之外,但除非我认为我会在多个地方使用这些资源,否则我不喜欢这样做。

在这种情况下使用Style的一个非常好的优点是,不仅在使用ListBox时无需定义DataTemplate,而且Binding也大大简化了。

<ListBox Style="{StaticResource CheckBoxListBox}"
    DataContext="{Binding SampleEnum, Mode=TwoWay}"/>

在使用此ListBox样式时,有一件非常重要的事情:为DataContext定义的Binding必须是TwoWay。如果不是,无论StyleListBoxSelectedItem的绑定模式如何,绑定的值都不会被更新。

由于这是一个相对简单的实现,几乎所有用普通ListBox可以做的事情,都可以用这个ListBox通过标准的XAML来完成。

在代码中,我还为ComboBox创建了一个与ListBox非常相似的样式,并且只需更改ListBoxComboBox以及样式名称,而无需其他更改,XAML如下:

<ComboBox Style="{StaticResource CheckBoxComboBox}"
    DataContext="{Binding SampleEnum, Mode=TwoWay}"/>

XAML部分是比较容易的部分——我从一开始就有了使用带有单选按钮的ListBox的想法。其中一个绑定也很明显;ItemsSource必须实现。为了绑定到ListBox中的单选按钮,我需要同时绑定到ContentIsChecked属性。这意味着我必须为ItemsSource绑定创建一个新类,该类由Convert方法中传递的值生成。由于我希望在显示每个CheckBox的文本时有很大的灵活性,我设计了转换器,以便在可用时使用特定枚举的DisplayAttribute,否则只使用枚举名称。

var list = new List<EnumDrivenRadioButtonBinding>();
  
foreach (var value in Enum.GetValues(e))
{
    FieldInfo info = value.GetType().GetField(value.ToString());
    var valueDescription = (DescriptionAttribute[])info.GetCustomAttributes
              (typeof(DescriptionAttribute), false);
    list.Add(new EnumDrivenRadioButtonBinding(value,
         valueDescription.Length == 1 ?
            valueDescription[0].Description : value.ToString()));
}

每个枚举值的类如下:

    public class EnumDrivenRadioButtonBinding : INotifyPropertyChanged
    {
    public string Text { get; private set; }
    public bool IsChecked { get; set; }
    public object Enumeration { get; private set; }

    internal EnumDrivenRadioButtonBinding(object value, string description)
    {
      Text = description;
      Enumeration = value;
    }

    internal void UpdateIsChecked (bool value)
    {
      if (value != IsChecked)
      {
        IsChecked = value;
        if (PropertyChanged != null)
          PropertyChanged (this, new PropertyChangedEventArgs("IsChecked"));
      }
    }

    public event PropertyChangedEventHandler PropertyChanged;
}

请注意,它继承自INotifyPropertyChanged,这样IsChecked属性中的更改就会传播到视图。我有一个构造函数,它设置要显示的文本和实际的枚举值,这样它们就可以是只读的,从而受到保护。由于IsChecked属性由UpdateIsChecked方法更新,因此无需使用后备字段。基本上,当绑定的值更新时,它会被重新读取,然后视图设置的值会在绑定的ViewModel值被读回时再次设置。这就是先前设置的值被重置的方式。

我最初的尝试是将所有状态处理都放在EnumDrivenRadioButtonBinding类中,但是即使通过回调,也没有好的方法来更新ViewModel中绑定的值。我唯一能找到的更新ViewModel值的方法是绑定到ListBoxSelectedItemSelectedIndex。我选择了SelectedItem。因为ItemsSourceSelectedItem都需要访问EnumDrivenRadioButtonBinding实例的同一个集合,所以必须使用同一个值转换器。这很容易解决,因为只需要检查Convert方法的targetType参数是否为IEnumerable。如果是,则返回列表(这将用于ItemsSource),否则会更新列表中的项目,以便只有其中一个枚举的类实例的IsChecked属性设置为trueconvert方法如下:

public object Convert(object value, Type targetType, object parameter,
            System.Globalization.CultureInfo culture)
{
  if (value == null || !value.GetType().IsEnum)
    return value;
  if (!_localLists.ContainsKey(value.GetType()))
    CreateList(value.GetType());

  if (targetType.Name == "IEnumerable") //ItemsSource
  {
    return _localLists[value.GetType()];
  }
  else //SelectedItem
  {
    foreach (var item in _localLists[value.GetType()])
      item.UpdateIsChecked(item.Enumeration.Equals(value));
    //this is irrelevant
    return null;
  }
}

您会注意到,实际使用的列表存储在一个字典中,该字典的键是值参数的类型。值转换器的问题在于,所有使用该转换器的实例都使用同一个值转换器。如上面的代码所示,我需要一个用于绑定ItemsSource的列表,该列表也是代码用于设置单选按钮的数据。

如果有两组这样的单选按钮ListBox,那么它们都使用同一个转换器实例。这意味着它们会互相干扰。此代码中解决此问题的方法是使用以枚举类型为键的字典。在示例中,当单击主窗口上的“Launch Basic Example”按钮时,我会打开两个“Value Converter Interference of Two Controls”窗口的实例。这表明问题不会发生在两个窗口之间。

我还将RadioButton ListBoxComboBox封装在一个UserControl中,并将此UserControl的两个实例放在一个窗口中。这两个UserControl完全独立,尽管每个UserControl内部仍然存在问题。单击主窗口上的“LaunchUserControl Example”按钮即可看到此示例。将RadioButton ListBox控件放在另一个ListBoxDataTemplate中,表明问题仍然存在,而当DataTemplate包含UserControl(其中包含CheckBox ListBox)时,问题消失了。也可以通过单击示例主窗口上的按钮来查看这些示例。

在大多数情况下,干扰不是问题,但我可以轻易看到多个是/否RadioButton选择时可能出现的问题。现在,如果我不尝试在Style中定义ListBox并使用此style来处理所有绑定,那么我就可以使用ConverterParameter来区分不同的控件。然而,如果这个ListBox控件位于DataTemplate中,这仍然可能出现问题,但这表明对于许多情况,该实现将正常工作。

学到的教训

这项工作揭示了微软使用的IValueConverter实现的弱点。

第一个问题是IValueConverter类的实例会被重用于窗体上所有使用该转换器的控件。在简单情况下,这没问题,但在有状态信息的情况下,它会使解决方案复杂化,甚至使使用IValueConverter实现解决方案变得不可能。我以前通过使用有关正在绑定的Type的信息来成功地绕过这个问题,只要它足够,并且只要这是唯一的依赖项,换句话说,如果行为仅取决于类型,而不是类型的实例,那么它就能工作。

我尝试了许多技巧来克服这个问题,但无论我怎么做,在窗体内部都使用了相同的转换器实例。这包括将转换器的定义移到样式内部。

第二个窗口中不使用同一个值转换器实例。因此,设计者无需担心它。

该示例很好地展示了使用值转换器时出现的问题。

  • 对不同控件使用相同的资源键仍然会使用相同的值转换器实例(不同的键将是不同的值转换器实例)。
  • 有两个相同的窗口,这表明问题不会出现在窗口之间。
  • UserControl也有自己的值转换器实例。

我定义了一个ComboBox和一个ListBox来使用同一个值转换器。在示例中,唯一的区别是控件的名称。这表明用代码在两者之间进行转换很容易,这很好,因为有时很难推广单选按钮,尽管用户倾向于更喜欢它们。这种方式可以为ComboBox编写代码,然后只需更改一个词,就可以切换到RadioButton

还有很多注释掉的代码和XAML,允许您尝试各种选项来查看值转换器的行为。

修复问题的选项

我提出的一个立即解决问题的想法是使用多值转换器。想法是将其中一个值绑定到枚举,另一个绑定到控件本身。对于Convert来说,这很好,因为所有值都可用,但对于ConvertBack来说,只有单个值可用。事实上,几乎每次使用IMultValueConveter时,您都不会编写ConvertBack方法,而是会抛出异常。

使用参数参数是另一种选择,但参数参数不是DependencyProperty,因此这个值没有动态性。有很多关于人们希望以动态方式使用参数参数但对此限制感到沮丧的帖子。有时可以使用IMultiValueConverter来绕过参数参数的这种限制。

另一个选项是将同一个值转换器定义为具有不同的键。这可行,但实际上比使用参数好不了多少,因为我必须将SelectedItemItemsSource的特定绑定移出样式。

我还研究了向值转换器添加DependencyProperty。这是另一种很好的方法来绕过参数参数的限制,但对于当前的需求,似乎没有好的方法来从控件中获取值转换器的引用;又一道障碍。

我还尝试继承自MarkupExtension,但这遇到了与值转换器完全相同的问题,这实际上并不令人惊讶。

UserControl可以包含RadioButton ListBox,但我认为这不是一个令人满意的解决方案,并且我更愿意在代码中创建RadioButton ListBox

结论

值转换器(如实现的那样),其中特定的绑定在ListBox样式中定义,只要窗体中没有两个控件绑定到相同枚举类型的不同值,就可以正常工作。另一种解决方案是将ItemsSourceSelectedItem的绑定定义移出样式,并使用ConverterParameter来确保唯一性(或为值转换器使用不同的键)。如果ListBox用在DataTemplate中,这仍然会有问题,并且会消除将绑定复杂性隐藏在样式中的优雅性。

如果微软将参数参数设为DependencyProperty,那将会奏效。然后,可以将对使用值转换器的控件的引用传递给ConvertConvertBack方法。在我看来,微软应该修复参数不是DependencyPropety的问题,因为它确实限制了IValueConverter的可用性。通常事件都有一个发送者参数,而IValueConverter的方法没有。在事件中有一个发送者参数已被证明非常有用,并且我认为很少有人支持从事件中删除这个参数。如果值转换器有一个发送者参数,那么这个问题将会更容易解决,并且可以解决微软社区中使用值转换器的一些其他问题。

我本不会发表这篇文章,而是会创建一个完美的实现,但我认为这种方法的失败中有值得吸取的教训,而且该示例很好地展示了IValueConverter的行为和局限性;一个普遍适用的RadioButton ListBox无法像我希望的那样使用值转换器。它也有趣之处在于,我绑定到了ListBoxDataContext,然后在样式中使用了这个绑定。

历史

  • 2011年12月13日:初始版本
© . All rights reserved.