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:
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 MenuItem
s 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:
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 MenuItem
s. 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.