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

WPF 中菜单和工具栏的自动合并

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.40/5 (5投票s)

2010年9月26日

CPOL

7分钟阅读

viewsIcon

44092

downloadIcon

1346

本文介绍了如何在 WPF 中自动合并菜单和工具栏。

引言

WPF 是一个强大的 UI 库,但在某些方面它缺少一些在其他 UI 框架(如 WinForms)中可用的功能。其中一个缺失的功能是菜单和工具栏的自动合并。

Using the Code

首先,我想定义我们需要哪些功能

  • 在菜单的任意位置插入菜单项
  • 在菜单项的任意位置插入菜单项
  • 在工具栏的任意位置插入按钮
  • 将工具栏插入到工具栏托盘中

我们该如何实现呢?

一种解决方案是通过从 MenuMenuItemToolBarTrayToolBar 派生来创建专门的子类。但这会有一些缺点

  • 由于类型已更改,样式和主题会变得混乱。
  • 所有现有代码都需要重构才能使其工作。

本文选择的第二种解决方案使用附加属性。通过这种方式,菜单、菜单项等的类型保持不变。

为了合并菜单,我决定将项目分为两类

  • 主机:主机是项目可以合并到的容器。
  • 项目:项目可以合并到主机中。

可能的主机有

  • Menu
  • MenuItem
  • ToolBarTray
  • ToolBar

可能的项目有

  • 菜单项
  • Button(实际上是 ButtonBase 的所有子类)
  • ToolBar

主机

主机基本上是项目的容器。如果我们查看主机类层次结构,我们会发现它们都派生自 ItemsControl,除了 ToolBarTray。因此,主机的唯一限制是派生自 ItemsControlToolBarTray 将被特殊处理。

是什么让一个控件成为主机?如前所述,我使用附加属性。使 ItemsControl(或 ToolBarTray)成为主机的附加属性如下

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

此代码定义了带有 setter 和 getter 的附加属性。如果 Id 属性发生更改,则主机会在注册前先注销,然后再注册到列表中。MergeHost 类将在稍后讨论。实际的合并在那里发生。附加属性在 XAML 中的用法如下所示

<Menu x:Name="mainMenu" mm:MergeMenus.Id="MainMenu">
</Menu>

项目

如果我们看一下我们的主机,我们会发现 ItemsControl 将任何类型的对象作为项目。但因为我们想对它们应用附加属性,所以它们需要是 DependencyObjects。通过应用以下附加属性来定义合并项

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

HostId 标识项目合并到的主机。IdHostId 属性不是互斥的。因此,一个 MenuItem 可以合并到主机中,并且本身也可以是主机。当 HostId 更改时,项目将添加到 _UnmergedItems 列表中。当实际合并发生时,该项目将从列表中删除。我们在此处为 Initialized 事件添加了一个处理程序。事件处理程序检查给定主机是否存在并将项目合并到其中。

应用于项目的第二个附加属性是 Priority 属性

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

将这两个属性应用于项目是将其合并到主机所需的全部操作。主机菜单或工具栏的默认项目也应该用 Priority 属性扩展。一个完整的示例在 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>

这将创建一个如下所示的菜单
合并前

[File]   [Edit]    [Help]
+-----+  +------+
|Exit |  |Cut   |
+-----+  |Copy  |
         |Paste |
         +------+

合并后

[File]   [Edit]    [Utility]     [Help]
+-----+  +------+  +----------+
|Exit |  |Cut   |  |Utility 1 |
+-----+  |Copy  |  |Utility 2 |
         |Paste |  |Utility 3 |
         +------+  +----------+
         |Undo  |
         |Redo  |
         +------+
您可能已经注意到“粘贴”和“撤消”菜单项之间的分隔符。我将在高级主题部分描述其工作原理。

高级主题 (Advanced Topics)

在本节中,我将描述实际的合并代码,自动添加分隔符以及在项目不可用时隐藏它们。

首先:实际合并何时发生?
主机和项目初始化后。

这就是为什么我们为每个主机和项目的 Initialized 事件注册一个事件处理程序的原因。项目事件处理程序已在上面的代码中。这里我们有由 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);
            }
         }
      }
   }
}

FrameworkElement 设置在 MergeHost 上时,我们为 Initialized 事件注册事件处理程序。事件处理程序查找应合并到此主机中的未合并项目并执行合并。

合并的工作原理如下

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

此方法执行实际的合并。如果主机是 ToolBarTray,则项目会简单地添加到其中。我们无法在此处控制 ToolBar 的顺序,因为 ToolBarTrayToolBars 属性是 ICollection 类型,不允许索引访问。对于其他主机(即 ItemsContols),我们可以处理顺序。我们反向遍历主机项目,如果主机项目的优先级小于或等于新项目,我们将在主机项目之后插入新项目。

现在自动注入分隔符进来了。通常我们使用分隔符分隔菜单项或工具栏按钮组。您可以像添加菜单项一样手动添加分隔符。但是合并主机也可以为您自动完成此操作。逻辑很简单:具有相同优先级的项目属于同一组,由分隔符分隔。如果新项目之前的项目不是分隔符并且优先级不同,则在新项目之前插入一个分隔符。如果新项目之后的项目不是分隔符,则在新项目之后添加一个分隔符。但是我们不希望每个菜单或工具栏中都有分隔符。例如,主菜单没有分隔符。这里就用到了 ShouldAddSeperators() 方法。

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

此方法执行两件事:首先它从主机读取附加属性 AddSeparator。此属性可以设置为 AddDontAddDefault。在 Default 的情况下,它会检查主机的类型:设置为 trueToolBarTrays 和具有 IsMainMenu 属性的 Menus 不会获得分隔符。所有其他主机都会获得分隔符。

我想在这里讨论的最后一个主题是删除合并项。我得出的结论是,删除它们并不是真正必要的。我们只需要隐藏它们即可。为此,您可以简单地将合并项的 Visibility 属性设置为 Collapsed。因为 Visibility 是一个依赖属性,所以您可以选择从绑定到样式到直接设置属性的所有方式。

隐藏合并项时仍然存在一个问题。如果您隐藏两个分隔符之间的所有项目,那么您将有两个相邻的分隔符。如果分隔符是自动添加的,那么您无法手动隐藏它们。这就是 MergeHost 类也处理此问题的原因。每当合并项的可见性发生变化时,MergeHost 都会检查是否有任何自动添加的分隔符需要显示或隐藏。代码有点通用且很长。这就是我没有将其包含在文章中的原因。但您可以在源代码下载中查看它。只需查找 void CheckSeparatorVisibility(bool itemWasHidden) 方法。

示例应用程序

MergeMenuSample 应用程序演示了本文中描述的所有功能。
下面的屏幕截图显示了启动后以及实用程序被激活、撤消/重做项被隐藏和插件加载后的示例应用程序。

启动后的示例应用程序

MergeMenu1.png

切换项目可见性和加载插件 DLL 后的示例应用程序

MergeMenu2.png

“实用程序活动”复选框切换实用程序菜单、实用程序工具栏和上下文菜单中的实用程序项。
“可以撤消/重做”复选框切换撤消和重做项的可见性。
“加载插件”按钮加载一个也有菜单和工具栏的插件 DLL。

关注点

需要提及一件事:合并项需要被实例化才能合并。如果您将项添加到窗口的资源中,则不会发生这种情况。您必须这样做

private void Window_Loaded(object sender, RoutedEventArgs e)
{
  FindResource("utilityMenu");
}

这将触发合并。在我的示例应用程序中,您可以看到此行代码已注释掉。如果您将 MergedDictionary 放入窗口的资源中,其中的元素会以某种方式被实例化。

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

历史

  • 2010-09-26:首次发布
© . All rights reserved.