“掌控”MDI 客户端






4.93/5 (91投票s)
一个组件,用于使用颜色、图像、边框样式等来自定义 Form 的 MDI 区域。
目录
引言
在一个最近的项目中,我决定使用多文档界面 (MDI) 是最好的方法。我欣喜地发现,在 Visual Studio 和 .NET 平台上创建 MDI 应用程序非常容易。只需将 System.Windows.Forms.Form
的 IsMdiContainer
属性设置为 true
,其他窗体就可以托管在应用程序工作区中。然而,如果您和我一样,可能会想知道这个工作区如果颜色不同、自定义绘图或者边框样式不同会是什么样子。我很快发现 Form
控件没有提供这样的属性来控制这种行为。搜索网络后发现,许多人都有相同的愿望,并且有各种方法来实现这一点。在成功地将他们的建议应用到我的应用程序并创建了一些自己的方法后,我决定将所有这些信息收集到一处,也许还会开发一个组件,以便能够轻松设置这些属性。
MdiClient 控件
事实证明,Windows® Form 的 MDI 区域只是另一个控件。当 IsMdiContainer
属性设置为 true
时,类型为 System.Windows.Forms.MdiClient
的控件会被添加到 Form
的 Controls
集合中。加载后遍历 Form
的控件会显示 MdiClient
控件,这可能是获取其引用的最佳方式。MdiClient
控件*确实*有一个公共构造函数,并且*可以*通过编程方式将其添加到 Form
的 Controls
集合中,但更好的做法是设置 Form
的 IsMdiContainer
属性,让它来完成工作。要设置 MdiClient
控件的引用,请遍历控件直到找到 MdiClient
控件
MdiClient mdiClient = null;
// Get the MdiClient from the parent form.
for(int i = 0; i < parentForm.Controls.Count; i++)
{
// If the form is an MDI container, it will contain an MdiClient control
// just as it would any other control.
mdiClient = parentForm.Controls[i] as MdiClient;
if(mdiClient != null)
{
// The MdiClient control was found.
// ...
//
break;
}
}
在这里使用 as
关键字比在 try
/catch
块中使用直接转换或使用 is
关键字更好,因为如果 type
匹配,则会返回控件的引用,否则返回 null
。这就像一价两用。
注意:在测试中,我发现有可能将多个
MdiClient
控件添加到Form
的Controls
集合中。在这种情况下,只有一个MdiClient
控件将充当宿主,此代码可能会因为返回对未执行子窗体托管的MdiClient
的引用而失败。这是使用IsMdiContainer
属性而不是手动将控件添加到窗体的另一个好理由。
引用有什么用
更改背景颜色
获得 MdiClient
控件的引用后,许多常见的控件属性都可以按预期设置。最常要求的当然是更改背景颜色。应用程序工作区的默认背景颜色对于所有 Windows® 应用程序都是全局的,可以在控制面板中更改。.NET 框架在 System.Drawing.SystemColors.AppWorkspace
静态属性中公开此颜色。更改背景颜色按预期进行,通过 BackColor
属性即可完成
// Set the color of the application workspace.
mdiClient.BackColor = value;
MdiClient
控件将像其他控件一样按预期工作,这以及许多其他常见控件的属性。
句柄有什么用
更改边框样式
然而,MdiClient
控件中缺少 BorderStyle
属性。System.Windows.Forms.BorderStyle
枚举选项中常见的 Fixed3D
、FixedSingle
和 None
选项都不存在了。默认情况下,MDI 窗体的应用程序工作区具有相当于 Fixed3D
的 3D 边框内嵌。仅仅因为控件没有公开此行为,并不意味着它无法访问。从现在开始,您会发现 MdiClient
的 Handle
比仅仅引用它更有价值。
要更改边框的外观,需要使用 Win32 函数调用。(有关更多信息,可以参考 Jason Dorie 的文章:为用户控件添加可设计边框。)Windows® 中的每个窗口(即 Control
)都有可以通过使用 GetWindowLong
检索并在使用 SetWindowLong
函数进行设置的信息。这两个函数都需要一个标志,该标志指定我们要获取和设置的信息。在这种情况下,我们关心的是 GWL_STYLE
和 GWL_EXSTYLE
,它们分别获取和设置窗口样式和扩展窗口样式标志。由于这些更改是针对控件的非客户区进行的,调用控件的 Invalidate
方法不会导致边框重绘。相反,我们调用 SetWindowPos
函数来更新非客户区。这些函数和常量定义如下
// Win32 Constants
private const int GWL_STYLE = -16;
private const int GWL_EXSTYLE = -20;
private const int WS_BORDER = 0x00800000;
private const int WS_EX_CLIENTEDGE = 0x00000200;
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOREDRAW = 0x0008;
private const uint SWP_NOACTIVATE = 0x0010;
private const uint SWP_FRAMECHANGED = 0x0020;
private const uint SWP_SHOWWINDOW = 0x0040;
private const uint SWP_HIDEWINDOW = 0x0080;
private const uint SWP_NOCOPYBITS = 0x0100;
private const uint SWP_NOOWNERZORDER = 0x0200;
private const uint SWP_NOSENDCHANGING = 0x0400;
// Win32 Functions
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern int GetWindowLong(IntPtr hWnd, int Index);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern int SetWindowLong(IntPtr hWnd, int Index, int Value);
[DllImport("user32.dll", ExactSpelling = true)]
private static extern int SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
注意:这些常量的定义在 Winuser.h 头文件中,通常由 Platform SDK 或 Visual Studio .NET 安装。
我们可以通过以下方式根据 BorderStyle
枚举来调整边框:在扩展窗口样式(Fixed3D
)中包含 WS_EX_CLIENTEDGE
标志,在标准窗口样式(FixedSingle
)中包含 WS_BORDER
标志,或者移除这两个标志以获得无边框(None
)。然后调用 SetWindowPos
函数进行更新。SetWindowPos
函数有很多选项,但我们只需要重绘非客户区,并传入必要的标志
// Get styles using Win32 calls
int style = GetWindowLong(mdiClient.Handle, GWL_STYLE);
int exStyle = GetWindowLong(mdiClient.Handle, GWL_EXSTYLE);
// Add or remove style flags as necessary.
switch(value)
{
case BorderStyle.Fixed3D:
exStyle |= WS_EX_CLIENTEDGE;
style &= ~WS_BORDER;
break;
case BorderStyle.FixedSingle:
exStyle &= ~WS_EX_CLIENTEDGE;
style |= WS_BORDER;
break;
case BorderStyle.None:
style &= ~WS_BORDER;
exStyle &= ~WS_EX_CLIENTEDGE;
break;
}
// Set the styles using Win32 calls
SetWindowLong(mdiClient.Handle, GWL_STYLE, style);
SetWindowLong(mdiClient.Handle, GWL_EXSTYLE, exStyle);
// Update the non-client area.
SetWindowPos(mdiClient.Handle, IntPtr.Zero, 0, 0, 0, 0,
SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER |
SWP_NOOWNERZORDER | SWP_FRAMECHANGED);
窗口消息有什么用
NativeWindow 类
要进入自定义的领域,而不仅仅是更改简单属性或进行 Win32 调用,我们需要拦截和处理窗口消息。不幸的是,MdiClient
类是 sealed
的,因此不能对其进行子类化,也不能覆盖其 WndProc
方法。值得庆幸的是,System.Windows.Forms.NativeWindow
类派上了用场。NativeWindow
类的目的是提供“窗口句柄和窗口过程的低级封装”。换句话说,它允许我们接入控件接收的窗口消息。要使用 NativeWindow
,请继承该类并覆盖其 WndProc
方法。一旦控件的句柄通过 AssignHandle
方法分配给 NativeWindow
,WndProc
方法的行为就好像它是控件的 WndProc
方法一样。通过能够监听 MdiClient
控件的窗口消息,可以实现全新的自定义。
隐藏滚动条
虽然使应用程序工作区外部的控件能够通过滚动条访问是一项很棒的功能,但我个人记不起用过的 MDI 应用程序有相同的效果。关闭或隐藏 MdiClient
中的滚动条可能比更改其颜色更常被要求。
MdiClient
控件的滚动条是其非客户区(ClientRectangle
之外的区域)的一部分,本身并不是作为父控件添加到 MdiClient
的。这排除了更改滚动条可见性的可能性,因此我们可以依赖窗口消息和影响非客户区大小的 Win32 函数。当需要计算控件的非客户区大小时,控件会收到一个 WM_NCCALCSIZE
消息。为了隐藏滚动条,我们可以告诉 Windows® 非客户区比实际尺寸稍小一些,从而覆盖滚动条。我最初的尝试是试图确定非客户区的大小,但失败了。一个更好的方法是在计算非客户区大小时使用 ShowScrollBar
Win32 函数隐藏滚动条。ShowScrollBar
函数需要窗口句柄、要隐藏的滚动条以及一个指示其可见性的 bool
值
// Win32 Constants
private const int SB_HORZ = 0;
private const int SB_VERT = 1;
private const int SB_CTL = 2;
private const int SB_BOTH = 3;
// Win32 Functions
[DllImport("user32.dll")]
private static extern int ShowScrollBar(IntPtr hWnd, int wBar, int bShow);
protected override void WndProc(ref Message m)
{
switch(m.Msg)
{
//
// ...
//
case WM_NCCALCSIZE:
ShowScrollBar(m.HWnd, SB_BOTH, 0 /*false*/);
break;
}
base.WndProc(ref m);
}
隐藏滚动条后,WM_NCCALCSIZE
消息会按正常方式处理,并计算出非客户区,减去最近隐藏的滚动条。万一您想知道,通过 ShowScrollBar
函数隐藏滚动条并不会保持滚动条隐藏,它会立即重置为可见。这就是为什么每次计算非客户区大小时都必须隐藏它。
高级绘图
在网络上的 .NET 论坛中,我看到的另一个常见请求是:“如何将图像放入 MDI 窗体的应用程序工作区?” 最简单的方法是,在获得 MdiClient
的引用后,监听 Paint
事件。对于某些情况,这可能有效,但我注意到每次 MdiClient
调整大小时都会出现非常糟糕的闪烁。这是由于绘图不是双缓冲的,并且在 WM_PAINT
和 WM_ERASEBKGND
消息中都调用了绘图。如果我们可以从 MdiClient
控件继承,这很容易通过使用控件的受保护方法 SetStyle
和标志 System.Windows.Forms.ControlStyles.AllPaintingInWmPaint
、ControlStyles.DoubleBuffer
和 ControlStyles.UserPaint
来修复。但如前所述,MdiClient
类是 sealed
的,所以这不是一个选项。*可以*选择的是监听 WM_PAINT
和 WM_ERASEBKGND
窗口消息并实现我们自己的自定义绘图。(更多信息可在 Steve McMahon 的文章中找到:在 MDI 客户端区域绘图。)
我们将需要的 Win32 项是 BeginPaint
和 EndPaint
函数,称为 PAINTSTRUCT
和 RECT
的 struct
,以及一些额外的常量
// Win32 Constants
private const int WM_PAINT = 0x000F;
private const int WM_ERASEBKGND = 0x0014;
private const int WM_PRINTCLIENT = 0x0318;
// Win32 Structures
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct PAINTSTRUCT
{
public IntPtr hdc;
public int fErase;
public RECT rcPaint;
public int fRestore;
public int fIncUpdate;
[MarshalAs(UnmanagedType.ByValArray, SizeConst=32)]
public byte[] rgbReserved;
}
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int left;
public int top;
public int right;
public int bottom;
}
// Win32 Functions
[DllImport("user32.dll")]
private static extern IntPtr BeginPaint(IntPtr hWnd,
ref PAINTSTRUCT paintStruct);
[DllImport("user32.dll")]
private static extern bool EndPaint(IntPtr hWnd, ref PAINTSTRUCT paintStruct);
双缓冲的典型方法是将所有绘图操作到 Image
上,或者更确切地说,从 Image
获取 Graphics
对象,而不是直接绘制到屏幕上。当绘制到 Image
完成后,然后将 Image
本身绘制到屏幕上。这样,控件的所有绘图都会一次性显示,而不是进行中的间歇性绘图。由于 MdiClient
控件的图形非常简单,我们可以轻松地自己完成所有绘图,但更好的做法是不要消除基本图形的绘制,而是将它们集成到我们的自定义绘图中。这样,即使 MdiClient
以我们意想不到的方式发生变化,绘图也应该仍然正确显示。这是通过创建我们自己的窗口消息(WM_PRINTCLIENT
)并使用 DefWndProc
(即默认 WndProc
)方法将其发送到基本控件来实现的。我们得到的是基本控件绘制的原控件的图形缓冲区。(有关更多信息,可参考 J Young 的文章:为 TreeView 和 ListView 控件生成缺失的 Paint 事件。)然后,可以处理其上的任何自定义绘图
protected override void WndProc(ref Message m)
{
switch(m.Msg)
{
//Do all painting in WM_PAINT to reduce flicker.
case WM_ERASEBKGND:
return;
case WM_PAINT:
// Use Win32 to get a Graphics object.
PAINTSTRUCT paintStruct = new PAINTSTRUCT();
IntPtr screenHdc = BeginPaint(m.HWnd, ref paintStruct);
using(Graphics screenGraphics = Graphics.FromHdc(screenHdc))
{
// Double-buffer by painting everything to an image and
// then drawing the image.
int width = (mdiClient.ClientRectangle.Width > 0 ?
mdiClient.ClientRectangle.Width : 0);
int height = (mdiClient.ClientRectangle.Height > 0 ?
mdiClient.ClientRectangle.Height : 0);
using(Image i = new Bitmap(width, height))
{
using(Graphics g = Graphics.FromImage(i))
{
// Draw base graphics and raise the base Paint event.
IntPtr hdc = g.GetHdc();
Message printClientMessage =
Message.Create(m.HWnd, WM_PRINTCLIENT,
hdc, IntPtr.Zero);
DefWndProc(ref printClientMessage);
g.ReleaseHdc(hdc);
//
// Custom painting here...
//
}
// Now draw all the graphics at once.
screenGraphics.DrawImage(i, mdiClient.ClientRectangle);
}
}
EndPaint(m.HWnd, ref paintStruct);
return;
}
base.WndProc(ref m);
}
注意:有关
BeginPaint
、EndPaint
、PAINTSTRUCT
、RECT
和WM_PRINTCLIENT
的更多信息可以在 Platform SDK 或 MSDN 库中找到。
请注意,在这种情况下,我们不让 WM_PAINT
消息通过以由基本 WndProc
处理,因为那样会导致它在我们的绘图之上执行其默认绘图。WM_ERASEBKGND
消息被忽略,因为我们希望在 WM_PAINT
消息中一次性完成所有绘图。现在,MdiClient
控件的 Paint
事件将不再闪烁,自定义绘图代码可以放在上面的 WM_PAINT
消息处理中。
MdiClientController 组件
与其将此代码放入每个使用多文档界面的项目中,不如将其打包到一个 System.ComponentModel.Component
中,该组件可以从项目复制到项目并拖放到设计图面上。源代码文件中包含一个我称之为 MdiClientController
的组件,它位于 Slusser.Components
命名空间中。该组件继承自 NativeWindow
并实现了 System.ComponentModel.IComponent
接口,以赋予其 Component
行为。它整合了之前讨论过的所有功能,并添加了一些属性,可以轻松地将 Image
放置在应用程序工作区中。
要将组件与 MDI 窗体一起使用,只需将父 Form
传递给构造函数或通过 ParentForm
属性设置。要在设计器中设置 MdiClientController
组件的 ParentForm
属性,我们必须自定义 Site
属性以确定组件是否被拖放到 Form
上。了解设计器在这里很有帮助。如果确实将组件拖到了 Form
上,我们将设置 ParentForm
属性,并在设计器代码中正确地对其进行序列化
public ISite Site
{
get { return site; }
set
{
site = value;
if(site == null)
return;
// If the component is dropped onto a form during design-time,
// set the ParentForm property.
IDesignerHost host =
(value.GetService(typeof(IDesignerHost)) as IDesignerHost);
if(host != null)
{
Form parent = host.RootComponent as Form;
if(parent != null)
ParentForm = parent;
}
}
}
创建此组件的挑战之一是知道组件何时会被初始化。拖放到设计器上的 Components
会在窗体构造函数的 InitializeComponent
方法中初始化。如果您检查设计器生成的 InitializeComponent
方法,您会注意到 Form
的属性是最后设置的。如果 MdiClientController
在 Form
的 IsMdiContainer
属性被设置之前扫描 Form
的 Controls
集合中的 MdiClient
控件,则找不到 MdiClient
控件。解决方案是知道何时创建了父 Form
的 Handle
。这将肯定表明所有子控件和变量都已初始化,此时我们可以开始查找 MdiClient
。如果设置 ParentForm
属性时父窗体没有 Handle
,则组件将监听 Form
的 HandleCreated
事件并在那时获取 MdiClient
public Form ParentForm
{
get { return parentForm; }
set
{
// If the ParentForm has previously been set,
// unwire events connected to the old parent.
if(parentForm != null)
parentForm.HandleCreated -=
new EventHandler(ParentFormHandleCreated);
parentForm = value;
if(parentForm == null)
return;
// If the parent form has not been created yet,
// wait to initialize the MDI client until it is.
if(parentForm.IsHandleCreated)
{
InitializeMdiClient();
RefreshProperties();
}
else
parentForm.HandleCreated +=
new EventHandler(ParentFormHandleCreated);
}
}
private void ParentFormHandleCreated(object sender, EventArgs e)
{
// The form has been created, unwire the event,
// and initialize the MdiClient.
parentForm.HandleCreated -=
new EventHandler(ParentFormHandleCreated);
InitializeMdiClient();
RefreshProperties();
}
使用组件
一旦 MdiClientController
被添加到工具箱,只需将其拖到设计器中的 Form
上,或者双击它,它就会显示在设计器的组件托盘中。MdiClientController
不会更改 Form
的 IsMdiContainer
属性,因此您必须进行设置。组件的所有属性都遵循 .NET 命名约定。边框样式功能封装在 BorderStyle
属性中。滚动条的隐藏,我认为最好放在 AutoScroll
属性中。BackColor
和 Paint
事件现在可以从设计器访问,以便于您使用。此外,还有三个属性用于控制客户端区域中 Image
的显示。Image
属性设置要显示的 Image
,ImageAlign
属性将其放置在客户端区域的不同位置,StretchImage
属性将其拉伸以填充整个客户端区域。此外,我添加了一个 HandleAssigned
事件,以指示何时找到 MdiClient
并且其 Handle
已分配给 NativeWindow
。当然,所有这些都可以通过编程方式完成。
结论
与许多最终成为文章的项目一样,我最初需要的东西大约在 30 分钟内就完成了,但花了几天时间来准备一些可以与我的同行程序员分享的东西。最终的组件应该足以满足大多数关于 MDI 窗体外观的要求。它运行良好,配合良好,并使应用程序看起来漂亮[ly]。如果需要,仍然可以向组件添加更多功能,我相信对某些程序员来说,这将会是。有一个功能,或者更确切地说是一个障碍,我谦虚地承认我未能克服:设计时预览。使用 Reflector,我发现了很多阻止 MDI 区域设计时预览的障碍。我欢迎任何关于如何克服这一点的建议。尽情享用。