65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 WPF 和 LINQ 改善用户体验

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.55/5 (6投票s)

2009年6月7日

CPOL

4分钟阅读

viewsIcon

39527

downloadIcon

767

构建一个控件来搜索菜单项。

Figure3.jpg

引言

WPF不仅仅是漂亮的界面,更是关于良好的UI用户体验。我一直想着如何提高我应用程序主菜单的可访问性。我不想使用快捷键,而是想寻找一些不同的、对用户更自然的方法。

作为应用程序的用户,我知道我最常访问的菜单名称是什么,于是我决定搜索它们,而不是使用快捷键。

为了提供解决方案,我制作了一个SearchMenuTextBox控件。

一个概念验证

在构建这个控件之前,我做了一个小演示来测试可能性。目标是:

  • 允许搜索菜单项,这些菜单项
    • 是启用的;
    • 没有子菜单项。
  • 找到它们之后
    • 选择一个并通过点击来执行它。

为了实现这些需求,我启动了一个新的WPF应用程序,并这样调整了它的主窗口:

Figure1.jpg

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论坛上展示的扩展。所以,正如我所说的,结果显示在列表框中。

Figure2.jpg

现在我可以搜索菜单项了,但如何执行它们呢?如果选中的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:允许用户在结果中导航

这是通过重写TextBoxKeyDownPreviewKeyDown事件来实现的。

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 - 第一个版本。
© . All rights reserved.