×

.NET中的自绘机制

Kalet Kalet 发表于2009-03-20 12:00:14 浏览310 评论0

抢沙发发表评论

.NET中的自绘机制

2008-02-26 18:31

.NET中的自绘机制



原文出处:MSDN Magazine Feb 2004(Cutting Edge)

原代码下载:CuttingEdge0402.exe (182KB)

  每次 Microsoft 推出象 Office 或者 Visual Studio 这样拳头产品的新版本时,都会推出一些新的特性,其中包括了新的菜单样式(Menu Style)。当新的菜单样式以各自的方式集成到成品中后,第三方的开发商便会掀起一阵模仿浪潮,利用一些定制控件和组件来仿效它。如果你正在使用这些产品,那么你惟有升级到新版本才能享受提供的新的特性。否则,你的应用程序将继续使用大约十年前随 Windows 95 上市时的那种 Windows 经典菜单用户界面。
  虽然 Microsoft 在其主要产品中定期更新菜单样式,但其可用来定制应用程序的菜单 API 却自 Windows 3.x 和 文件管理器以来一直没有发生太大的变化。从 Win16 平台开始,应用程序就已经可以有权自已绘制一个个的菜单项来实现自定义菜单。这种技术今天称作自绘(Owner-drawing),并已大量用于其它一些系统组件与控件上,包括列表框、按钮和组合框。

Figure 1 自绘控制示例
Figure 1 自绘控件

  自定义控件的绘图在 Win16 和 Win32 平台下是很无聊的事情。从功能上讲并不是特别复杂,但是代码的编写和维护着实令人讨厌。在本期栏目中,我将深入研究 .NET框架为窗口菜单提供的自绘机制。最终目的是创建一个自定义的组件——只要你将它拖放到某个 Windows 窗体的组件托盘(Component Tray)上,它就允许你按照给定的主题定制菜单的外观。作为例子,我将混合使用 Visual Studio.NET 和 Office 2003 的菜单样式。如 Figure 1 所示,一旦你理解了 .NET 中的自绘控件,那么你也能完成这个示例。

自定义菜单的显示
  在.NET框架中,与窗口菜单相关的类包括 MainMenu、ContextMenu 和 MenuItem,这些类都是从公共类——Menu 类派生而来 的。某个窗体的最顶层菜单总是一个 MainMenu 类实例。应用程序的菜单包括了一个弹出式菜单的列表,每个弹出式菜单又由若干个菜单项和子菜单组成。ContextMenu 类对应动态上下文菜单,应用程序中所有活动可见的对象都可以显示这样的一个菜单。 上下文菜单是一个独立的子菜单,并且没有办法与顶层菜单显示的其它子菜单区分开来。最后,MenuItem对象则对应在MainMenu 或者 ContextMenu 对象 内显示的某个独立的菜单项。总之,任何.NET框架下的菜单都是一个 MenuItem 对象集合——无论它在哪里、以及如何被显示。
  Win32平台下的自绘功能比较容易通过其 MenuItem 类为数不多的属性和事件来实现。如果你已是一位熟练的 Win32 程序员,你应该赏识.NET下自绘编程模型 的简捷与效率。如果你从未接触过 Win32的自绘控件,你将无法想象它会有多痛苦。再回到.NET的自绘菜单,只要你简单地把在 MenuItems 属性中找到的每一个MenuItem对象的 OwnerDraw 属性设置为真(TRUE)即可。此外,每个 MenuItem 对象都能处理一对事件-DrawItem 和 MeasureItem,它们 收集用于 Win32 平台的底层信息。下面的Visual Basic.NET 代码演示了如何将一个菜单项变成自绘菜单项。

Sub MakeItemOwnerDraw(ByVal item As MenuItem) 
item.OwnerDraw = True
AddHandler item.DrawItem, AddressOf StdDrawItem
AddHandler item.MeasureItem, AddressOf StdMeasureItem
End Sub
  在绘制菜单之前,当菜单需要知道某个菜单项的大小时,便触发 MeasureItem 事件,当菜单需要绘制特定项目时,则触发 DrawItem 事件 。Windows 使菜单足够大以适应最大的菜单项。MeasureItem 事件处理函数根据给定的菜单字体大小计算并返回菜单项的文本大小,而 DrawItem 事件处理函数则将 某些菜单文本及其位图、花哨的背景,甚或是你想在菜单上显示的任何东西绘制在给定的Graphics对象上。

重写窗体菜单
  重写某个窗体菜单,使之更多彩、更直观的步骤很简单:只要设置每个 MenuItem 对象的OwnerDraw 属性为True,并为关键事件编写适当的事件处理函数。怎样 以最佳方式实现这些呢?你会选择从Menu或MainMenu类派生一个新的类吗?甚至创建一个新的组件与这些缺省的菜单对象集并肩运行?
  就我所知,最简单的方法是创建一个新的包含一个初始化方法的外部类——我称其为 GraphicMenu。它这个初始化方法使用一个指向你所希望定制的菜单的引用 作为参数,将其所有子菜单项的自绘标志打开。这个方法的缺点在于始终需要一个对 初始化程序(initializer )的显式调用。更重要的是,你将无法 得到 Visual Studio .NET设计器的任何支持。最终,这个菜单在运行时只有一个自绘外观。在本文中,我将全面描述这种方法。
GraphicMenu 和 Menu 类一样继承自 Component:
Public Class GraphicMenu : Inherits Component
  这意味着在设计时它会悬停在设计器的组件托盘上(如 Figure 2)。GraphicMenu类相当于现有菜单对象的转换器——两者都是应用程序主菜单和上下文菜单。你 添加一个GraphicMenu类实例到窗体的控件集中,窗体便用这个类将普通菜单转换成自绘菜单。

Figure 2 Visual Basic .NET的设计器
Figure 2 Visual Basic .NET的设计器

  Figure 3中的代码显示了 GraphicMenu 类的入口方法 Init()。这个方法将一个灰色的3D的只是文本式样的标准菜单变成了多彩菜单。这个方法以一个Menu对象作为传入参数,将该Menu对象的所有菜单项设置为自绘方式。如果这个菜单是一个上下文菜单——也就是说该对象类型是ContextMenu的话——那么 它会直接进行自绘循环操作。
  那么,为什么你要给菜单增加更多图象元素呢?除了改善应用程序的外观,其中的一个主要原因是为了给菜单项旁边添加说明性图标。这意味着为了存储 拟在菜单项旁边绘制的图标,我们要向每一个 MenuItem 添加一些额外的信息。在理想的情况下,需要用一个新的派生类来代替MenuItem 类 ,假设这个派生类是 GraphicMenuItem。这个 GraphicMenuItem 类更象父类,只是多了个Icon属性。当你总要通过编程来生成或操纵菜单,这不失为一个好方法。 许多基于 Visual Studio .NET 应用程序的菜单通常是利用菜单设计器来生成(如 Figute 4),它会自动创建并注册 MenuItem 对象。

Figure 4 Visual Studio .NET的菜单设计器
Figure 4 Visual Studio .NET的菜单设计器

  为了避免编写一个新的可视化设计器并充分利用自动生成的 Visual Studio .NET 代码,我决定另辟蹊径。图标与菜单项之间的联系将被存储在 GraphicMenu 类内部的一个 Hash 表中。用 AddIcon 方法来向 Hash 表添加元素。
extendedMenu.AddIcon(FilePrint, "..\images\print.bmp")
extendedMenu.AddIcon(FileNew, "..\images\new.bmp")
extendedMenu.AddIcon(FileOpen, "..\images\open.bmp")
extendedMenu.AddIcon(FileSave, "..\images\save.bmp")
  AddIcon方法有两个参数——菜单项对象及欲显示的小位图文件的(或者任何System.Drawing.Image 类支持的其它图像文件格式)路径。在此 Hash 表中,菜单项对象是表的主键,而图标文件名是元素的值。虽然这种解决方案从理论上 讲并不优秀,但却可以让你以传统方式来使用惯用的工具(Visual Studio .NET)。你只需要调用额外的方法、运行一些重新配置菜单的后台代码。
  前示代码假设菜单位图存放于外部文件——有点类似一个Web应用程序。如果你打算使用内嵌在应用程序集中的图像,你可以重载 AddIcon 方法,并使之能接受代替文件路径的一个Image对象作为传入参数:
Sub AddIcon(ByVal item As MenuItem, ByVal icon As Image)
这个应用程序将首先从程序集(assembly)中提取图像,然后将其添加到Hash表。

MeasureItem 事件
  当菜单项被置为自绘方式后,用户需要激活两个事件来定制菜单的显示。第一个事件对应Win32的WM_MEASUREITEM消息。窗口收到这个消息时,它就会触发一个 MeasureItem 事件给所有的自绘 MenuItem 对象。这个事件代理(Delegate)是一个名为MeasureItemEventHandler 的类, 其原型如下:
Sub StdMeasureItem(ByVal sender As Object, ByVal e As MeasureItemEventArgs)
  与此事件相关的信息都被存储在一个MeasureItemEventArgs 对象中并被传递到事件处理函数。Figure 5 列出并描述了这个类的所有属性。
  MeasureItem事件的目的是要菜单项需要多大的空间。为了计算这个值,你需要知道关于该菜单项被绘制时所附绘制面的详细信息。而该事件相关信息中的GDI+ Graphic对象正对应了菜单被绘出时的绘制面。而你要计算字体的高宽度,则依赖于你从菜单项获得的希望用于显示的字体。MeasureItemEventArgs 结构中并未显式地包含一个 MenuItem 的引用。但你却可以 通过将 MeasureItem 事件强制转换为 MenuItem 来轻松获取这个引用。Figure 6 说明了实现方法。菜单项的显示文本和热键信息(比如Ctrl+S )的大小依赖于当前的绘制面和字体,通过MeasureString 方法确定。实际的大小将用以设置 ItemWidth 的值。Figure 6 所示的代码中,菜单项的高度全部进行了统一固定化,这是方法所要求的。注意,自绘机制也适用于许多其他的控 件,包括 ListBox 和 ComboBox,它们有时也可能会接受不同高度的项。
  最大的菜单项决定了整个菜单显示时占据的空间大小。当尺寸测定后,菜单开始在各菜单项的显示区域实际绘制这些项。每个菜单项都能绘制它想显示的任何东西,包括不同字体的文本、花 哨的背景、位图。这些工作则由下面将要讨论的 DrawItem 事件来完成。

DrawItem 事件
  正如上文提到的,自绘方式下的菜单项在需要被绘出时收到这个事件。这个事件与 Win32 的WM_DRAWITEM消息对应,并给每个注册了的事件处理函数传递一个 DrawItemEventArgs 对象。Figure 7 列出并描述了这个类中与显示菜单项有关的一些成员信息。
  DrawItemEventArgs 的三个成员很重要:Graphics、Bounds 和 State。Graphics 对象与相关的 GDI+ 实体设备上下文 对应,所有的绘制都必须在这个 Graphics 对象上进行。Bounds 属性界定了由 MeasureItem 事件确定的欲绘制矩形区域,而且提供了相对于上一个菜单项高度的坐标及其项序号。如果你想绘出一个背景、一张位图或一些文本,在这个事件的处理函数中实现就是最安全的。
  最后,State 属性等于 DrawStateItem 的掩码组合值,与菜单项的状态对应。DrawStateItem 枚举某个菜单项可能的状态值:禁用 (disabled)、选中(checked)、热跟踪(hot-tracked)、已选择(selected)。每种状态都影响着菜单项的显示。比如一个已被选择的菜单项会有不同的背景色,而禁用的菜单项应该是 置灰的。
  对于复选(check)或单选按钮(radio)形式的菜单项则要复杂些。一个复选形式的菜单项与某种逻辑状态而不是某个操作对应。当它被点击后,应用程序会切换某个对应的内部变量的值而不是执行 某个动作。被选中的菜单项会用一个选中标志图标进行标识。而单选形式的菜单项则表示多选一,并以一个着重号图标标识。注意,一旦你开始自定义菜单,你就需要自己来负责处理所有的状态。

绘制菜单项
  下面的代码片段展示了一个典型的 DrawItem 事件处理函数。这个方法接受一个菜单项和相关的绘制参数,然后给定参数完成四个操作。
Sub StdDrawItem(ByVal sender As Object, ByVal e As DrawItemEventArgs)
Dim item As MenuItem = CType(sender, MenuItem)

Dim g As Graphics = e.Graphics.NET中的自绘机制
Dim itemState As DrawItemState = e.State

CreateLayout(bounds)
DrawBackground(g, itemState)
DrawBitmap(g, item, itemState)
DrawText(g, item, itemState)
End Sub
  这段代码首先创建了一个用以容纳菜单各项的矩形层,然后绘制出背景和位图,最后显示菜单文本。当然这些步骤都依赖于我为这个菜单设计的绘制层——就象Figure 4中所示的一样。
我的菜单项由一个位图(居左)和一串文本(居右)两部分区域组成。目的是为这两个区域设置不同的前景与背景色。
  DrawBackground 方法检查菜单项的状态,创建相应的前景色与背景色的刷子(可以选择渐变色的刷子)。然后,它填充位图与文本区域。两个区域的背景色都由 GraphicMenu 中的属性控制。在GDI+中,填充一个区域需要使用一个 Brush 对象。有趣的是,Brush 是 SolidBrush 和 LinearGradientBrush 类的基类,因此要使用已有的实心或背景刷子也并不需要改变相应的代码。完整的代码请参看 打包下载的源代码,我在这里只做简要的介绍。
  DrawBitmap 方法同样要检查菜单项的状态。如果需要一个内嵌的位图(比如一个复选或单选按钮形式的菜单项),对应的图像就会从这个控制的程序集中被提取并显示。否则 DrawBitmap 方法会在 Hash 表中查找与该 MenuItem 对象关联的图标,如果找到了相应的图标文件就显示它。
  你可以把位图作为一种内嵌的资源嵌入一个 .NET的程序集中。要这样做,只需在选择位图后,将解决方案资源管理器的属性管理器中的“生成操作”设置改为“嵌入的资源”即可(注意,“嵌入的资源”对于一个图形而言并非缺省的生成操作,因此这步操作是决定性的)。同样的,你必须为这个图像定义一个特定的名字。而这个名字在  Visual Basic .NET 里必须以命名空间名作为前缀(如果你用的C#,会有少许差别)。假设 GraphicMenu 类属于 MsdnMag 命名空间,那么所有内嵌图像名须采取  MsdnMag.FileName.bmp 这样的形式。那么如何在运行时获得这些图像呢?如下所示:
If item.RadioCheck Then
bmp = ToolboxBitmapAttribute.GetImageFromResource( _
Me.GetType(), "Bullet.bmp", False)
Else
bmp = ToolboxBitmapAttribute.GetImageFromResource( _
Me.GetType(), "Checkmark.bmp", False)
End If
  你可以使用一个叫做 GetImageFromResource 的 ToolboxBitmapAttribute 类的共享成员。不要被它的类名迷惑——它是一个定义在 System.Drawing 程序集中的类,但可以按传统方式在编程中使用它(与基于属性的编程模型相对)。GetImageFromResource 方法只是这段代码的一个辅助方法:
'''' t is the type whose namespace is used to scope 
'''' the resource name (GraphicMenu in this context)
Dim img As Image
Dim str As Stream
str = t.Module.Assembly.GetManifestResourceStream(t, "Bullet.bmp");
If Not (str Is Nothing) Then
img = new Bitmap(str)
End If
  当在一个控制上使用时,ToolboxBitmapAttribute 属性通知Visual Studio Form设计器这样的容器返回该控制对应的一个图标。位图通常会被嵌入包含了该控制的程序集中,当然也不是必须的。菜单位图的大小由编程者控制,但要保证所有位图的大小统一。在本文所示程序中,我用16×16大小的位图。
  DrawBitmap 方法则通过利用 Bitmap 类的 MakeTransparent 将图像以透明方式显示,并会忽略其本来的格式。
bmp.MakeTransparent()
  MakeTransparent 方法以图像的第一个象素的颜色作为其所属 Bitmap 对象的透明色。通过使用这个方法的重载版本,你也可以自定义透明色。而要显示一个灰化的位图,你有两种选择:第一种方式是使用一套不同的图标,就象工具条的多套图标那样。而我选择第二种方式:使用不同的伽玛值(Gamma)使图像灰化,见 Figure 8
  当你显示一个图像时,你可以利用各类图形对象来控制图像的伽玛校正值,比如画笔Pen或者画刷 Brush。你可以利用Graphics类DrawImage方法传递的ImageAttributes对象来设置或重置伽玛校正值。 通过使用零附近的伽玛值,DrawImage 就可以实现图像的灰化。如 Figure 9 所示(请注意 Print 菜单项),这不失为一个比较好的的自动灰化图像的技巧,更好的方法则是使用一个转换矩阵(参见 GDI+ SDK文档)。

Figure 9 灰化的Print菜单项
Figure 9 灰化的 Print菜单项

  最后一步是利用 DrawText 方法显示菜单的文本。这串要绘制的文本可以选择性地包括其热键的定义串——某个象 Shift+Ctrl+F 这样的 串。如果该菜单项的Shortcut属性已置为真,则被显示的文本将包括菜单文本及其热键两部分。DrawText方法使用了一个内部的GetEffectiveText方法来返回菜单项文本与热键文本。热键不能以显示的格式文本(键1+键2)直接使用将是无意义的。键盘热键全被整理在Shortcut枚举类型中,它的ToString方法将可以返回“CtrlO”这样没有间隔符的热键文本串。这些串中的键名都是除首字母大写,其余字母小写。所以你在任何一个大写字母(除了第一个字母)之前插入一个+分隔符,就能创建Figure 9中所示的热键文本串。
  忽略了嵌套的层数,处理子菜单就简单了。使用我自己写的一个递归算法就可以在GraphicMenu 的 Init 方法中启用所有的子菜单的自绘设置。不要介意你有多少层的级联菜单,它们都会拥有正确的 MeasureItem 与 DrawItem 事件处理函数。此外,MainMenu 类提供了箭头符号表示一个菜单项还有下级子菜单。

上下文菜单与文本框
  自定义一个上下文菜单,只需将相同的代码进行一定的扩展即可。还记得吗?在.NET框架中,上下文菜单被看作是只有一个子菜单的主菜单。它的上级菜单从未出现过。因此按照这种层次关系,目前我的代码还不需要做任何改动即可工作。
  一个上下文菜单可以拥有一个缺省项。当用户在一个拥有缺省项的子菜单上双击时,这个缺省的菜单项就被自动地选择了。你可以使用 MenuItem 的 DefaultItem 属性来指定一个上下文菜单的缺省动作。而 Windows 则会将缺省项以黑体显示。一个自绘菜单也必须支持这个 特性。DrawItemState枚举有一个值——Default,可以让你在显示一个项时知道它是否为缺省菜单项。如果是,那你就需要改变菜单文本的字体,以黑体显示它。代码如下:
Dim tmpFont As Font
Dim defaultItem As Boolean
defaultItem = (itemState And DrawItemState.Default)
If (defaultItem) Then
tmpFont = New Font(ItemFont, FontStyle.Bold)
Else
tmpFont = ItemFont
End If
  Font.Bold 属性是只读的,这意味着你在字体创建后就不能再撤销或设置其为黑体形式了。因此,如果你要显示一个缺省菜单项,你必须创建一个临时的黑体 属性字体对象并用它来显示菜单文本。记住,在你用完字体对象或其它的GDI+对象后应该将其释放,这是个好的编程习惯。
在一个 Windows 窗体应用程序中,你可以创建多个上下文菜单,然后通过各个控制的ContextMenu属性将这些菜单绑定到这些控制上。各个上下文菜单对象必须先用GraphicMenu对象的Init方法进行预处理,以确定其自定义外观。Figure 10即是一个示例,这个按钮绑定到了一个带有缺省项 Order 的上下文菜单上。

Figure 10 创建上下文菜单
Figure 10 创建上下文菜单

  文本框是W indows 窗体中唯一提供了内建上下文菜单的控制。这个控制提供了一个ContextMenu 属性,但是当你右击它时却不会返回一个当前显示的上下文菜单的实例对象。为什么会这样?因为文本框的上下文菜单的相关代码属于Win32平台的固有API。特 别是这个上下文菜单在每次用户右击时才生成,在用户选择后又被释放并销毁。你可以通过设置ContextMenu属性,用自己的菜单替换这个菜单,但这也意味着你将无法再操纵原来的那个菜单。因为这个菜单是如此的短命(并且不受控),那么如何才能轻易地将其子类化并让它实现自绘呢?
  我找到的最简单的办法如下:创建一个 TextboxContextMenu 类,对应包含了标准的文本框上下文菜单项(Undo、Cut、Copy之类)的自定义上下文菜单。实现这些方法的代码可以相当轻易地从 MSDN 文档中获得。一旦你获得了这样一个类似文本框标准上下文菜单的受控类后,你先要设置其为自绘方式,然后象下面这样将其绑定到任何一个你希望使用的文本框上即可:
Dim txtMenu As New TextBoxContextMenu 
gMenu.AddIcon(txtMenu.MenuItemCut, "..\images\cut.bmp")
gMenu.AddIcon(txtMenu.MenuItemCopy, "..\images\copy.bmp")
gMenu.AddIcon(txtMenu.MenuItemPaste, "..\images\paste.bmp")
gMenu.AddIcon(txtMenu.MenuItemDelete, "..\images\delete.bmp")
TextBox1.ContextMenu = txtMenu
gMenu.Init(TextBox1.ContextMenu)
  TextBoxContextMenu 类与 GraphicMenu 类一同被封装在一个程序集中。因此你必须注意自绘菜单在一个NotifyIcon组件上将不能正常工作。关于这个问题,请参考“Knowledge Base article 827043”。

无缝地使用图形化的菜单.NET中的自绘机制
  .NET框架支持通过一个封装了Win32自绘机制的编程接口使用图形化的菜单。这些受控的API进行了一定的抽象,但并没有重新设计底层模型。这意味着,图形化菜单只能象本文中所示一样通过自编的代码进行使用。是否有一种方法使这种处理自动化,使我们只需要在IDE(集成开发环境)中设置属性而不必编写代码呢?目前至少需要在父窗口被装载时初始化 GraphicMenu。一种完全隐藏它的方法是创建一个包含了GraphicMenu对象的 窗体类,然后在Load事件中设置主菜单为自绘方式。如果你将这个类加入窗体类的继承体系,那么当你将一个菜单添加到这种窗体上时,它就能自动地获得并显示自定义的外观。自己试试吧!


群贤毕至

访客