WPF 的思维过程






4.79/5 (56投票s)
使用 WPF 解决问题的内省之旅

引言
我向您保证,这篇文章对我来说非常难写。这是我在 CodeProject 上的第三十篇文章,所以我认为应该挑战一下新的东西。这个挑战不仅对我,对您,读者,也是一样。希望我已经足够好地克服了写作这篇文章的挑战,这样您就可以面对学习如何用 WPF 思考这一更艰巨的挑战了。如果您已经是经验丰富的 WPF 开发人员,请随意继续阅读,看看别人是如何用 WPF 解决问题的。
本文试图解释我在使用 WPF 设计和实现解决方案时所经历的思考过程。如果您是 WPF 新手,并且刚刚开始攀登其臭名昭著的学习陡坡,那么本文或许能帮助您阐明为什么以及如何将许多 WPF 概念付诸实践。我并非声称我处理 WPF 的方式是“正确”或“最佳”的方式,而仅仅是我思考的方式。
背景
本文假设您已经对 WPF 有一定的了解。我们不会在这里讨论基础知识。如果您需要学习 WPF 的基础知识,您可能想看看我在 CodeProject 上发表的五部分《WPF 入门指南》。
需要解决的问题
我想为 ListBox
中的项目创建时尚的选定指示器。而不是让我的 ListBox
看起来像这样……

…我希望它看起来像这样……

在上面的两个截图都显示了选定了 ListBox
中的三个相同的项目。上面一张图片显示了 Windows XP 上带有 Olive 主题的 ListBox
的标准外观。下面一张图片显示了带有三角形选定指示器而不是高亮 ListBoxItem
的标准 ListBox
。如果您滚动 ListBox
,这些选定指示器需要在任何时候都保持与其关联的 ListBoxItem
直接相邻(否则它们就毫无意义了)。
选定指示器不需要交互。如果用户单击指示器,则不会发生任何事情。它只是一个视觉特征,并且不影响 ListBox
的状态。
我还希望“选定指示器”功能是可重用的,以便我可以轻松地将选定指示器应用于任何应用程序中的任何 ListBox
。我需要将此功能封装起来,但诸如颜色和字体大小等内容应该是可自定义的。
从哪里开始?
到目前为止,我们对需要解决的问题已经有了相当清晰的理解。现在是时候比较和对比解决该问题的各种可能方案了。我想到了一些方法,让我们回顾一下。
- 我们可以为
ListBox
提供一个包含选定指示器的ItemTemplate
。该模板可以有一个触发器,在ListBoxItem
未被选中时隐藏指示器。此解决方案的问题在于,选定指示器似乎是ListBoxItem
的“一部分”。我更希望指示器位于整个ListBox
之外,如上面的截图所示。这个美学决定使得ItemTemplate
方法不合适。 - 我们可以渲染一个选定指示器在
ListBoxItem
的 Adorner 层。这种方法使我们不必在ListBoxItem
的边界内渲染指示器,但这会带来一个新问题。如果ListBox
直接放置在另一个控件旁边,那么选定指示器可能会渲染在该相邻控件的上方。这将导致一些奇怪的视觉问题,所以我们似乎需要为选定指示器分配一些屏幕空间。 - 我们可以为
ListBox
创建一个ControlTemplate
,并在模板中为选定指示器分配一些空间。这将允许我们使其看起来好像选定指示器在ListBox
之外,并为指示器提供自己的空间。但是,为什么我们要坚持让其他开发人员认为拥有选定指示器并使用他们自己的自定义ControlTemplate
是相互排斥的选项呢?如果他们两者都需要怎么办?由于无法自定义或“继承”现有的ControlTemplate
,因此此方法也行不通。
我们刚刚回顾了三种关于如何以及在哪里渲染选定指示器的可能方法。它们都没有成功,但我们在过程中学到了三点重要知识。这些要点是:
- 根据我的审美偏好,选定指示器必须位于
ListBox
的外部。 - 选定指示器需要有自己的空间来存在,这样它们才不会与相邻的控件重叠。
- 使用选定指示器不应限制您对
ListBox
进行的其他操作,例如禁止您应用自定义ControlTemplate
。
第三点需要一些澄清。我们只能支持 ListBox
的一定程度的自定义。如果用户将 ListBox
的 ItemsPanel
替换为其他布局面板,我们不能保证我们的选定指示器将始终与选定的项目正确对齐。我们需要假设 ListBoxItem
将垂直堆叠,如默认情况所示。
基于以上所有要点,我们现在必须决定如何继续并开始实现此功能。通过创建一个继承自 UserControl
的子类,其中包含一个 ListBox
和一个 Grid
面板,该面板在其旁边托管选定指示器,我们可以满足所有约束。该 UserControl
(名为 ListBoxWithIndicator
)的基本结构如下所示:
<UserControl>
<DockPanel>
<Grid DockPanel.Dock="Left" />
<ListBox />
</DockPanel>
</UserControl>
如何绘制选定指示器?
选定指示器不是 ListBox
的一部分。它们存在于相邻的面板中,并且必须在 ListBox
中的项目被选中/滚动/取消选中时创建/定位/删除。在 WPF 中完成此操作的好方法是什么?在阅读任何内容之前,请花些时间思考一下这个问题。
欢迎回来。如果您花了一些时间思考如何管理选定指示器,您可能会意识到有很多方法可以解决这个问题。如果您的第一反应是绘制选定项目旁边的小三角形,您应该环顾四周,意识到您已经不是在堪萨斯州了。WPF 确实允许您进行低级渲染,这在某种程度上类似于使用 HDC 或 Graphics
对象,但这将是采取高层方法,而且完全没有正当理由。
一个看似可行的方法是挂钩 ListBox
的 SelectionChanged
事件,并在其被引发时,在选定指示器区域创建一些 Polygon
元素(即三角形选定指示器)。您可以将这些 Polygon
定位,使它们分别位于选定的 ListBoxItem
旁边,方法是设置它们的 Margin
的 Top
为某个计算出的偏移量。这有效地将每个 Polygon
“推”到 ListBoxItem
旁边的正确位置。
这种技术肯定会奏效,但在我看来,它就是“不对”。在我看来,我们不应该手动创建和定位选定指示器。它们应该基于纯粹的 XAML 标记自行创建和定位。这减少了我们代码中的活动部件数量,意味着需要修复的 bug 会更少。那么,我们如何在不编写过多代码的情况下实现此逻辑呢?
这个问题的解决方案利用了 WPF 的几个强大功能:一个项目面板、数据绑定、附加属性和一个 DataTemplate
。让我们回顾一下我提出的解决方案,看看它是如何工作的。
选定指示器具有固定的宽度和高度,并且与它所在的容器的左边缘具有固定的水平偏移量。它本身唯一不知道的变量是它与它所在的容器顶部的垂直偏移量。该垂直偏移量有效地决定了它“指向”哪个选定的 ListBoxItem
。
假设我们计算了显示选定指示器在每个选定 ListBoxItem
旁边所需的垂直偏移量,并将这些偏移量存储在一个集合中。如果我们为 ItemsControl
提供这些值作为其 ItemsSource
,ItemsControl
将如下所示(偏移量用红色圆圈标出):

显然,这不是我们想要的视觉效果,但这是一个开始。此时,我们有一个 ItemsControl
位于 ListBox
旁边,它包含一个 Double
列表,这些 Double
值表示每个选定指示器需要距离 ItemsControl
顶部多远。接下来,我们需要为 ItemsControl
的 ItemTemplate
属性提供一个 DataTemplate
,该属性渲染一个选定指示器,如下所示:
<DataTemplate>
<Grid Width="16" Height="16">
<!--
A lightweight drop shadow
under the selection indicator.
-->
<Polygon Fill="LightGray">
<Polygon.Points>
<Point X="4" Y="4" />
<Point X="16" Y="10" />
<Point X="4" Y="16" />
</Polygon.Points>
</Polygon>
<!--
The selection indicator itself.
-->
<Polygon Fill="{Binding ElementName=mainControl, Path=IndicatorBrush}">
<Polygon.Points>
<Point X="2" Y="2" />
<Point X="14" Y="8" />
<Point X="2" Y="14" />
</Polygon.Points>
</Polygon>
</Grid>
</DataTemplate>
完成此操作后,UI 如下所示:

这肯定看起来不对!这里有什么问题?为什么选定指示器不在选定项目旁边?花点时间思考一下,然后再继续。没关系,我等着……
这里的问题是我们选定指示器不知道它们在 ItemsControl
中表示的 Double
值应该用作它们的垂直偏移量。仅仅告诉 ItemsControl
将每个项目渲染成一个小三角形,并不意味着它会为我们定位它们。我们需要解释这些偏移量应该如何使用。为此,我们可以利用一些强大的 WPF 功能:自定义项目面板和绑定附加属性。
默认情况下,ItemsControl
将其项目排列成垂直堆栈。在这种情况下,我们不希望它这样做。相反,我们需要它将项目排列在 Canvas
中,这样我们就可以告诉 Canvas
在哪里定位选定指示器。通过绑定每个指示器上的附加 Canvas.Top
属性,我们告知 Canvas
每个选定指示器的垂直偏移量。XAML 如下所示,并且是 ItemsControl
声明的一部分:
<!--
Host all of the selection indicators
within a Canvas panel.
-->
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<!--
Position a selection indicator based on the
offset value to which it is bound.
-->
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Top" Value="{Binding Path=.}" />
</Style>
</ItemsControl.ItemContainerStyle>
由于 ItemsControl
在内部创建一个 ContentPresenter
来托管每个项目,因此我们需要在该元素上设置 Canvas.Top
属性,以便 Canvas
正确地定位它。当这些设置到位,并应用了一些视觉技巧来移除选定的 ListBoxItem
的高亮颜色后,UI 如下所示:

何时计算选定指示器的偏移量?
偏移量计算的确切细节对于此讨论无关紧要,但值得注意的是它们何时计算。在两种情况下更新偏移量很重要:当选定的项目更改时以及当项目滚动时。这是 ListBoxWithIndicator
构造函数,它为这两个事件设置了处理程序:
public ListBoxWithIndicator()
{
InitializeComponent();
// Set up the list of selection indicator offsets
// as the data source for the ItemsControl.
_indicatorOffsets = new ObservableCollection<double>();
_indicatorList.ItemsSource = _indicatorOffsets;
// Move the indicators when the set of
// selected items is modified.
_listBox.SelectionChanged += delegate
{
this.UpdateIndicators();
};
// Move the indicators when the ListBox's
// ScrollViewer is scrolled.
_listBox.AddHandler(
ScrollViewer.ScrollChangedEvent,
new ScrollChangedEventHandler(delegate
{
this.UpdateIndicators();
}));
}
处理 ScrollViewer
的 ScrollChanged
事件的方式很有趣,因为我们实际上不必查找实际的 ScrollViewer
并直接挂钩其事件。相反,我们依靠路由事件的冒泡性质,让事件“找上门”,可以说是这样。最初我计划编写一些代码来遍历视觉树以查找 ListBox
的 ScrollViewer
,但决定仅监听冒泡事件既简单又安全。使用此技术更安全,因为您编写的代码越多,出现 bug 的可能性就越大!
如何使 ListBox 和选定指示器可自定义?
到目前为止,我们已经找到了一种渲染选定指示器并在用户与 ListBox
交互时保持其更新的方法。我们还没有解决的一个问题是如何让开发人员轻松使用 ListBoxWithIndicator
控件。在我看来,主要有两个问题:您需要能够从 XAML 配置 ListBox
,并且您需要能够轻松指定选定指示器的颜色。不幸的是,像这样的 XAML 行不通:
<!-- This is invalid XAML. -->
<local:ListBoxWithIndicator>
<local:ListBoxWithIndicator.ListBox>
<ListBox.ItemsSource>
<SomeData />
</ListBox.ItemsSource>
</local:ListBoxWithIndicator.ListBox>
</local:ListBoxWithIndicator>
问题在于,在 XAML 中无法轻松访问内部 ListBox
。除非您正在创建子对象,否则您无法在 XAML 中设置子对象上的属性。那么,我们如何允许开发人员在我们的 ListBoxWithIndicator
控件中设置 ListBox
的属性呢?再一次,花点时间思考一下……
我决定的解决方案是简单地在 ListBoxWithIndicator
上公开一个名为 ListBoxStyle
的依赖属性,然后将我们 ListBox
的 Style
属性绑定到它。这是它的工作原理:
<!-- In ListBoxWithIndicator.xaml -->
<ListBox
x:Name="_listBox"
Style="{Binding ElementName=mainControl, Path=ListBoxStyle}"
/>
当您创建控件的实例时,您可以将其 ListBoxStyle
属性设置为一个 Style,该 Style 在内部 ListBox
上设置任意数量的属性。
我还创建了一个名为 IndicatorBrush
的公共依赖属性,选定指示器将其 Fill 属性与之绑定。这使得开发人员也可以控制指示器的颜色。
结论
如果您是 WPF 新手,但有使用旧 UI 平台的经验,那么要忘记旧的做事方式并学习 WPF 的方式并非易事。WPF 为开发人员提供了许多新的强大功能,但您必须愿意经历重新成为新手这一令人沮丧的经历。希望本文能帮助您加快这一痛苦的过程,前提是您首先需要任何帮助。
修订历史
- 2007 年 10 月 13 日 – 创建了文章