Introduction
WPF is a powerful UI library, but in some areas it has missing features, which were available in other UI frameworks like WinForms. One of these missing features is automatic merging of menus and toolbars.
Using the Code
First, I want to define which features we need:
- Insert MenuItems into a Menu at any position
- Insert MenuItems into a MenuItem at any position
- Insert Buttons into a ToolBar at any position
- Insert a ToolBar into a ToolBarTray
How can we implement this?
One solution would be to create specialized sub classes by deriving from Menu
, MenuItem
, ToolBarTray
and ToolBar
. But this would have some disadvantages:
- Styles and Themes would get messed up because the types have changed.
- All existing code needs to be refactored to make it work.
The 2nd solution which I have chosen for this article uses attached properties. By this, the types of menus, menu items, ... stays the same.
To merge menus, I decided to separate the items in two categories:
- Hosts: A host is the container where items can be merged into.
- Items: A item can be merged into a host.
Possible hosts are:
Menu
MenuItem
ToolBarTray
ToolBar
Possible Items are:
MenuItems
Button
(actually all sub classes of ButtonBase
) ToolBar
The Hosts
A host is basically a container for items. If we have a look at the class hierarchy of our hosts, then we see that they are derived from ItemsControl
, except for the ToolBarTray
. By this the only restriction for a host is to be derived from ItemsControl
. ToolBarTray
s will be treated specially.
What makes a host a host? As already mentioned, I use attached properties. The attached property which makes a ItemsControl
(or ToolBarTray
) a host is as follows:
public static readonly DependencyProperty IdProperty =
DependencyProperty.RegisterAttached("Id",
typeof(string), typeof(MergeMenus), new FrameworkPropertyMetadata(null, OnIdChanged));
public static void SetId(DependencyObject d, string value)
{
d.SetValue(IdProperty, value);
}
public static string GetId(DependencyObject d)
{
return (string)d.GetValue(IdProperty);
}
private static void OnIdChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (!(d is ItemsControl) && !(d is ToolBarTray))
{
throw new ArgumentException("Attached property \'Id\'
con only be applied to ItemsControls or ToolBarTrays");
}
var oldId = (string)e.OldValue;
var newId = (string)e.NewValue;
if (!String.IsNullOrWhiteSpace(oldId) && _MergeHosts.ContainsKey(oldId))
{
MergeHost host;
if (_MergeHosts.TryGetValue(oldId, out host))
{
host.HostElement = null;
_MergeHosts.Remove(oldId);
}
}
if (!String.IsNullOrWhiteSpace(newId))
{
var host = new MergeHost(newId);
host.HostElement = d as FrameworkElement;
_MergeHosts.Add(newId, host);
}
}
This code defines the attached property with its setter and getter. If the Id property changes, then the host is registered in a list if it was registered before it gets unregistered. The MergeHost
class will be discussed later. There the actual merging happens. The usage of the attached property in XAML looks like this:
<Menu x:Name="mainMenu" mm:MergeMenus.Id="MainMenu">
</Menu>
The Items
If we have a look at our hosts, then we see that an ItemsControl
takes any kind of object as item. But because we want to apply attached properties to them, they need to be DependencyObjects
. A merge item is defined by applying the following attached property to it:
public static readonly DependencyProperty HostIdProperty =
DependencyProperty.RegisterAttached("HostId",
typeof(string), typeof(MergeMenus), new FrameworkPropertyMetadata
(null, OnHostIdChanged));
public static void SetHostId(DependencyObject d, string value)
{
d.SetValue(HostIdProperty, value);
}
public static string GetHostId(DependencyObject d)
{
return (string)d.GetValue(HostIdProperty);
}
private static void OnHostIdChanged
(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var oldHostId = (string)e.OldValue;
var newHostId = (string)e.NewValue;
if (!String.IsNullOrWhiteSpace(oldHostId) && _UnmergedItems.Contains(d))
{
if (d is FrameworkElement)
{
(d as FrameworkElement).Initialized -= UnmergedItem_Initialized;
}
_UnmergedItems.Remove(d);
}
if (!String.IsNullOrWhiteSpace(newHostId))
{
_UnmergedItems.Add(d);
if (d is FrameworkElement)
{
(d as FrameworkElement).Initialized += UnmergedItem_Initialized;
}
}
}
private static void UnmergedItem_Initialized(object sender, EventArgs e)
{
var item = sender as DependencyObject;
var hostId = GetHostId(item);
MergeHost host;
if (_MergeHosts.TryGetValue(hostId, out host))
{
if (host.MergeItem(item))
{
_UnmergedItems.Remove(item);
}
}
}
The HostId
identifies the host in which the item is merged into. The Id
and HostId
properties are not exclusive. By this, a MenuItem
can be merged into an host and being a host itself. When the HostId
changes, the item is added to the _UnmergedItems
list. When the actual merging happens, then the item is removed from the list. We add a handler to the Initialized
event here. The event handler checks if the given host exists and merges the item into it.
The second attached property that applies to items is the Priority
property:
public static readonly DependencyProperty PriorityProperty =
DependencyProperty.RegisterAttached("Priority",
typeof(int), typeof(MergeMenus), new FrameworkPropertyMetadata(0));
public static void SetPriority(DependencyObject d, int value)
{
d.SetValue(PriorityProperty, value);
}
public static int GetPriority(DependencyObject d)
{
return (int)d.GetValue(PriorityProperty);
}
Applying these two properties to an item is all you need to merge it into a host. The default items of a host menu or toolbar should also be extended with the Priority
property. A complete sample could look like this in XAML:
<Window.Resources>
<MenuItem Header="Utilities" x:Key="utilityMenu"
mm:MergeMenus.Id="UtilityMenu" mm:MergeMenus.Priority="50"
mm:MergeMenus.HostId="MainMenu">
<MenuItem Header="Utility 1" mm:MergeMenus.Priority="10"/>
<MenuItem Header="Utility 2" mm:MergeMenus.Priority="10"/>
<MenuItem Header="Utility 3" mm:MergeMenus.Priority="10"/>
</MenuItem>
<MenuItem x:Key="undoToolMenuItem" Header="_Undo"
mm:MergeMenus.HostId="EditMenu" mm:MergeMenus.Priority="10"/>
<MenuItem x:Key="redoToolMenuItem" Header="_Redo"
mm:MergeMenus.HostId="EditMenu" mm:MergeMenus.Priority="10"/>
</Window.Resources>
<DockPanel>
<Menu DockPanel.Dock="Top" x:Name="mainMenu"
mm:MergeMenus.Id="MainMenu">
<MenuItem Header="_File" x:Name="fileMenuItem"
mm:MergeMenus.Id="FileMenu" mm:MergeMenus.Priority="0">
<MenuItem Header="_Exit" x:Name="exitMNenuItem"
mm:MergeMenus.Priority="100000"/>
</MenuItem>
<MenuItem Header="_Edit" x:Name="editMenuItem"
mm:MergeMenus.Id="EditMenu" mm:MergeMenus.Priority="10">
<MenuItem Header="_Cut" x:Name="cutMenuItem" mm:MergeMenus.Priority="0"/>
<MenuItem Header="_Copy" x:Name="copMenuItem" mm:MergeMenus.Priority="0"/>
<MenuItem Header="_Paste" x:Name="pasteMenuItem" mm:MergeMenus.Priority="0"/>
</MenuItem>
<MenuItem Header="_Help" x:Name="helpMenuItem"
mm:MergeMenus.Id="HelpMenu" mm:MergeMenus.Priority="100000">
</MenuItem>
</Menu>
<Grid>
</Grid>
</DockPanel>
This creates a menu like this:
Before merge:
[File] [Edit] [Help]
+-----+ +------+
|Exit | |Cut |
+-----+ |Copy |
|Paste |
+------+
And after Merge:
[File] [Edit] [Utility] [Help]
+-----+ +------+ +----------+
|Exit | |Cut | |Utility 1 |
+-----+ |Copy | |Utility 2 |
|Paste | |Utility 3 |
+------+ +----------+
|Undo |
|Redo |
+------+
You might have noticed the Separator between the Paste and the Undo menu item. I describe how this works in the advanced topics section.
Advanced Topics
In this section, I will describe the actual merging code, adding Separators automatically and hiding items if they are not usable.
First: When does the actual merging happen?
After host and item are initialized.
That's why we register an event handler to the Initialized
event of every host and item. The item event handler is already in the code above. Here we have the host logic which is covered by the class MergeHost
.
internal MergeHost(string id)
{
Id = id;
}
public string Id { get; private set; }
private FrameworkElement _HostElement = null;
public FrameworkElement HostElement
{
get { return _HostElement; }
internal set
{
if (_HostElement != null)
{
_HostElement.Initialized -= HostElement_Initialized;
}
_HostElement = value;
if (_HostElement != null)
{
_HostElement.Initialized += HostElement_Initialized;
}
}
}
private void HostElement_Initialized(object sender, EventArgs e)
{
if (HostElement != null)
{
var id = MergeMenus.GetId(sender as DependencyObject);
foreach (var item in MergeMenus.UnmergedItems.ToList())
{
if (String.CompareOrdinal(id, MergeMenus.GetHostId(item)) == 0)
{
if (MergeItem(item))
{
MergeMenus.UnmergedItems.Remove(item);
}
}
}
}
}
When the FrameworkElement
is set on the MergeHost
, we register the event handler to the Initialized
event. The event handler looks for unmerged items which should be merged into this host and does so.
The merging works as follows:
internal bool MergeItem(DependencyObject item)
{
bool itemAdded = false;
int priority = MergeMenus.GetPriorityDef(item, Int32.MaxValue);
if (HostElement != null)
{
if (HostElement is ToolBarTray)
{
if (item is ToolBar && !(HostElement as ToolBarTray).ToolBars.Contains(item))
{
(HostElement as ToolBarTray).ToolBars.Add(item as ToolBar);
}
itemAdded = true;
}
else
{
var items = (HostElement as ItemsControl).Items;
if (!items.Contains(item))
{
for (int n = items.Count - 1; n >= 0; --n)
{
var d = items[n] as DependencyObject;
if (d != null)
{
if (MergeMenus.GetPriority(d) <= priority)
{
++n;
itemAdded = true;
items.Insert(n, item);
if (ShouldAddSeperators())
{
if (n > 0 && !(items[n - 1] is Separator))
{
int prioBefore = MergeMenus.GetPriority(items[n - 1]
as DependencyObject);
if (priority != prioBefore)
{
var separator = new Separator();
MergeMenus.SetPriority(separator, priority);
items.Insert(n, separator);
_AutoCreatedSeparators.Add(separator);
++n;
}
}
if (n < items.Count - 1 && !(items[n + 1] is Separator))
{
int prioAfter = MergeMenus.GetPriority(items[n + 1]
as DependencyObject);
var separator = new Separator();
MergeMenus.SetPriority(separator, prioAfter);
items.Insert(n + 1, separator);
_AutoCreatedSeparators.Add(separator);
}
}
break;
}
}
}
if (!itemAdded)
{
items.Add(item);
}
_MergedItems.Add(item);
if (item is UIElement)
{
DependencyPropertyDescriptor.FromProperty
(UIElement.VisibilityProperty, item.GetType()).AddValueChanged
(item, Item_VisibilityChanged);
}
CheckSeparatorVisibility(true);
}
else
{
itemAdded = true;
}
}
}
return itemAdded;
}
This method does the actual merging. If the host is a ToolBarTray
, the item is simply added to it. We cannot control the order of the ToolBar
s here because the ToolBars
property of the ToolBarTray
is of the type ICollection
which does not allow indexed access. For other hosts (which are ItemsContols
then), we can handle the order. We iterate through the host items in reverse, and if the host item priority is less or equal to the new item, we insert the new item after the host item.
Now the automatic injection of separators comes in. Normally we separate groups of menu items or toolbar buttons with separators. You can add separators manually in the same way like you add menu items. But the merge host can do this also automatically for you. The logic is simple: Items with the same priority belong to the same group which is separated by Separators. If the item before the new one is not a separator and has a different priority, then insert a separator before the new one. If the item after the new item is a non separator then add a separator behind the new one. But we don't want separators in every menu or toolbar. Main menus for example does not have separators. Here comes the ShouldAddSeperators()
method in.
private bool ShouldAddSeperators()
{
switch(MergeMenus.GetAddSeparator(HostElement))
{
case AddSeparatorBehaviour.Add:
return true;
case AddSeparatorBehaviour.DontAdd:
return false;
default:
return (!(HostElement is ToolBarTray)) &&
(!(HostElement is Menu) || !(HostElement as Menu).IsMainMenu);
}
}
This method does two things: First it reads the attached property AddSeparator
from the host. This property can say Add
, DontAdd
or Default
. In the case of Default
, it checks the kind of host: ToolBarTrays
and Menus
with the property IsMainMenu
set to true
gets no Separators. Every other host gets Separators.
The last topic I want to cover here is removing merged items. I came to the conclusion that removing them is not really necessary. Hiding them is all we need to do. To do so, you can simply set the Visibility
property of the merged items to Collapsed
. Because Visibility
is a dependency property, you can choose between all varieties from binding over styles to directly setting the property.
There is one remaining issue when hiding merged items. If you hide all items between two separators, then you have 2 separators next to another. If the separators have been added automatically, then you have no chance of hiding them manually. That's why the MergeHost
class handles this as well. Every time a merged item changes its visibility, the MergeHost
checks if any automatically added separator needs to be shown or hidden. The code is a bit generic and long. That's why I did not include it in the article. But you can have a look at it in the source download. Just look for the void CheckSeparatorVisibility(bool itemWasHidden)
method.
The Sample Application
The MergeMenuSample
application demonstrates all the features described in this article.
The screenshots below show the sample application after startup and after the utilities have been activated, the undo/redo items was hidden and the plugin was loaded.
Sample application after startup
Sample application after switching visibility of items and loading the plugin DLL
The 'Utility active' checkbox toggles the Utility menu, Utility toolbar and the Utility items in the context menu.
The 'Can undo/redo' checkbox toggles the visibility of the undo and redo items.
The 'Load Plugin' button loads a Plugin DLL which also has a menu and a toolbar.
Points of Interest
One thing needs to be mentioned: The merged items needs to get instantiated to be merged. If you add your items to the Resources of a Window, then this will not happen. You have do something like this:
private void Window_Loaded(object sender, RoutedEventArgs e)
{
FindResource("utilityMenu");
}
This will trigger the merging. In my sample application, you can see that this line of code is commented out. If you put a MergedDictionary
in the resources of the window, the elements in it get instantiated somehow.
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary>
...
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
History
- 2010-09-26: First release