目录
介绍
本文回顾了一个WPF TreeView,其项目包含复选框。每个项目都绑定到一个ViewModel对象。当ViewModel对象的检查状态发生变化时,它会将简单的规则应用于其父项和子项的检查状态。本文还展示了如何使用附加行为概念将一个TreeViewItem变为虚拟ToggleButton,这有助于使TreeView的键盘交互简单直观。
本文假设读者已经熟悉数据绑定和模板、使用ViewModel模式简化WPF TreeView及附加属性。
背景
TreeView的项是复选框,这是很常见的,比如在向用户展示一组要选择的分层选项时。在某些UI平台(例如WinForms)中,标准TreeView控件提供了对在其项中显示复选框的内置支持。由于元素组合和丰富的数据绑定是WPF的两个核心方面,因此WPF不提供对显示复选框的内在支持。在TreeView的ItemTemplate中声明一个CheckBox控件是很容易的,突然间树中的每一项都包含了一个CheckBox。向IsChecked属性添加一个简单的{Binding}表达式,这些框的复选状态就会被绑定到底层数据对象的某些属性上。对于WPF TreeView来说,拥有一个特定于在其项目中显示复选框的API是多余的。
细节决定成败
这听起来好得令人难以置信,而且确实如此。从键盘导航的角度来看,让TreeView“感觉正确”并不是那么简单。基本问题是,当您通过箭头键导航树时,TreeViewItem将首先获取输入焦点,然后它包含的CheckBox将焦点放在下一次击键上。TreeViewItem和CheckBox控件都是可聚焦的。结果是您必须按两次箭头键才能在树中从一个项目导航到另一个项目。这绝对不是一种可接受的用户体验,并且您无法设置任何简单的属性来使其正常工作。我已经将这个问题提请微软WPF团队的某个关键成员注意,因此他们可能会在该平台的未来版本中解决这个问题。
功能要求
在我们开始检查这个演示程序是如何工作的之前,首先我们将回顾它的作用。这是演示应用程序的屏幕截图:
现在让我们看看功能需求是什么:
要求 1:树中的每个项目都必须显示一个复选框,以显示底层数据对象的文本和检查状态。
要求2:当一个项目被选中或取消选中时,它的所有子项目应该分别被选中或取消选中。
要求 3:如果一个项目的后代并非都具有相同的检查状态,则该项目的检查状态必须是“不确定的”。
要求 4:从一个项目导航到另一个项目应该只需要按一次箭头键。
要求 5:按空格键或Enter键应切换所选项目的检查状态。
要求 6:单击项目的复选框应切换其检查状态,但不选择项目。
要求 7:单击项目的显示文本应选择项目,但不能切换其检查状态。
要求 8:默认情况下,树中的所有项目都应处于展开状态。
我建议您复制这些要求并将它们粘贴到您最喜欢的文本编辑器中,例如记事本,因为我们将在文章的其余部分按编号引用它们。
将Smarts放入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如何能够显示复选框并绑定到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>
该模板有几个有趣的地方。该模板包括一个其Focusable属性设置为false的CheckBox。这可以防止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,它如何切换其检查状态以响应空格键或Enter键?由于元素只有在具有键盘焦点时才会接收击键,因此似乎不可能满足要求5。记住, 我们必须将CheckBox的Focusable属性设置为false,以便在树中从一个项目导航到另一个项目不需要多次击键。
这是一个棘手的问题:我们不能让CheckBox输入焦点,因为它会对键盘导航产生负面影响,但是,当它的包含项被选中时,它必须以某种方式切换其检查状态以响应某些击键。这些似乎是相互排斥的要求。当我碰到这个问题时,我决定向WPF门徒寻求帮助,并开始了这个线程。令我惊讶的是,WPF医生已经遇到了这类问题,并设计了一个非常接近天才的解决方案,该解决方案很容易插入到我的应用程序中。好医生给我发了一个VirtualToggleButton类的代码,并且很友好地允许我在这篇文章中发布它。
医生的解决方案使用了约翰·戈斯曼所说的“附加行为”。这个想法是您在元素上设置附加属性,以便您可以从公开附加属性的类中访问该元素。一旦该类可以访问该元素,它就可以在其上挂钩事件,并响应这些事件触发,使该元素执行它通常不会执行的操作。它是创建和使用子类的一种非常方便的替代方法,并且对XAML非常友好。
在本文中,我们将了解如何提供TreeViewItem一个附加IsChecked属性,该属性在用户按下空格键或Enter键时进行切换。附加的IsChecked属性绑定到FooViewModel对象的IsChecked属性,而FooViewModel对象也绑定到TreeViewItem中CheckBox的IsChecked属性。该解决方案看起来CheckBox正在切换其检查状态以响应空格键或Enter键,但实际上,它的IsChecked属性会更新以响应TreeViewItem通过数据绑定将新值推送到ViewModel的IsChecked属性。
在我看来,这是WPF v3.5中实现TreeView复选框的最干净的方式,这表明微软需要简化平台的这方面。对我来说,微软需要简化平台的这一方面。但是,在他们这样做之前,这可能是实现该功能的最佳方式。
在此演示中,我们没有使用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方法在一个元素上设置附加属性IsChecked,在这个演示中是一个TreeViewItem。在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将切换其检查状态以响应Enter键。Style中的第一个Setter将每个项的IsExpanded属性的初始值设置为true,确保满足要求8。
Aero主题中的复选框错误
我必须指出一个奇怪且令人失望的问题。WPF CheckBox控件的Aero主题在.NET 3.5中存在问题。当它从“不确定”状态移动到“已检查”状态时,框的背景不会正确更新,直到您将鼠标光标移到它上面。您可以在下面的屏幕截图中看到这一点:
为了解决这个问题,我将Royale主题合并到窗口的Resources集合中。使用CheckBox Royale主题时不会出现此缺陷。我真的希望微软在下一版本的WPF中解决这个问题。
https://www.codeproject.com/Articles/28306/Working-with-Checkboxes-in-the-WPF-TreeView