Click here to Skip to main content
Click here to Skip to main content
Go to top

Improving user experience using WPF and LINQ

, 7 Jun 2009
Rate this:
Please Sign up or sign in to vote.
Building a control to search MenuItems of a Menu.

Figure3.jpg

Introduction

WPF is more than a beautiful interface, it's about good user experiences on UI. Having that on mind, I was trying to find a way out to improve accessibility for main menus of applications of mine. Instead of using hotkeys, I wanted something different, more natural for users.

As a user of applications, I know which menu names are most accessed by me, and decided to search for them instead of use a hotkey.

To providing a solution, I made a SearchMenuTextBox control.

A proof of concept

Before building this control, I did a little demo, to test possibilities. The goals here are:

  • Allow searching for menu items that:
    • are enabled;
    • do not have subitems.
  • After finding them:
    • Select one and execute it by clicking on it.

To accomplish these requirements, I started a new WPF application and adjusted its main window like this:

Figure1.jpg

In Window1, I have a main menu:

<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>

See that on the MenuItem "item1.3", we have a click event implemented:

private void MenuItem_Click(object sender, RoutedEventArgs e)
{
   MessageBox.Show("Menu");
}

The result of searching will be in the list box, and to perform the search, we have a button.

LINQ to objects

LINQ is wonderful. Using it, we can search for objects that match some criteria. For this demo, the criterion are MenuItems of a main menu that have a "3" in the header and do not have subitems:

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();
}

As you can see, using LINQ make things easy. But, to do a recursive search, I am using an extension shown on MSDN Forums. So, like I said, the result is shown in a listbox:

Figure2.jpg

Now I can search for menu items, but how can I execute one? Well, if the selected MenuItem has an associated Command, it is possible to execute that Command. But, if not, we need to use the Automation API, like shown here:

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(); 
    }
   }
}

Building the control

Now that the idea is tested, let's build a control that encapsulates the searching. Well, I'm not experienced on developing controls for WPF, neither am I a good designer, so I Googled about how to build a control. I found two good resources:

I decided to take good ideas from both, and build a new control inheriting from the WPF Search Text Box. Please look at these articles because I will not cover issue like templating a control.

The idea for this control is to search as go. As the user types, we search for MenuItems. The first thing I did following the WPF Search Text Box was to make a template. I just copied its original template to a new one:

<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>

In this template, we have defined inside the LayoutGrid a Popup, and inside it, a ListBox. That ListBox will keep the results from the menu search. Now, we code a class inheriting from SearchTextBox. This class gets the controls defined on the template:

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; } }

{...}

};

Also, when a template is applied, we override some methods to get the functionalities:

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);
}

Functionality 1: Search as go

By overriding the on change event of the TextBox, we can query as typing occurs:

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; 
    }
   }
}

In this code, we are checking if the text property has some valid text. If not, we close the popup; but if it has a valid text, we do a search, and if we get results, they are bound to the ListBox ItemList, and the popup is opened, showing them. But, before doing all that, I check for a dependency property MainMenu. This property represents the searched menu. See the code below:

public static DependencyProperty MainMenuProperty =
       DependencyProperty.Register(
          "MainMenu",
          typeof(Menu),
          typeof(SearchMenuTextBox));

public Menu MainMenu
{
  get { return (Menu)GetValue(MainMenuProperty); }
  set { SetValue(MainMenuProperty, value); }
}

Functionality 2: Allow users to navigate in results

This is done by overriding the KeyDown and PreviewKeyDown events of the TextBox.

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;
         }
   } 
}

In the PreviewKeyDown event, we check if TextBox had focus when the key was pressed. That way we change the focus to ListBox, allowing the users to navigate on its items. On the KeyDown event, we just close the popup on the Escape key and the Enter key.

Functionality 3: Allow users to execute the MenuItem selected by the keyboard

I implemented a method for this: void ExecuteItem(MenuItem itemMenu). When the user presses the Enter key over a listbox item, we get the associated MenuItem and call ExecuteItem, sending the selected MenuItem as a parameter:

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();
  }
}

Functionality 4: Allow users to execute selected item by clicking on it

That is easy too. We implement the DoubleClick event:

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();
  }
}

Notice that we are calling the UpdateSource method. It only refreshes the binding:

void updateSource()
{
  if (this.GetBindingExpression(TextBox.TextProperty) != null)
     this.GetBindingExpression(TextBox.TextProperty).UpdateSource();
}

Using the control

To use it is very simple:

<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" />

Conclusion

WPF is great! Using its capabilities, we have created a new control, a composite control to increase user experience. Also, we saw how LINQ makes thing easier.

I just want to thank Leung Yat Chun Joseph and David Owens for sharing their code with the community. Without their code, I would not have been able to build this control.

Points of interest

As I said in the beginning, I'm not experienced in building WPF controls, so, if you have some suggestions to improve the code or usability, just tell me.

History

  • 06-07-2009 - First version.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

quicoli
Software Developer (Senior)
Brazil Brazil
Editor of .Net Magazine, WebMobile Magazine and Clube Delphi Magazine (www.devmedia.com.br)

Comments and Discussions

 
Bugpop up bug Pinmemberbg_bindi14-Apr-13 18:53 
GeneralGreat control!! PinmemberMember 333537726-Aug-09 4:27 
GeneralRe: Great control!! Pinmemberquicoli26-Aug-09 5:43 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web02 | 2.8.140926.1 | Last Updated 7 Jun 2009
Article Copyright 2009 by quicoli
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid