Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

Automatic Merging of Menus and Toolbars in WPF

4.40/5 (5 votes)
26 Sep 2010CPOL7 min read 43.4K   1.3K  
This article describes how menus and toolbars in WPF can be automatically merged.

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. ToolBarTrays 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:

C#
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)
{
   // check if object is an ItemsControl or ToolBarTray (is a must for menu hosts)
   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;

   // unregister with old id (if possible) 
   if (!String.IsNullOrWhiteSpace(oldId) && _MergeHosts.ContainsKey(oldId))
   {
      MergeHost host;
      if (_MergeHosts.TryGetValue(oldId, out host))
      {
         host.HostElement = null;
         _MergeHosts.Remove(oldId);
      }
   }
   // register with new id
   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:

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

C#
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;

   // unregister item
   if (!String.IsNullOrWhiteSpace(oldHostId) && _UnmergedItems.Contains(d))
   {
      if (d is FrameworkElement)
      {
         (d as FrameworkElement).Initialized -= UnmergedItem_Initialized;
      }

      _UnmergedItems.Remove(d);
   }

   // register item
   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:

C#
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:

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

C#
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:

C#
internal bool MergeItem(DependencyObject item)
{
   bool itemAdded = false;

   // get the priority of the item (if non is attached use highest priority)
   int priority = MergeMenus.GetPriorityDef(item, Int32.MaxValue);

   if (HostElement != null)
   {
      if (HostElement is ToolBarTray)
      {
         /// special treatment for ToolBarTray hosts because 
         /// a ToolBarTray is no ItemsControl.
         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 item is not already in host add it by priority
         if (!items.Contains(item))
         {
            // iterate from behind...
            for (int n = items.Count - 1; n >= 0; --n)
            {
               var d = items[n] as DependencyObject;
               if (d != null)
               {
                  // ... and add it after 1st existing item with lower or equal priority
                  if (MergeMenus.GetPriority(d) <= priority)
                  {
                     ++n;
                     itemAdded = true;
                     items.Insert(n, item);

                     // add separators where necessary, but not on a main menu
                     if (ShouldAddSeperators())
                     {
                        // if before us is a non separator and its priority 
                        // is different to ours, then insert a separator
                        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 after us is a non separator then add a separator after us
                        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)
            {
               // if item is not added for any reason so far, simply add it
               items.Add(item);
            }
            _MergedItems.Add(item);

            // register a VisibilityChanged notifier to hide separators if necessary
            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 ToolBars 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.

C#
private bool ShouldAddSeperators()
{
   switch(MergeMenus.GetAddSeparator(HostElement))
   {
      case AddSeparatorBehaviour.Add:
         return true;

      case AddSeparatorBehaviour.DontAdd:
         return false;

      default:
         // default is add, except for ToolBarTrays and MainMenus
         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

MergeMenu1.png

Sample application after switching visibility of items and loading the plugin DLL

MergeMenu2.png

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:

C#
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.

XML
<Window.Resources>
  <ResourceDictionary>
    <ResourceDictionary.MergedDictionaries>
      <ResourceDictionary>
	    ...
      </ResourceDictionary>
    </ResourceDictionary.MergedDictionaries>
  </ResourceDictionary>
  
</Window.Resources>

History

  • 2010-09-26: First release

License

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