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

“掌控”MDI 客户端

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (91投票s)

2004年10月7日

MIT

12分钟阅读

viewsIcon

313805

downloadIcon

5378

一个组件,用于使用颜色、图像、边框样式等来自定义 Form 的 MDI 区域。

MdiClient Demo Application

目录

引言

在一个最近的项目中,我决定使用多文档界面 (MDI) 是最好的方法。我欣喜地发现,在 Visual Studio 和 .NET 平台上创建 MDI 应用程序非常容易。只需将 System.Windows.Forms.FormIsMdiContainer 属性设置为 true,其他窗体就可以托管在应用程序工作区中。然而,如果您和我一样,可能会想知道这个工作区如果颜色不同、自定义绘图或者边框样式不同会是什么样子。我很快发现 Form 控件没有提供这样的属性来控制这种行为。搜索网络后发现,许多人都有相同的愿望,并且有各种方法来实现这一点。在成功地将他们的建议应用到我的应用程序并创建了一些自己的方法后,我决定将所有这些信息收集到一处,也许还会开发一个组件,以便能够轻松设置这些属性。

MdiClient 控件

事实证明,Windows® Form 的 MDI 区域只是另一个控件。当 IsMdiContainer 属性设置为 true 时,类型为 System.Windows.Forms.MdiClient 的控件会被添加到 FormControls 集合中。加载后遍历 Form 的控件会显示 MdiClient 控件,这可能是获取其引用的最佳方式。MdiClient 控件*确实*有一个公共构造函数,并且*可以*通过编程方式将其添加到 FormControls 集合中,但更好的做法是设置 FormIsMdiContainer 属性,让它来完成工作。要设置 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 控件添加到 FormControls 集合中。在这种情况下,只有一个 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 枚举选项中常见的 Fixed3DFixedSingleNone 选项都不存在了。默认情况下,MDI 窗体的应用程序工作区具有相当于 Fixed3D 的 3D 边框内嵌。仅仅因为控件没有公开此行为,并不意味着它无法访问。从现在开始,您会发现 MdiClientHandle 比仅仅引用它更有价值。

要更改边框的外观,需要使用 Win32 函数调用。(有关更多信息,可以参考 Jason Dorie 的文章:为用户控件添加可设计边框。)Windows® 中的每个窗口(即 Control)都有可以通过使用 GetWindowLong 检索并在使用 SetWindowLong 函数进行设置的信息。这两个函数都需要一个标志,该标志指定我们要获取和设置的信息。在这种情况下,我们关心的是 GWL_STYLEGWL_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 方法分配给 NativeWindowWndProc 方法的行为就好像它是控件的 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_PAINTWM_ERASEBKGND 消息中都调用了绘图。如果我们可以从 MdiClient 控件继承,这很容易通过使用控件的受保护方法 SetStyle 和标志 System.Windows.Forms.ControlStyles.AllPaintingInWmPaintControlStyles.DoubleBufferControlStyles.UserPaint 来修复。但如前所述,MdiClient 类是 sealed 的,所以这不是一个选项。*可以*选择的是监听 WM_PAINTWM_ERASEBKGND 窗口消息并实现我们自己的自定义绘图。(更多信息可在 Steve McMahon 的文章中找到:在 MDI 客户端区域绘图。)

我们将需要的 Win32 项是 BeginPaintEndPaint 函数,称为 PAINTSTRUCTRECTstruct,以及一些额外的常量

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

注意:有关 BeginPaintEndPaintPAINTSTRUCTRECTWM_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 的属性是最后设置的。如果 MdiClientControllerFormIsMdiContainer 属性被设置之前扫描 FormControls 集合中的 MdiClient 控件,则找不到 MdiClient 控件。解决方案是知道何时创建了父 FormHandle。这将肯定表明所有子控件和变量都已初始化,此时我们可以开始查找 MdiClient。如果设置 ParentForm 属性时父窗体没有 Handle,则组件将监听 FormHandleCreated 事件并在那时获取 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 不会更改 FormIsMdiContainer 属性,因此您必须进行设置。组件的所有属性都遵循 .NET 命名约定。边框样式功能封装在 BorderStyle 属性中。滚动条的隐藏,我认为最好放在 AutoScroll 属性中。BackColorPaint 事件现在可以从设计器访问,以便于您使用。此外,还有三个属性用于控制客户端区域中 Image 的显示。Image 属性设置要显示的 ImageImageAlign 属性将其放置在客户端区域的不同位置,StretchImage 属性将其拉伸以填充整个客户端区域。此外,我添加了一个 HandleAssigned 事件,以指示何时找到 MdiClient 并且其 Handle 已分配给 NativeWindow。当然,所有这些都可以通过编程方式完成。

结论

与许多最终成为文章的项目一样,我最初需要的东西大约在 30 分钟内就完成了,但花了几天时间来准备一些可以与我的同行程序员分享的东西。最终的组件应该足以满足大多数关于 MDI 窗体外观的要求。它运行良好,配合良好,并使应用程序看起来漂亮[ly]。如果需要,仍然可以向组件添加更多功能,我相信对某些程序员来说,这将会是。有一个功能,或者更确切地说是一个障碍,我谦虚地承认我未能克服:设计时预览。使用 Reflector,我发现了很多阻止 MDI 区域设计时预览的障碍。我欢迎任何关于如何克服这一点的建议。尽情享用。

© . All rights reserved.