在 WPF TreeView 中使用复选框






4.90/5 (95投票s)
探讨如何创建复选框树,
引言
本文介绍一个 WPF TreeView
,其项目包含复选框。每个项目都绑定到一个 ViewModel 对象。当 ViewModel 对象的选中状态改变时,它会对其父级和子级项目的选中状态应用简单的规则。本文还展示了如何使用“附加行为”概念将 TreeViewItem
转换为虚拟 ToggleButton
,这有助于使 TreeView
的键盘交互变得简单直观。
本文假设读者已经熟悉数据绑定和模板、将 TreeView 绑定到 ViewModel 以及附加属性。
背景
拥有项目为复选框的 TreeView
非常常见,例如在向用户呈现分层选项集以供选择时。在某些 UI 平台(例如 WinForms)中,标准 TreeView
控件提供内置支持以在其项目中显示复选框。由于元素组合和丰富的数据绑定是 WPF 的两个核心方面,WPF TreeView
不提供内在支持来显示复选框。在 TreeView
的 ItemTemplate
中声明一个 CheckBox
控件非常容易,然后树中的每个项目都会突然包含一个 CheckBox
。为 IsChecked
属性添加一个简单的 {Binding}
表达式,然后这些复选框的选中状态突然就绑定到基础数据对象上的某个属性。充其量,WPF TreeView
拥有专门用于在其项目中显示复选框的 API 也是多余的。
魔鬼藏在细节里
这听起来好得令人难以置信,事实也确实如此。从键盘导航的角度来看,让 TreeView
“感觉良好”并不那么简单。根本问题在于,当您通过箭头键导航树时,TreeViewItem
将首先获得输入焦点,然后它包含的 CheckBox
将在下一次按键时获得焦点。TreeViewItem
和 CheckBox
控件都是可聚焦的。结果是您必须按两次箭头键才能在树中的项目之间导航。这绝对不是可接受的用户体验,并且没有简单的属性可以设置以使其正常工作。我已将此问题提请 Microsoft WPF 团队中的某个关键成员注意,因此他们可能会在平台的未来版本中解决此问题。
功能要求
在我们开始研究这个演示程序是如何工作之前,我们首先回顾一下它的功能。以下是演示应用程序运行时的屏幕截图

现在让我们看看功能要求是什么
要求 1:树中的每个项目必须显示一个复选框,该复选框显示基础数据对象的文本和选中状态。
要求 2:当一个项目被选中或取消选中时,其所有子项目应分别被选中或取消选中。
要求 3:如果一个项目的后代不都具有相同的选中状态,则该项目的选中状态必须为“不确定”。
要求 4:在项目之间导航只需按一次箭头键。
要求 5:按空格键或回车键应切换所选项目的选中状态。
要求 6:单击项目的复选框应切换其选中状态,但不选择项目。
要求 7:单击项目的显示文本应选择项目,但不切换其选中状态。
要求 8:树中的所有项目默认应处于展开状态。
我建议您将这些要求复制并粘贴到您喜欢的文本编辑器(例如记事本)中,因为我们将在本文的其余部分中通过编号引用它们。
将智能放入 ViewModel
正如我在我的文章“通过使用 ViewModel 模式简化 WPF TreeView”中所解释的,TreeView
实际上是为了与 ViewModel 结合使用而设计的。本文进一步阐述了这一思想,并展示了我们如何使用 ViewModel 来封装与树中项目的选中状态相关的应用程序特定逻辑。在本文中,我们将研究我的 FooViewModel
类,其由以下接口描述
interface IFooViewModel : INotifyPropertyChanged
{
List<FooViewModel> Children { get; }
bool? IsChecked { get; set; }
bool IsInitiallySelected { get; }
string Name { get; }
}
这个 ViewModel 类最有趣的方面是 IsChecked
属性背后的逻辑。此逻辑满足前面看到的要求 2 和 3。FooViewModel
的 IsChecked
逻辑如下
/// <summary>
/// Gets/sets the state of the associated UI toggle (ex. CheckBox).
/// The return value is calculated based on the check state of all
/// child FooViewModels. Setting this property to true or false
/// will set all children to the same check state, and setting it
/// to any value will cause the parent to verify its check state.
/// </summary>
public bool? IsChecked
{
get { return _isChecked; }
set { this.SetIsChecked(value, true, true); }
}
void SetIsChecked(bool? value, bool updateChildren, bool updateParent)
{
if (value == _isChecked)
return;
_isChecked = value;
if (updateChildren && _isChecked.HasValue)
this.Children.ForEach(c => c.SetIsChecked(_isChecked, true, false));
if (updateParent && _parent != null)
_parent.VerifyCheckState();
this.OnPropertyChanged("IsChecked");
}
void VerifyCheckState()
{
bool? state = null;
for (int i = 0; i < this.Children.Count; ++i)
{
bool? current = this.Children[i].IsChecked;
if (i == 0)
{
state = current;
}
else if (state != current)
{
state = null;
break;
}
}
this.SetIsChecked(state, false, true);
}
此策略特定于我给自己施加的功能要求。如果您对项目如何以及何时更新其选中状态有不同的规则,只需调整这些方法中的逻辑以满足您的需求即可。
TreeView 配置
现在是时候看看 TreeView
如何显示复选框并绑定到 ViewModel 了。这完全在 XAML 中完成。TreeView
的声明实际上非常简单,如下所示
<TreeView
x:Name="tree"
ItemContainerStyle="{StaticResource TreeViewItemStyle}"
ItemsSource="{Binding Mode=OneTime}"
ItemTemplate="{StaticResource CheckBoxItemTemplate}"
/>
TreeView
的 ItemsSource
属性隐式绑定到其 DataContext
,后者从包含窗口继承 List<FooViewModel>
。该列表只包含一个 ViewModel 对象,但有必要将其放入集合中,因为 ItemsSource
的类型是 IEnumerable
。
TreeViewItem
是由 ItemTemplate
生成的可视元素的容器。在此演示中,我们将以下 HierarchicalDataTemplate
分配给树的 ItemTemplate
属性
<HierarchicalDataTemplate
x:Key="CheckBoxItemTemplate"
ItemsSource="{Binding Children, Mode=OneTime}"
>
<StackPanel Orientation="Horizontal">
<!-- These elements are bound to a FooViewModel object. -->
<CheckBox
Focusable="False"
IsChecked="{Binding IsChecked}"
VerticalAlignment="Center"
/>
<ContentPresenter
Content="{Binding Name, Mode=OneTime}"
Margin="2,0"
/>
</StackPanel>
</HierarchicalDataTemplate>
该模板中有几个值得注意的地方。模板包含一个 CheckBox
,其 Focusable
属性设置为 false
。这会阻止 CheckBox
接收输入焦点,这有助于满足要求 4。您可能想知道如果 CheckBox
从未获得输入焦点,我们如何才能满足要求 5。我们将在本文后面,当我们研究如何将 ToggleButton
的行为附加到 TreeViewItem
时,解决这个问题。
CheckBox
的 IsChecked
属性绑定到 FooViewModel
对象的 IsChecked
属性,但请注意其 Content
属性未设置任何内容。相反,它旁边有一个 ContentPresenter
,其 Content
绑定到 FooViewModel
对象的 Name
属性。默认情况下,单击 CheckBox
上的任何位置都会使其切换选中状态。通过使用单独的 ContentPresenter
,而不是设置 CheckBox
的 Content
属性,我们可以避免该默认行为。这有助于我们满足要求 6 和 7。单击 CheckBox
中的框元素将导致其选中状态改变,但单击旁边的显示文本不会。同样,单击 CheckBox
中的框不会选择该项目,但单击旁边的显示文本会。
我们将在下一节中检查 TreeView
的 ItemContainerStyle
。
将 TreeViewItem 转换为 ToggleButton
在上一节中,我们迅速思考了一个有趣的问题。如果 TreeViewItem
中的 CheckBox
将其 Focusable
属性设置为 false
,那么它如何响应空格键或回车键来切换其选中状态呢?由于元素只有在获得键盘焦点时才能接收击键,因此似乎不可能满足要求 5。请记住;我们必须将 CheckBox
的 Focusable
属性设置为 false
,以便在树中从一个项目导航到另一个项目不需要多次击键。
这是一个棘手的问题:我们不能让 CheckBox
获得输入焦点,因为它会对键盘导航产生负面影响,然而,当其包含的项目被选中时,它必须以某种方式响应某些击键来切换其选中状态。这些似乎是相互排斥的要求。当我遇到这个难题时,我决定向 WPF Disciples 寻求帮助,并创建了这个线程。不出我所料,Dr. WPF 已经遇到过这类问题,并设计了一个接近于天才的精彩解决方案,可以轻松地集成到我的应用程序中。这位优秀的医生给我发来了 VirtualToggleButton
类的代码,并好心地允许我在本文中发布它。
医生的解决方案使用了 John Gossman 所说的“附加行为”。其思想是,您在元素上设置一个附加属性,以便可以从公开该附加属性的类访问该元素。一旦该类访问了该元素,它就可以在该元素上挂钩事件,并响应这些事件的触发,使该元素执行它通常不会做的事情。这是一种非常方便的替代创建和使用子类的方法,并且非常XAML友好。
在本文中,我们展示了如何为 TreeViewItem
提供一个附加的 IsChecked
属性,当用户按下空格键或回车键时,该属性会切换。该附加的 IsChecked
属性绑定到 FooViewModel
对象的 IsChecked
属性,该属性也绑定到 TreeViewItem
中 CheckBox
的 IsChecked
属性。此解决方案使 CheckBox
看起来正在响应空格键或回车键切换其选中状态,但实际上,其 IsChecked
属性通过数据绑定响应 TreeViewItem
向 ViewModel 的 IsChecked
属性推送新值而更新。
在继续之前,我必须指出,我完全承认这很疯狂。在 WPF v3.5 中,这是实现复选框 TreeView
最简洁的方式,这对我来说表明 Microsoft 需要简化平台的这一方面。然而,在他们这样做之前,这可能是实现该功能的最佳方式。
在此演示中,我们并未利用 Dr. WPF 的 VirtualToggleButton
类中的所有功能。它支持一些我们不需要的功能,例如处理鼠标点击和提供三态复选框。我们只需要使用它对附加的 IsVirtualToggleButton
和 IsChecked
属性以及它提供的键盘交互行为的支持。
这是附加的 IsVirtualToggleButton
属性的属性更改回调方法,它使此类能够访问树中的 TreeViewItem
/// <summary>
/// Handles changes to the IsVirtualToggleButton property.
/// </summary>
private static void OnIsVirtualToggleButtonChanged(
DependencyObject d, DependencyPropertyChangedEventArgs e)
{
IInputElement element = d as IInputElement;
if (element != null)
{
if ((bool)e.NewValue)
{
element.MouseLeftButtonDown += OnMouseLeftButtonDown;
element.KeyDown += OnKeyDown;
}
else
{
element.MouseLeftButtonDown -= OnMouseLeftButtonDown;
element.KeyDown -= OnKeyDown;
}
}
}
当 TreeViewItem
触发其 KeyDown
事件时,此逻辑会执行
private static void OnKeyDown(object sender, KeyEventArgs e)
{
if (e.OriginalSource == sender)
{
if (e.Key == Key.Space)
{
// ignore alt+space which invokes the system menu
if ((Keyboard.Modifiers & ModifierKeys.Alt) == ModifierKeys.Alt)
return;
UpdateIsChecked(sender as DependencyObject);
e.Handled = true;
}
else if (e.Key == Key.Enter &&
(bool)(sender as DependencyObject)
.GetValue(KeyboardNavigation.AcceptsReturnProperty))
{
UpdateIsChecked(sender as DependencyObject);
e.Handled = true;
}
}
}
private static void UpdateIsChecked(DependencyObject d)
{
Nullable<bool> isChecked = GetIsChecked(d);
if (isChecked == true)
{
SetIsChecked(d,
GetIsThreeState(d) ?
(Nullable<bool>)null :
(Nullable<bool>)false);
}
else
{
SetIsChecked(d, isChecked.HasValue);
}
}
UpdateIsChecked
方法在元素(在此演示中为 TreeViewItem
)上设置附加的 IsChecked
属性。在 TreeViewItem
上设置附加属性本身没有效果。为了让应用程序使用该属性值,它必须绑定到某个东西。在此应用程序中,它绑定到 FooViewModel
对象的 IsChecked
属性。以下 Style
被分配给 TreeView
的 ItemContainerStyle
属性。它将 TreeViewItem
绑定到 FooViewModel
对象,并添加我们刚刚检查的虚拟 ToggleButton
行为。
<Style x:Key="TreeViewItemStyle" TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="True" />
<Setter Property="IsSelected" Value="{Binding IsInitiallySelected, Mode=OneTime}" />
<Setter Property="KeyboardNavigation.AcceptsReturn" Value="True" />
<Setter Property="dw:VirtualToggleButton.IsVirtualToggleButton" Value="True" />
<Setter Property="dw:VirtualToggleButton.IsChecked" Value="{Binding IsChecked}" />
</Style>
这一部分将整个拼图联系在一起。请注意,每个 TreeViewItem
上的附加 KeyboardNavigation.AcceptsReturn
属性都设置为 true
,以便 VirtualToggleButton
会响应回车键切换其选中状态。Style
中的第一个 Setter
将每个项目的 IsExpanded
属性的初始值设置为 true
,确保满足要求 8。
Aero 主题中的复选框错误
我必须指出一个奇怪且令人失望的问题。WPF 中 CheckBox
控件的 Aero 主题在 .NET 3.5 中存在问题。当它从“不确定”状态变为“选中”状态时,框的背景不会正确更新,直到您将鼠标光标移到其上方。您可以在下面的屏幕截图中看到这一点

为了解决这个问题,我将 Royale 主题合并到窗口的 Resources
集合中。使用 Royale 主题时,CheckBox
不会表现出这个缺陷。我真希望微软能在 WPF 的下一个版本中修复这个问题。
修订历史
- 2008 年 8 月 1 日 – 创建文章