数据转换

  在基本绑定中,信息在从源到目标的传递过程中没有任何变化。这看起来是符合逻辑的,但我们并不总是希望出现这种行为。通常,数据源使用的是低级表达方式,我们可能不希望直接在用户界面中使用这种低级表达方式。例如,可能希望使用更便于读取的字符串来代替数字编码,数字需要被削减到合适的尺寸,日期需要使用长格式显示等。如果是这样的话,就需要有一种方法将这些数值转换为恰当的显示形式。并且如果正在使用双向绑定,还需要进行反向转换–获取用户提供的数据并将它们转换到适于在恰当的数据对象中保存的表示形式。
  幸运的是,WPF提供了两个工具,可提供帮助:

  • 字符串格式化。使用该功能个通过设置Binding.StringFormat属性对文本形式的数据进行转换——例如包含日期和数值的字符串。对于至少一般的格式化任务,字符串格式化是一种便捷的技术
  • 值转换器。该功能更强大(有时更复杂),使用该功能可将任意类型的源数据转换为任意类型的对象表示,然后个传递到关联的控件。

使用StringFormat属性

  为格式化需要显示的文本的数字,字符串格式化堪称完美工具。当设置Binding.StringFormat属性时,使用标准的.NET格式化字符串。

显示货币格式,0代表第一个数值,C应用希望应用的格式字符串——对于这个示例,它是标准的本地专用的货币格式

1
<TextBlock Text="{Binding UnitCost, StringFormat={}{0:C}}" />

  StringFormat属性的值以花括号{}开头。完整值是{}{0:C}而不是*{0:C}*,为了转义字符串,需要在开始初使用稍微笨拙的花括号对。否则,XAML解析器可能会被{0:C}开头的花括号说迷惑

  只有当StringFormat值以花括号开头时才需要{}转义序列。例如下面的示例,在格式化数值之前添加了一个字面文本序列,因此不需要添加{}开头进行转义。

1
<TextBlock Text="{Binding UnitCost, StringFormat=The value is {0:C}}" />

  这个表达式将值(如 999.99)转换成“The value is $999.99.”。因为 StringFormat属性值中的第个字符是普通字母,不是括号,所以在开头不需要初始化转义序列。 然而,**这个格式字符串只能在一个方向上工作。**如果用户试图提供包含该字面文本的编辑过的值(如“The value is $4.25.”),更新会失败。而另一方面,如果用户为只有数字字符(4.25)或具有数字字符和货币符号($4.25)的数值执行编辑,编辑就会成功,绑定表达式将其转换为显示文本“Thevalueis$4.25.”,并在文本框中显示该文本。

数值数据的格式字符串
类型 格式字符串 示例
货币 C $999.99,货币符号特定于区域
科学计数法(指数) E 1234.50E+004
百分数 P 45.6%
固定小数 F? 取决于设置的小数位数,F3格式化数值类似与123.400,F0格式化数值类似于123
时间和日期数据的格式字符串
类型 格式字符串 格式
短日期 d M/d/yyy 例如09/08/2024
长日期 D dddd,MMMM,dd,yyyy 例如:Staurday,August 03,2024
长日期和短时间 f dddd,MMMM,dd,yyyy HH:mm aa 例如:Staurday,August 03,2024 10:17 AM
长日期和长时间 f dddd,MMMM,dd,yyyy HH:mm:ss aa 例如:Staurday,August 03,2024 10:17:14 AM
ISO Sortable标准 s yyyy-MM-dd HH:mm:ss 例如:2024-08-03 10:17:14
月和日 M MMMM dd 例如:August 03
通用格式 G M/d/yyyy HH:mm:ss aa(取决于特定区域设置) 例如:03/08/2024 10:17:14 AM

  WPF列表控件还支持针对列表项的字符串格式化。为使用这种格式化,只需要设置列表的ItemStringFormat属性(该属性继承自ItemsControl基类)

1
<ListBox x:Name="lstProducts" DisplayMemberPath="UnitCode" ItemStringFormat="{0:C}">

值转换器

  Binding.StringFormat属性时针对简单的、标准的格式化数字和日期而创建的。当许多数据绑定需要更强大的工具,称为**值转换器(value converter)**类。

  值转换器的作用显而易见。它负责在目标中显示数据之前转换源数据,并且(对于双向绑定)在将数据应用回源之前转换新的目标值。值转换器是WPF数据绑定难题中非常有用的一部分。个通过如下几种有用的方式使用它们:

  • 将数据格式化为字符串表示形式。你嘞,个将数字转换成货币字符串。这是值转换器最明显的用途,但当然不是唯一用途
  • 创建特定类型的WPF对象。例如,个读取一块二进制数据,并创建一幅能绑定到Image元素的BitmappImage对象
  • 根据绑定数据有条件地改变元素中的属性。例如,个创建值转换器,用于改版了元素的背景色以突出显示位于特定范围内的数值

值转换器设置字符串的格式

  以下示例依然分析格式化货币的示例,尽管使用Binding.StringFormat属性个更简单的完成相同的工作。

  为创建值转换器,需要执行以下步骤:

  • 创建一个实现了IValueConverter接口的类。
  • 为该类声明添加ValueConversion特性,并制定目标数据类型。
  • 实现Convert()方法,该方法将数据成原来的格式转换为显示格式。
  • 实现ConvertBack()方法,该方法执行反向变换,将值从显示格式转换为原格式。
值转换器的工作原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[ValueConversion(typeof(decimal), typeof(string))]
public class PriceConverter : MarkupExtension, IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
decimal price = (decimal)value;
return price.ToString("C", culture);
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
string price = value.ToString();
decimal result;
if (decimal.TryParse(price, NumberStyles.Any, culture, out result))
{
return result;
}

return value;
}

public override object ProvideValue(IServiceProvider serviceProvider)
{
// 通过Markup语法可以减少代码的书写,还可以通过传统定义转换器资源的方式或定义静态属性通过x:Static绑定使用转换器
return this;
}
}
1
2
3
 <Grid>
<TextBox Text="{Binding UnitCost, Converter={cvt:PriceConverter}}" />
</Grid>

值转换器创建对象

  如果需要填平数据在自定义类中存储的方式和在窗口中显示的方式之间的鸿沟,值转换器是必不可少的。例如,设想具有在数据库的字段中存储为字节数组的图片数据。可将二进制数据转换为 System.Windows.Media.Imaging.BitmapImage 对象,并将该对象存储为自定义数据对象的一部分。然而,这种设计可能不合适。

  要将一块二进制数据转换成一幅图像,首先必须创建 BitmapImage 对象,并将图像数据读入到 MemoryStream 对象中。然后,可调用 Bitmaplmage.BeginInit()方法来设置 Bitmaplmage 对象的StreamSource 属性,使其指向 MemoryStream 对象,并调用 EndInit( )方法来完成图像的加载。

以下示例采用简单的图片路径转BitmapImage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[ValueConversion(typeof(string), typeof(BitmapImage))]
public class ImagePathConverter : MarkupExtension, IValueConverter
{
private string _imageDirectory = Directory.GetCurrentDirectory();

public string ImageDirectory
{
get => _imageDirectory;
set => _imageDirectory = value;
}

public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
string imagePath = Path.Combine(ImageDirectory, (string)value);
return new BitmapImage(new Uri(imagePath));
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}

public override object ProvideValue(IServiceProvider serviceProvider)
{
return this;
}
}

应用条件格式化

  有些有趣的值转换器不是为了显示而格式化数据,而是旨在根据数据规则格式化元素中与外观相关的其他方面。

例如,设想希望通过不同的背景色标志那些价格高昂的产品项。使用下面的值转换器就能方便地封装这一逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[ValueConversion(typeof(decimal), typeof(Brush))]
public class PriceToBackgroundConverter : MarkupExtension, IValueConverter
{
public decimal MinimumPriceToHeight { get; set; }
public Brush HighlightBrush { get; set; }
public Brush DefaultBrush { get; set; }

public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
decimal price = (decimal)value;
if (price >= MinimumPriceToHeight)
return HighlightBrush;
else
return DefaultBrush;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}

public override object ProvideValue(IServiceProvider serviceProvider)
{
return this;
}
}
1
2
3
4
<Border
Background="{Binding UnitCost, Converter={cvt:PriceToBackgroundConverter DefaultBrush={x:Null}, HighlightBrush=Orange, MinimumPriceToHeight=50}}"
Height="100"
Width="100" />

评估多个属性

  到目前为止,已经使用绑定表达式将一部分源数据转换成单个格式化的结果。尽管不能修改等式的另一部分(结果),但是只需要很少的技巧,就可以创建能够评估或结合多个源属性信息的绑定。

  第一个技巧是用 MultiBinding 对象代替 Binding,对象。然后使用 MultiBinding.StringFormat 属性定义绑定属性的排列。下面是一个示例,该例将 Joe 和 Smith 转换为“Smith,Joe”,并在 TextBlock元素中显示结果:

1
2
3
4
5
6
7
8
<TextBlock>
<TextBlock.Text>
<MultiBinding StringFormat="{1}, {0}">
<Binding Path="FirstName" />
<Binding Path="LastName" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>

  您可能注意到,这个示例在 StingFormat 属性中使用了两个字段。同样,可使用格式字符串进行修改。例如,如果使用 MultiBinding 结合文本值和货币值,可将 StingFormat 属性设置为“{0} costs {l:C}。”

  如果希望使用两个源字段完成更富有挑战性的工作,而不只是将它们缝合到一起,需要借助于值转换器。可使用这种方法执行计算(例如,将UnitPrice 乘以 UnitsInStock),或同时使用多个细节进行格式化(例如,突出显示特定目录中的所有高价位产品)。然而对于这种情况,值转换器必须实现 IMultiValueConverter接口,而不是实现IValueConverter 接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[ValueConversion(typeof(decimal),typeof(string))]
public class ValueInStockConverter : MarkupExtension, IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
decimal unitCost = (decimal)values[0];
int unitsInStock = (int)values[1];

return (unitCost * unitsInStock).ToString("C",culture);
}

public object[] ConvertBack(
object value,
Type[] targetTypes,
object parameter,
CultureInfo culture
)
{
throw new NotImplementedException();
}

public override object ProvideValue(IServiceProvider serviceProvider)
{
return this;
}
}
1
2
3
4
5
6
7
8
<TextBlock>
<TextBlock.Text>
<MultiBinding Converter="{cvt:ValueInStockConverter}">
<Binding Path="UnitCost" />
<Binding Path="UnitsInStock" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>

列表控件

  ItemsControl类为封装项列表中的控件定义了基本功能。这些项可以是列表中的项、树中的节点、菜单中的命令、工具栏中的按钮等,下图将要显示了WPF中所有ItemsConrol类的基本情况。

继承自ItemsConrol的类

  ItemsControl类定义了支持数据绑定以及两个重要格式化特性(样式和数据模板)的属性,下表简要总结了支持着两个特性的属性:

ItemsControl类中与格式化相关的属性
名称 说明
ItemsSource 绑定数据源(希望在列表中显示的集合或DataView对象)
DisplayMemberPath 希望为每个数据项显示的属性。对于更复杂的表达形式或者为了使用多个属性的组合而言,应改用ItemTemplate属性
ItemStringFormat 该属性时一个.NET格式字符串,如果设置了该属性,将使用该属性为每个项格式化文本。通常,这种技术用于将数字或日期值转换成合适的显示形式
ItemContainerStyle 该属性时一个样式,通过该样式可以设置封装每个项的容器的多个属性。容器取决于列表类型(例如,对于ListBox类是ListBoxItem,对于ComboBox类是ComboBoxItem)。当填充列表时,自动创建这些封装器对象
ItemContainerStyleSelector 使用代码为列表中每项的封装器选择样式的StyleSelector对象。可以使用该属性为列表中的不同项提供不同的样式。必须创建自定义的StyleSelector类
AlternationCount 在数据中设置的交替集合数量。例如,将AlternationCount设置为2,将在两个不同的行样式之间交替。如果将AlternationCount设置为3,将在3个不同的行样式之间交替
ItemTemplate 此类模板从绑定的对象提取合适的数据,并将提取的数据安排到合适的控件组合中
ItemTemplateSelector 使用代码为列表中的每个项选择模板的DataTemplateSelector对象。可以通过这个属性为不同的项应用不同的模板。必须创建自定义的DataTemplateSelector类
ItemsPanel 定义用于包含列表中项的面板。所有项封装器都添加到这个容器中。通常,使用垂直方向(自上而下)的VirtualizingStackPanel面板
GroupStyle 如果正在使用分组,这个样式定义了应当如何格式化每个分组。当使用分组时,项封装器(ListBoxItemHeComboBoxItem等)被添加到表示每个分组的GroupItem封装器中,然后这些分组被添加到列表中
GroupStyleSelector 使用代码为每个分组选择样式的StyleSelector对象。可以通过这个属性为不同的分组使用不同的样式。必须创建自定义的StyleSelector类

  ItemsControl类继承层次中的下一层是 Selector 类,该类为确定并设置选择项添加了一套简单的属性。并不是所有的ItemsControls类都支持选择。例如,对于ToolBar和 Menu 控件,选择就没有任何意义,所以这些类继承自ItemsControl类,而不是继承自Selector类。

  Selector 类添加的属性包括 SelectedItem(选中的数据对象)、SelectedIndex(选中项的位置)以及 SelectedValue(所选数据对象的 value 属性,它是通过设置 SelectedValuePath 属性指定的)。注意,Selector类不支持多项选择–ListBox控件通过它的SelectionMode和 SelectedItems属性(本质上这是 ListBox 类为这个模型添加的所有内容)添加了这一支持。

列表样式

  本章将集中介绍WPF列表控件提供的两个特性:样式和数据模板。在这两个工具中,样式更简单一些(当功能逊色一些)。在许多情况下,个通过它们添加精彩的格式。

ItemContainerStyle

  因为WPF的数据绑定系统自动生成列表项对象。因此,为单个项应用您所希望的格式不是很容易。解决方案是ItemContainerStyle属性。如果设置了ItemContainerStyle属性,当创建列表项时,列表控件会将其向下传递给每个项。对于ListBox控件,每个项有ListBoxItem对象表示;对于ComboBox控件,每个项由ComboBoxItem对象表示,等等。因此,通过ListBox.ItemContainerStyle属性应用的样式都将用于设置每个ListBoxItem对象的属性。

下面的示例,为每项应用蓝灰色背景。为了确保单个项和其他项能够相互区别,样式还添加了一些外边距空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<ListBox Margin="5">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="Background" Value="LightSteelBlue" />
<Setter Property="Margin" Value="5" />
<Setter Property="Padding" Value="5" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="DarkRed" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderBrush" Value="Black" />
<Setter Property="BorderThickness" Value="1" />
</Trigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
<sys:String>🍎</sys:String>
<sys:String>🍌</sys:String>
<sys:String>🍐</sys:String>
</ListBox>

包含子控件的ListBox控件

  如果希望深入到列表控件并修改项使用的控件模板,ItemContainerStyle属性同样十分重要例如,可使用这种技术,让每个 ListBoxItem 对象在项文本的旁边显示单选按钮或复选框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<ListBox Margin="5">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="Background" Value="LightSteelBlue" />
<Setter Property="Margin" Value="5" />
<Setter Property="Padding" Value="5" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<RadioButton Focusable="False" IsChecked="{Binding IsSelected, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<ContentPresenter VerticalAlignment="Center" />
</RadioButton>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="DarkRed" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderBrush" Value="Black" />
<Setter Property="BorderThickness" Value="1" />
</Trigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
<sys:String>🍎</sys:String>
<sys:String>🍌</sys:String>
<sys:String>🍐</sys:String>
</ListBox>

下面的样式规则将普通的ListBox转换成复选框列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<ListBox Margin="5" SelectionMode="Multiple">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="Background" Value="LightSteelBlue" />
<Setter Property="Margin" Value="5" />
<Setter Property="Padding" Value="5" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<CheckBox Focusable="False" IsChecked="{Binding IsSelected, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<ContentPresenter VerticalAlignment="Center" />
</CheckBox>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="DarkRed" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderBrush" Value="Black" />
<Setter Property="BorderThickness" Value="1" />
</Trigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
<sys:String>🍎</sys:String>
<sys:String>🍌</sys:String>
<sys:String>🍐</sys:String>
</ListBox>

可通过设置ListBox.SelectionMode = Multiple启用列表的多选

交替条目样式

  格式化列表的一种常用方式是使用交替格式化——换句话说,用于区分列表中每两项的一套格式特征。交替行通常是通过稍微不同的背景色提供的,从而清晰地隔离行。

  WPF通过两个属性为交替项提供了内置支持:AlternationCountAlternationIndex AlternationCount制定序列中项的数量,经过该数量的项之后交替样式。默认情况下,AlternationCount被设置为0,而且不使用交替格式。如果将AlternationCount设置为1,列表将在每项之后交替。

  为每个ListBoxItem提供AlternationIndex属性,该属性用于确定在交替项序列中如何编号。假设将AlternationCount设置为2,第一个ListBoxItem将获得值为0的AlterationIndex,第二个将获得值为1的AlternationIndex,第3个将获得值为0的AlternationIndex,第4个将获得值为1的AlternationIndex等等技巧是使用触发器在ItemContainerStyle中检查AlternationIndex值并相应改变格式

如下示例,在此显示的ListBox控件为交替项提供了稍微不同的背景颜色(除非已经选择了该项,否则这时针对ListBoxItem.IsSelected属性的具有更高优先级的触发器胜出):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<ListBox AlternationCount="2" Margin="5">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="Background" Value="LightSteelBlue" />
<Setter Property="Margin" Value="5" />
<Setter Property="Padding" Value="5" />
<Style.Triggers>
<Trigger Property="ItemsControl.AlternationIndex" Value="1">
<Setter Property="Background" Value="Orange" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="DarkRed" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderBrush" Value="Black" />
<Setter Property="BorderThickness" Value="1" />
</Trigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
<sys:String>🍎</sys:String>
<sys:String>🍌</sys:String>
<sys:String>🍐</sys:String>
<sys:String>🍉</sys:String>
<sys:String>🍈</sys:String>
<sys:String>🍍</sys:String>
<sys:String>🍓</sys:String>
<sys:String>🍇</sys:String>
</ListBox>

  您可能注意到了 AltermationIndex 是附加属性,该属性是由 ListBox类定义的(或者从技术上讲,是在其父类 ItemsControl 中定义的)。由于不是在 ListBoxltem 类中定义的,因此当在样式触发器中使用该属性时,需要指定类名。

样式选择器

  现在您已看到了如何根据项的选择状态或在列表中的位置改变样式。然而,可能希望使用其他很多条件–依赖于您提供的数据而不是依赖于存储所提供数据的 ListBoxItem 容器的标准。

  为处理这种情况,需要一种为不同的项提供完全不同样式的方法,当无法以声明的方式进行该工作。相反,需要构建专门的继承自StyleSelector的类。该类负责检查每个数据项并选择合适的样式。该工作是在SelectStyle()方法中执行的,必须重写该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class RecommendFruitStyleSelector : StyleSelector
{
public Style HighlightStyle { get; set; }
public Style DefaultStyle { get; set; }
public string HotFruits { get; set; }


public override Style SelectStyle(object item, DependencyObject container)
{
var fruits = HotFruits.Split(',');
string curent = (string)item;
Window window = Application.Current.MainWindow;

if (fruits.Contains(curent))
return HighlightStyle;
else
return DefaultStyle;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<Window.Resources>
<Style TargetType="{x:Type ListBoxItem}" x:Key="DefaultFruitStyle">
<Setter Property="Background" Value="Pink" />
<Setter Property="Padding" Value="2" />
</Style>
<Style TargetType="{x:Type ListBoxItem}" x:Key="RecommandFruitStyle">
<Setter Property="Background" Value="Red" />
<Setter Property="Padding" Value="2" />
<Setter Property="Foreground" Value="White" />
</Style>
</Window.Resources>
<Grid>
<ListBox x:Name="lstFruits" AlternationCount="2" Margin="5">
<ListBox.ItemContainerStyleSelector>
<selectors:RecommendFruitStyleSelector
DefaultStyle="{StaticResource DefaultFruitStyle}"
HighlightStyle="{StaticResource RecommandFruitStyle}"
HotFruits="🍉,🍌,🍓,🍇" />
</ListBox.ItemContainerStyleSelector>
<sys:String>🍎</sys:String>
<sys:String>🍌</sys:String>
<sys:String>🍐</sys:String>
<sys:String>🍉</sys:String>
<sys:String>🍈</sys:String>
<sys:String>🍍</sys:String>
<sys:String>🍓</sys:String>
<sys:String>🍇</sys:String>
</ListBox>
</Grid>

创建使用一个或多个属性的样式选择器(Style),可由使用者自行传入样式选择,个提高程序的通用性

  样式选择过程只执行一次,当第一次绑定列表时执行。如果正在显示可编辑的数据,并且当进行编辑时可能将数据项从一个样式类别移到另一个样式类别中,这就会成为问题。在这种情况下,需要强制 WPF 重新应用样式,并且没有优雅的方式完成该任务。粗鲁的方法是通过将ItemComtainerStyleSelector 属性设置为 nul 来移除样式选择器,然后再次指定样式选择器:

1
2
3
StyleSelector selector = lstFruits.ItemContainerStyleSelector;
lstFruits.ItemContainerStyleSelector = null;
lstFruits.ItemContainerStyleSelector = selector; // 重新赋值

  可通过处理事件来响应特定修改,从而选择自动运行上面的代码,例如ProperyChanged 事件(所有实现了 INotifyPropertyChanged接口的类都会引发该事件,包括Product类)DataTable.RowChanged 事件(如果使用 ADO.NET 数据对象,可以处理该事件)以及更常用的Binding.SourceUpdated事件(只有当 Binding.NotifyOnSourceUpdated为tmue时,才会引发该事件)。当重新指定样式选择器时,WPF 检查并更新列表中的每个项–对于较小或中等规模的列表而言,该过程是较快的。

数据模板

  样式提供了基本的格式化能力,但它们不能消除到目前为止看到的列表的最重要局限性:不管如何修改 ListBoxItem,它都只是 ListBoxItem,而不是功能更强大的元素组合。并且因为每个 ListBoxItem 只支持单个绑定字段(因为是通过 DisplayMemberPath 属性进行设置),所以不可能实现包含多个字段或图像的富列表。

  然而,WPF 另有一个工具可突破这个相当大的限制,允许组合使用来自绑定对象的多个属性,并以特定的方式排列它们或显示比简单字符串更高级的可视化表示。这个工具就是数据模板。

  数据模板是一块定义如何显示绑定的数据对象的XAML标记。有两种类型的控件支持数据模板:

  • 内容控件通过ContentTemplate属性支持数据模板。内容模板用于显示任何放置在Content属性中的内容
  • 列表控件(继承自ItemsControl类的控件)通过ItemTemplate属性支持数据模板。这个模板用于显示作为ItemsSource提供的集合中的每个项(或来自DataTable的每一行)

  基于列表的模板特性实际上以内容控件模板为基础。这是因为列表中的每个项均由内容控件封装,例如用于 ListBox 控件的 ListBoxItem 元素、用于 ComboBox 控件的 ComboBoxItem 元素,等等。不管为列表的ItemTemplate 属性指定什么样的模板,模板都被用作列表中每项的ContentTemplate 模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<ListBox HorizontalAlignment="Stretch" ItemsSource="{Binding Products}">
<ListBox.ItemTemplate>
<DataTemplate>
<Border
BorderBrush="SteelBlue"
BorderThickness="1"
CornerRadius="4"
Margin="5">
<Grid Margin="3">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock FontWeight="Bold" Text="{Binding ModelNumber}" />
<TextBlock
FontWeight="Bold"
Grid.Row="1"
Text="{Binding ModelName}" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

分离和重用模板

  与样式类似,通常也将模板声明为窗口或应用程序的资源,而不是在使用它们的列表中进行定义。这种隔离通常更加清晰,当在同一个控件中使用很长的、复杂的一个或多个模板时尤其如此。当希望在用户界面的不同地方以相同的方式呈现数据时,这样还允许在多个列表或内容控件中重用模板。

为使该方法奏效,只需要在资源集合中定义数据模板并赋予键名。下面的示例提取上面示例中显示的模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<Window.Resources>
<DataTemplate x:Key="ProductDataTemplate">
<Border
BorderBrush="SteelBlue"
BorderThickness="1"
CornerRadius="4">
<Grid Margin="3">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock FontWeight="Bold" Text="{Binding ModelNumber}" />
<TextBlock
FontWeight="Bold"
Grid.Row="1"
Text="{Binding ModelName}" />
</Grid>
</Border>
</DataTemplate>
</Window.Resources>
<Grid>
<ListBox
HorizontalContentAlignment="Stretch"
ItemTemplate="{StaticResource ProductDataTemplate}"
ItemsSource="{Binding Products}" />
</Grid>

使用更高级的模板

  数据模板可包含非常丰富的内容。除基本元素外,如 TextBlock 控件和数据绑定表达式,们还能使用更复杂的控件、关联事件处理程序、将数据转换为不同的表达形式以及使用动画等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<Window.Resources>
<DataTemplate x:Key="ListBoxItemDataTemplate">
<Border
BorderBrush="SteelBlue"
BorderThickness="1"
CornerRadius="4"
Padding="5">
<Grid>
<TextBlock Text="{TemplateBinding Content}" VerticalAlignment="Center" />
<Button
Click="cmdView_Clicked"
Content="View"
HorizontalAlignment="Right"
Tag="{TemplateBinding Content}" />
</Grid>
</Border>
</DataTemplate>
</Window.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<ListBox
Grid.Column="0"
HorizontalContentAlignment="Stretch"
ItemTemplate="{StaticResource ListBoxItemDataTemplate}"
x:Name="LstThemes">
<sys:String>Dark</sys:String>
<sys:String>Light</sys:String>
</ListBox>
<Image Grid.Column="1" Source="{Binding ImagePath, Converter={cvt:ImagePathConverter}}" />
</Grid>
1
2
3
4
5
6
7
private void cmdView_Clicked(object sender, RoutedEventArgs e)
{
Button cmd = (Button)sender;

var vm = (this.DataContext as MainWindowViewModel);
vm.ImagePath = $"{cmd.Tag}.png";
}

  在这个示例中的技巧是对按钮单击的处理。显然,所有按钮都被连接到同一个事件处理程序。这个事件处理程序在模板中定义,因此可以在Button的Tag属性中存储一些额外的标识信息。

改变模板

  使用到目前为止介绍的数据模板的一个限制是,只能为整个列表使用一个模板。当在许多情况下,希望采用不同方式灵活地表示不同的数据。可使用多种方式实现这一目标,下面是一些常用技术:

  • 使用数据触发器。可根据绑定的数据对象中的属性值使用触发器修改模板中的属性。除了不需要依赖项属性外,数据触发器与样式的属性触发器的工作方式类似
  • 使用值转换器。实现了IValueConverter接口的类,能够将值成绑定的对象转换为可用于设置模板中与格式化相关的属性的值
  • 使用模板选择器。模板选择器插件绑定的数据对象,并在几个不同模板之间进行选择

数据触发器提供了最简单的方法。基本技术是根据数据项中的某个属性,设置模板中某个元素的某个属性:(例如,如果是Tools类型的商品者显示为红色)

1
2
3
4
5
6
7
8
9
10
<DataTemplate x:Key="DefaultTemplate">
<DataTemplate.Trigger>
<DataTrigger Biding="{Binding CategoryName}" Value="Tools">
<Setter Property="ListBoxItem.Foreground" Value="Red"/>
</DataTrigger>
</DataTemplate.Trigger>
<Border Margin="5" BorderThickness="1" BorderBrush="SteelBlue">
....
</Border>
</DataTemplate>

数据触发器和值转换器只是在修改模板中的某个属性,如果需要完全采用不同的模板,请使用模板选择器

模板选择器

  另一中更强大的选择是,为项赋于完全不同的模板。为此,需要创建继承自DataTemplateSelector的类。模板选择器的工作方式和在前面分析的样式选择器的工作方式相同——它们检查绑定对象并使用您提供的逻辑选择合适的模板。

下面的例子根据ListItemType选择不同的模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public enum ListItemType
{
RadioButton,
CheckBox
}

public class ListBoxItemTypeTemplateSelector : DataTemplateSelector
{
public DataTemplate RadioTemplate { get; set; }
public DataTemplate CheckBoxTemplate { get; set; }

public ListItemType ItemType { get; set; }

public override DataTemplate SelectTemplate(object item, DependencyObject container)
{
// item 是ListBox每一项数据,这里没有根据数据选择模板
if (ItemType == ListItemType.CheckBox)
return CheckBoxTemplate;
else
return RadioTemplate;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<Window.Resources>
<DataTemplate x:Key="RadioDataTemplate">
<Border
BorderBrush="SteelBlue"
BorderThickness="1"
CornerRadius="4"
Padding="5">
<RadioButton
Content="{TemplateBinding Content}"
Focusable="False"
IsChecked="{Binding IsSelected, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListBoxItem}}, Mode=TwoWay}" />
</Border>
</DataTemplate>
<DataTemplate x:Key="CheckBoxDataTemplate">
<Border
BorderBrush="SteelBlue"
BorderThickness="1"
CornerRadius="4"
Padding="5">
<CheckBox
Content="{TemplateBinding Content}"
Focusable="False"
IsChecked="{Binding IsSelected, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListBoxItem}}, Mode=TwoWay}" />
</Border>
</DataTemplate>
</Window.Resources>
<Grid>
<ListBox HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplateSelector>
<selectors:ListBoxItemTypeTemplateSelector
CheckBoxTemplate="{StaticResource CheckBoxDataTemplate}"
ItemType="RadioButton"
RadioTemplate="{StaticResource RadioDataTemplate}" />
</ListBox.ItemTemplateSelector>
<sys:String></sys:String>
<sys:String></sys:String>
</ListBox>

</Grid>

  这种方法的一个缺点是,可能必须创建多个类似的模板。如果模板比较复杂,这种方法会造成大量的重复。为尽量提高可维护性,不应为单个列表创建过多模板–而应当使用触发器和样式为模板应用不同的格式。

TemplateBinding 通常用于 ControlTemplate,而 Binding 更常用于 DataTemplate。TemplateBinding 是一种特殊的绑定形式,用于绑定到模板所属控件的属性。而在 DataTemplate 中,通常是绑定到数据对象的属性。

在DataTemplate中也不能直接使用ContentPresenter

模板与选择

  如果在列表中选择了一项,WPF 会自动设置项容器(在此是 ListBoxltem 对象)的 Foreground 和Background 属性。前景色是白色,背景色是蓝色。Foreground 属性使用属性继承,所以添加到模板中的任何元素都自动获得新的白色,除非明确指定新的颜色。Background 属性不使用属性继承,但默认的 Background 值是 Transparent。例如,如果边框也是透明的,就会穿透显示新的蓝色背景。否则仍然应用在模板中设置的颜色。

例如下面的示例,直接通过触发器设置项选中后的背景色是不生效的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<ListBox HorizontalContentAlignment="Stretch">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="Padding" Value="0" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="DarkRed" />
</Trigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
<sys:String>🍉</sys:String>
<sys:String>🍓</sys:String>
<sys:String>🍇</sys:String>
<sys:String>🍈</sys:String>
<sys:String>🍍</sys:String>
</ListBox>

  触发器为选择的项应用深红色背景,但上面的代码不能为使用模板的列表提供所期望的效果。这是因为这些模板包含了使用不同背景颜色的元素,这些元素在深红色背景上显示。除非所有内容都是透明的(从而使红色背景能够透过整个模板),否则只会在模板的外边距区域看到一条很细的红色边线。

  解决方法是将模板中部分元素的背景显式绑定到ListBoxItem.Background 属性的值。这是合理的–毕竟,现在能够选择正确的背景颜色来突出显示选择的项。只需要确保它在正确的位置显示。

  在直接使用触发器修改背景色的基础上,重写数据模板通过Binding RelativeSource让数据模块的外观背景色与ListBoxItem的背景色进行绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<ListBox HorizontalContentAlignment="Stretch">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="Padding" Value="0" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="DarkRed" />
</Trigger>
</Style.Triggers>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Border Background="{Binding Background, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListBoxItem}}}">
<TextBlock Text="{Binding Content, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListBoxItem}}}" />
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
<sys:String>🍉</sys:String>
<sys:String>🍓</sys:String>
<sys:String>🍇</sys:String>
<sys:String>🍈</sys:String>
<sys:String>🍍</sys:String>
</ListBox>

  通过重写ListBoxItem的控件模板,在控件模板的外观中进行背景色的绑定,并在控件模板内使用触发器修改背景色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<ListBox HorizontalContentAlignment="Stretch">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="Padding" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<Border Background="{TemplateBinding Background}">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="DarkRed" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<sys:String>🍉</sys:String>
<sys:String>🍓</sys:String>
<sys:String>🍇</sys:String>
<sys:String>🍈</sys:String>
<sys:String>🍍</sys:String>
</ListBox>

改变项的布局

  使用数据模板可非常灵活地控制项显示的各个方面,但它们不允许根据项之间的关系更改项的组织方式。不管使用什么样的模板和样式,ListBox控件总是在独立的水平行中放置每个项并堆叠每行从而创建列表。

  可通过替换列表用于布局其子元素的容器来改变这种布局。为此,需要使用一块XAML标记(定义希望使用的面板)来设置ItemsPanelTemplate 属性。这个面板可以是继承自 System.Windows.Controls.Panel 的任意类。

下面的示例使用WrapPanel面板男封装项,这些项跨域ListBox控件的可用宽度

1
2
3
4
5
6
7
8
9
10
11
12
<ListBox HorizontalContentAlignment="Stretch">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<sys:String>🍉</sys:String>
<sys:String>🍓</sys:String>
<sys:String>🍇</sys:String>
<sys:String>🍈</sys:String>
<sys:String>🍍</sys:String>
</ListBox>

  **这种方法有一个陷阱。通常,大多数列表控件使用 VirtualizingStackPanel 面板而不是使用标准的 StackPane! 面板。**如第19章所述,VirtualizingStackPanel 面板确保能够高效地处理大量的绑定数据。当使用 VirtualizingStackPanel 面板时,只创建显示当前可见项所需的元素。当使用StackPanel面板时,为整个列表创建所需的元素。如果数据源包含数千项(或更多),VirtualizingStackPanel 面板使用的内存要少得多。当填充列表并且当用户在列表中滚动时,使用VirtualizingStackPanel 面板可以执行得更好,因为 WPF 布局系统需要完成的工作要少得多。

  **因此,不应设置新的 ItemsPanelTemplate,除非使用列表显示数量适中的数据。**如果处于模糊地带——例如,正在显示的数据列表只有200个项并且有一个极其复杂的模板—可以针对这两种方法进行性能分析,查看性能和内存使用的变化情况,从而选择最佳策略

ComboxBox控件

  与 ListBox 类一样,ComboBox 类是 Selector类的派生类。与ListBox 类不同的是,ComboBox类增加了另外两个部分:显示当前选择项的选择框和用于选择项的下拉列表。当单击组合框边缘上的下拉箭头时会显示下拉列表。或者,如果组合框处于只读模式(默认设置),可通过单击选择框的任意位置展开下拉列表。最后,可通过代码设置IsDropDownOpen属性来打开或关闭下拉列表。

  通常,ComboBox 控件显示一个只读组合框,这意味着可使用它选择一个项,但不能随意输入自己的内容。然而,可通过将IsReadOnly属性设置为 false 并将 IsEditable 属性设置 true 来改变这种行为。现在选择框变成了文本框,并且可在其中输入希望的任何文本。

  ComboBox 控件提供了基本的自动完成功能,当输入内容时会自动完成输入(不要与IE这类程序中花哨的自动完成功能相混淆,此处的自动完成功能会在当前文本框的下面显示所有可能项的列表)。下面是它的工作原理–当在 ComboBox 控件中键入内容时,WPF 使用第一个四配自动完成建议的项填充选择框中的剩余内容。例如,如果输入Gr 并且列表中包含字符串 Green,组合框就会填充字母een。由于自动完成文本是可选的,因此当继续键入内容时会自动覆盖原来的文本。

  如果不希望使用自动完成行为,只需要将 ComboBox.IsTextSearchEnabled 属性设置为 false。这个属性继承自 ItemsContol基类,并被应用到许多其他列表控件。例如,如果在 ListBox 控件中将 IsTextSearchEnabled 属性设置为 tue,就可输入一个项的第一层次内容以跳到该项的位置。

WPF 没有为使用系统跟踪的自动完成列表提供任何功能,例如最近使用的URI 以及文件列表,也不支持自动完成下拉列表。

  如果 IsEditable 属性为 true,ComboBox 控件的行为会发生变化。不是显示选择项的副本,而是显示选择项的文本形式表示。为创建这种文本表示形式,WPF简单地为每个项调用ToString()方法。下图在这个示例中,显示的文本“DataBinding.Product”只是当前选择的 Product对象的完全限定类名,这是 ToSting()方法的默认实现,除非在自定义的数据类中重写该方法。

  解决这个问题最容易的方法是设置 TextSearch.TextPath 附加属性,指示应当被用于选择框内容的属性。下面是一个示例:

1
<ComboBox IsEditable="True" IsReadOnly="True" TextSearch.TextPath="ModelName" ...>

  尽管 IsEditable 属性必须为 true,但由您决定 IsReadOnly 属性的设置是 false(表示允许编辑该属性)还是 true(表示阻止用户随意输入内容)。

  如果希望显示更丰富的内容,而不只是显示一块简单文本,但又希望选择框中的内容与下拉列表中的内容不同,那么应如何实现这种效果呢?CoboBox控件提供了SelectionBoxItemTemplate 属性,该属性定义了为选择框使用的模板。遗憾的是,SelectionBoxltemTemplate 属性是只读的。它自动设置为与当前项相匹配,并且不能提供不同的模板。但可创建全新的根本不使用 SelectionBoxltemTemplate 属性的 ComboBox 控件模板,这个控件模板可硬编码选择框模板或从窗口的资源集合中检索选择框模板。