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






4.40/5 (5投票s)
本文介绍了如何在 WPF 中自动合并菜单和工具栏。
引言
WPF 是一个强大的 UI 库,但在某些方面它缺少一些在其他 UI 框架(如 WinForms)中可用的功能。其中一个缺失的功能是菜单和工具栏的自动合并。
Using the Code
首先,我想定义我们需要哪些功能
- 在菜单的任意位置插入菜单项
- 在菜单项的任意位置插入菜单项
- 在工具栏的任意位置插入按钮
- 将工具栏插入到工具栏托盘中
我们该如何实现呢?
一种解决方案是通过从 Menu
、MenuItem
、ToolBarTray
和 ToolBar
派生来创建专门的子类。但这会有一些缺点
- 由于类型已更改,样式和主题会变得混乱。
- 所有现有代码都需要重构才能使其工作。
本文选择的第二种解决方案使用附加属性。通过这种方式,菜单、菜单项等的类型保持不变。
为了合并菜单,我决定将项目分为两类
- 主机:主机是项目可以合并到的容器。
- 项目:项目可以合并到主机中。
可能的主机有
Menu
MenuItem
ToolBarTray
ToolBar
可能的项目有
菜单项
Button
(实际上是ButtonBase
的所有子类)ToolBar
主机
主机基本上是项目的容器。如果我们查看主机类层次结构,我们会发现它们都派生自 ItemsControl
,除了 ToolBarTray
。因此,主机的唯一限制是派生自 ItemsControl
。ToolBarTray
将被特殊处理。
是什么让一个控件成为主机?如前所述,我使用附加属性。使 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
标识项目合并到的主机。Id
和 HostId
属性不是互斥的。因此,一个 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
的顺序,因为 ToolBarTray
的 ToolBars
属性是 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
。此属性可以设置为 Add
、DontAdd
或 Default
。在 Default
的情况下,它会检查主机的类型:设置为 true
的 ToolBarTrays
和具有 IsMainMenu
属性的 Menus
不会获得分隔符。所有其他主机都会获得分隔符。
我想在这里讨论的最后一个主题是删除合并项。我得出的结论是,删除它们并不是真正必要的。我们只需要隐藏它们即可。为此,您可以简单地将合并项的 Visibility
属性设置为 Collapsed
。因为 Visibility
是一个依赖属性,所以您可以选择从绑定到样式到直接设置属性的所有方式。
隐藏合并项时仍然存在一个问题。如果您隐藏两个分隔符之间的所有项目,那么您将有两个相邻的分隔符。如果分隔符是自动添加的,那么您无法手动隐藏它们。这就是 MergeHost
类也处理此问题的原因。每当合并项的可见性发生变化时,MergeHost
都会检查是否有任何自动添加的分隔符需要显示或隐藏。代码有点通用且很长。这就是我没有将其包含在文章中的原因。但您可以在源代码下载中查看它。只需查找 void CheckSeparatorVisibility(bool itemWasHidden)
方法。
示例应用程序
MergeMenuSample
应用程序演示了本文中描述的所有功能。
下面的屏幕截图显示了启动后以及实用程序被激活、撤消/重做项被隐藏和插件加载后的示例应用程序。
“实用程序活动”复选框切换实用程序菜单、实用程序工具栏和上下文菜单中的实用程序项。
“可以撤消/重做”复选框切换撤消和重做项的可见性。
“加载插件”按钮加载一个也有菜单和工具栏的插件 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:首次发布