使用 WPF 和 LINQ 改善用户体验
构建一个控件来搜索菜单项。
引言
WPF不仅仅是漂亮的界面,更是关于良好的UI用户体验。我一直想着如何提高我应用程序主菜单的可访问性。我不想使用快捷键,而是想寻找一些不同的、对用户更自然的方法。
作为应用程序的用户,我知道我最常访问的菜单名称是什么,于是我决定搜索它们,而不是使用快捷键。
为了提供解决方案,我制作了一个SearchMenuTextBox
控件。
一个概念验证
在构建这个控件之前,我做了一个小演示来测试可能性。目标是:
- 允许搜索菜单项,这些菜单项
- 是启用的;
- 没有子菜单项。
- 找到它们之后
- 选择一个并通过点击来执行它。
为了实现这些需求,我启动了一个新的WPF应用程序,并这样调整了它的主窗口:
在Window1
中,我有一个主菜单
<Menu x:Name="MenuPrincipal">
<MenuItem Header="item1">
<MenuItem Header="item1.1"/>
<MenuItem Header="item1.2"/>
<MenuItem Header="item1.3" Click="MenuItem_Click"/>
<MenuItem Header="item1.4"/>
</MenuItem>
<MenuItem Header="item2"/>
<MenuItem Header="item3">
<MenuItem Header="item3.1"/>
<MenuItem Header="item3.2"/>
<MenuItem Header="item3.3"/>
<MenuItem Header="item3.4"/>
</MenuItem>
</Menu>
请注意,在MenuItem
“item1.3”上,有一个已实现的点击事件。
private void MenuItem_Click(object sender, RoutedEventArgs e)
{
MessageBox.Show("Menu");
}
搜索结果将显示在列表框中,并且为了执行搜索,我们有一个按钮。
LINQ to Objects
LINQ太棒了。使用它,我们可以搜索符合某些条件的项。在这个演示中,条件是主菜单中那些头部带有“3”且没有子菜单项的MenuItem
。
private void Button_Click(object sender, RoutedEventArgs e)
{
var x = from c in MenuPrincipal.Items.OfType<MenuItem>().Traverse(
c => c.Items.OfType<MenuItem>())
where c.Header.ToString().Contains("3")
&& c.HasItems == false
orderby c.Header
select c;
ListaMenus.DisplayMemberPath = "Header";
ListaMenus.ItemsSource = x.ToList();
}
正如你所见,使用LINQ让事情变得很容易。但是,为了进行递归搜索,我使用了一个在MSDN论坛上展示的扩展。所以,正如我所说的,结果显示在列表框中。
现在我可以搜索菜单项了,但如何执行它们呢?如果选中的MenuItem
有关联的Command
,那么就可以执行该Command
。但如果不是,我们就需要使用Automation API,就像这里所示:
private void ListaMenus_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
ExecuteMenuItem();
}
private void ExecuteMenuItem()
{
if (ListaMenus.SelectedItem!=null)
{
var itemMenu = (ListaMenus.SelectedItem as MenuItem);
if (itemMenu.Command != null)
{
itemMenu.Command.Execute(null);
}
else
{
MenuItemAutomationPeer peer = new MenuItemAutomationPeer(itemMenu);
IInvokeProvider invokeProv =
peer.GetPattern(PatternInterface.Invoke) as IInvokeProvider;
invokeProv.Invoke();
}
}
}
构建控件
现在想法已经测试过了,让我们构建一个封装搜索功能的控件。嗯,我对开发WPF控件没有经验,也不是一个好的设计者,所以我*谷歌*了一下如何构建控件。我找到了两个很好的资源:
我决定从这两个资源中汲取好的想法,并构建一个新的控件,它继承自WPF Search Text Box。请查阅这些文章,因为我不会涵盖诸如*模板化控件*之类的内容。
这个控件的想法是*边输入边搜索*。当用户键入时,我们就搜索MenuItem
。我做的第一件事是遵循WPF Search Text Box,制作了一个模板。我只是将它的原始模板复制到了一个新模板中。
<Style x:Key="{x:Type l:SearchMenuTextBox}" TargetType="{x:Type l:SearchMenuTextBox}">
<Setter Property="Background" Value="{StaticResource SearchTextBox_Background}" />
<Setter Property="BorderBrush" Value="{StaticResource SearchTextBox_Border}" />
<Setter Property="Foreground" Value="{StaticResource SearchTextBox_Foreground}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="LabelText" Value="Search" />
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="LabelTextColor"
Value="{StaticResource SearchTextBox_LabelTextColor}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type l:SearchMenuTextBox}">
<Border x:Name="Border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid x:Name="LayoutGrid">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition
Width="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=ActualHeight}" />
</Grid.ColumnDefinitions>
<ScrollViewer Margin="2"
x:Name="PART_ContentHost" Grid.Column="0" />
<Popup x:Name="PART_Popup"
AllowsTransparency="true" Grid.Column="0"
Placement="Bottom" IsOpen="False"
Width="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=ActualWidth}"
PopupAnimation="{DynamicResource {x:Static
SystemParameters.ComboBoxPopupAnimationKey}}">
<ListBox x:Name="PART_ItemList"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
VerticalContentAlignment="Stretch"
HorizontalContentAlignment="Stretch"
KeyboardNavigation.DirectionalNavigation="Contained" />
</Popup>
<Label x:Name="LabelText"
Margin="2"
Grid.Column="0"
Foreground="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=LabelTextColor}"
Content="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=LabelText}"
Padding="2,0,0,0"
FontStyle="Italic" />
<Border x:Name="PART_SearchIconBorder"
Grid.Column="1"
BorderThickness="1"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
BorderBrush="{StaticResource SearchTextBox_SearchIconBorder}"
Background="{StaticResource SearchTextBox_SearchIconBackground}">
<Image x:Name="SearchIcon"
Stretch="None"
Width="15"
Height="15"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Source="pack://application:,,,/UIControls;
component/Images/search.png" />
</Border>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush"
Value="{StaticResource SearchTextBox_BorderMouseOver}" />
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter Property="BorderBrush"
Value="{StaticResource SearchTextBox_BorderMouseOver}" />
</Trigger>
<Trigger Property="HasText" Value="True">
<Setter Property="Visibility"
TargetName="LabelText" Value="Hidden" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="HasText" Value="True" />
<Condition Property="SearchMode" Value="Instant" />
</MultiTrigger.Conditions>
<Setter Property="Source"
TargetName="SearchIcon"
Value="pack://application:,,,/UIControls;
component/Images/clear.png" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver"
SourceName="PART_SearchIconBorder"
Value="True" />
<Condition Property="HasText" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="BorderBrush"
TargetName="PART_SearchIconBorder"
Value="{StaticResource SearchTextBox_SearchIconBorder_MouseOver}" />
<Setter Property="Background"
TargetName="PART_SearchIconBorder"
Value="{StaticResource
SearchTextBox_SearchIconBackground_MouseOver}" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver"
SourceName="PART_SearchIconBorder" Value="True" />
<Condition Property="IsMouseLeftButtonDown" Value="True" />
<Condition Property="HasText" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Padding"
TargetName="PART_SearchIconBorder"
Value="2,0,0,0" />
<Setter Property="BorderBrush"
TargetName="PART_SearchIconBorder"
Value="{StaticResource SearchTextBox_
SearchIconBorder_MouseOver}" />
<Setter Property="Background"
TargetName="PART_SearchIconBorder"
Value="{StaticResource SearchTextBox_
SearchIconBackground_MouseOver}" />
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
在这个模板中,我们在LayoutGrid
内部定义了一个Popup
,并在其中定义了一个ListBox
。该ListBox
将保存菜单搜索的结果。现在,我们编写一个继承自SearchTextBox
的类。这个类获取模板中定义的控件。
public class SearchMenuTextBox: SearchTextBox
{
Popup Popup { get {return this.Template.FindName("PART_Popup", this) as Popup;} }
ListBox ItemList {get {
return this.Template.FindName("PART_ItemList", this) as ListBox; }}
ScrollViewer Host {get { return this.Template.FindName("PART_ContentHost",
this) as ScrollViewer; }}
UIElement TextBoxView { get { foreach (object o in
LogicalTreeHelper.GetChildren(Host)) return o as UIElement; return null; } }
{...}
};
另外,当应用模板时,我们重写一些方法来获取功能:
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
this.KeyDown += new KeyEventHandler(SearchMenuTextBoxKeyDown);
this.PreviewKeyDown += new KeyEventHandler(SearchMenuTextoBoxPreviewKeyDown);
ItemList.KeyDown += new KeyEventHandler(ItemListKeyDown);
ItemList.MouseDoubleClick +=
new MouseButtonEventHandler(ItemList_MouseDoubleClick);
}
功能1:边输入边搜索
通过重写TextBox
的*更改时*事件,我们可以在键入时进行查询。
protected override void OnTextChanged(TextChangedEventArgs e)
{
base.OnTextChanged(e);
if (MainMenu != null)
{
if (String.IsNullOrEmpty(this.Text))
{
ItemList.ItemsSource = null;
Popup.IsOpen = false;
return;
}
var x = from c in MainMenu.Items.OfType<MenuItem>().Transverse(
c => c.Items.OfType<MenuItem>())
where c.Header.ToString().ToUpperInvariant().Contains(
this.Text.ToUpperInvariant())
&& c.HasItems == false && c.IsEnabled
orderby c.Header
select c;
if (x.ToList().Count > 0)
{
ItemList.DisplayMemberPath = "Header";
ItemList.ItemsSource = x.ToList();
Popup.IsOpen = true;
}
else
{
ItemList.ItemsSource = null;
Popup.IsOpen = false;
}
}
}
在这段代码中,我们检查文本属性是否包含有效文本。如果没有,我们就关闭弹出窗口;但如果包含有效文本,我们就进行搜索,如果找到结果,则将它们绑定到ListBox ItemList
,并打开弹出窗口显示它们。但在执行所有这些之前,我检查了一个依赖属性MainMenu
。此属性代表要搜索的菜单。请看下面的代码:
public static DependencyProperty MainMenuProperty =
DependencyProperty.Register(
"MainMenu",
typeof(Menu),
typeof(SearchMenuTextBox));
public Menu MainMenu
{
get { return (Menu)GetValue(MainMenuProperty); }
set { SetValue(MainMenuProperty, value); }
}
功能2:允许用户在结果中导航
这是通过重写TextBox
的KeyDown
和PreviewKeyDown
事件来实现的。
void SearchMenuTextoBoxPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Down && ItemList.Items.Count > 0 &&
!(e.OriginalSource is ListBoxItem))
{
ItemList.Focus();
ItemList.SelectedIndex = 0;
ListBoxItem lbi =
ItemList.ItemContainerGenerator.ContainerFromIndex(
ItemList.SelectedIndex) as ListBoxItem;
lbi.Focus();
e.Handled = true;
}
}
void SearchMenuTextBoxKeyDown(object sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Enter:
{
Popup.IsOpen = false;
updateSource();
break;
}
case Key.Escape:
{
Popup.IsOpen = false;
this.Focus();
break;
}
}
}
在PreviewKeyDown
事件中,我们检查按键时TextBox
是否获得了焦点。这样,我们就将焦点转移到ListBox
,允许用户在其项目之间导航。在KeyDown
事件中,我们只需在按下Escape键和Enter键时关闭弹出窗口。
功能3:允许用户通过键盘执行选中的菜单项
我为此实现了一个方法:void ExecuteItem(MenuItem itemMenu)
。当用户在列表框项目上按下Enter键时,我们获取关联的MenuItem
并调用ExecuteItem
,将选中的MenuItem
作为参数传递。
void ItemListKeyDown(object sender, KeyEventArgs e)
{
if (e.OriginalSource is ListBoxItem)
{
ListBoxItem item = e.OriginalSource as ListBoxItem;
Text = (item.Content as string);
if (e.Key == Key.Enter)
{
if (item != null)
{
item.IsSelected = true;
Text = (item.Content as MenuItem).Header.ToString();
var m = (item.Content as MenuItem);
ExecuteMenuItem(m);
Popup.IsOpen = false;
updateSource();
}
}
}
}
private void ExecuteMenuItem(MenuItem itemMenu)
{
if (itemMenu.Command != null)
{
itemMenu.Command.Execute(null);
}
else
{
MenuItemAutomationPeer peer = new MenuItemAutomationPeer(itemMenu);
IInvokeProvider invokeProv =
peer.GetPattern(PatternInterface.Invoke) as IInvokeProvider;
invokeProv.Invoke();
}
}
功能4:允许用户通过点击执行选中的项目
这也很容易。我们实现DoubleClick
事件。
void ItemList_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (ItemList.SelectedItem != null)
{
var selectedMenuItem = (ItemList.SelectedItem as MenuItem);
Text = selectedMenuItem.Header.ToString();
ExecuteMenuItem(selectedMenuItem);
Popup.IsOpen = false;
updateSource();
}
}
请注意,我们调用了UpdateSource
方法。它只是刷新绑定。
void updateSource()
{
if (this.GetBindingExpression(TextBox.TextProperty) != null)
this.GetBindingExpression(TextBox.TextProperty).UpdateSource();
}
使用控件
使用起来非常简单:
<l:SearchMenuTextBox MainMenu="{Binding ElementName=MainMenu}"
Style="{StaticResource {x:Type l:SearchMenuTextBox}}"
LabelText="Fast access to menu items"
Height="21" SearchMode= "Instant"
HorizontalAlignment="Center" Width="200" />
结论
WPF太棒了!利用其功能,我们创建了一个新的复合控件,以提高用户体验。此外,我们还看到了LINQ是如何让事情变得更容易的。
我只想感谢Leung Yat Chun Joseph和David Owens与社区分享他们的代码。没有他们的代码,我就无法构建这个控件。
关注点
正如我开头所说,我在构建WPF控件方面没有经验,所以如果你对改进代码或可用性有什么建议,请告诉我。
历史
- 2009-06-07 - 第一个版本。