[DForm]我也来做自定义Winform之另类标题栏重绘
据说得有楔子
按照惯例,先来几张样例图(注:为了展示窗口阴影效果,截图范围向外扩展了些,各位凭想象吧)。
还要来个序
其实,很多年没写过Winform了,前端时间在重构我们公司自己的呼叫中心系统,突然就觉得客户端好丑好丑,对于我这种强迫症晚期患者来说,界面不好看都不知道怎么写代码的,简直就是种折磨,还是满清十大酷刑级别那种。
很多人推荐WPF,不过个人对WPF没啥感觉,而且据说也无法支持2.0,还是采用Winform技术来搞吧。
终于第一节
做Winform皮肤,我了解的无非有2种方式。
1.采用纯图片模式:由专业美工设计各种样式的图片,进行窗口背景图片设置
2.采用GDI+纯代码绘制:参照美工设计的各种样式的图片,使用C#代码绘制出来
第一种方式很简单,但是可复用性不高,性能上面应该也会有一些影响,如果图片太大,窗口拖动等引起的重绘时,会明显有闪烁等情况。
第二种方式很复杂,但是效率和复用性高,开放各种扩张属性之后,可以适用于大部分场景。
以前用了很多次第一种方案,每次新做APP,都得重新设计界面,很不便利。这次,我准备采用第二种方案来做一个公用的皮肤。
关于GDI+,我只能算一个新人,不做具体的介绍,这里只讲我的一些实现方式,计划项目完成后,开源到github。
绘制标题栏
做自定义界面,绕不开一个问题就是绘制标题栏。
每个Winform窗体,可以分为两个部分:非客户区域和客户区域。
非客户区域:表示无法由我们程序猿绘制的部分,具体包括:窗口标题栏,边框
客户区域:表示由我们程序猿绘制的部分,也就是窗体内容,平时我们拖控件都是拖到客户区域
一般自定义窗口的实现方式无非以下种
1.设置窗口为无边框窗口,顶部放一个Panel,设置Panel.Dock=Top,然后在Panel里面绘制logo、标题、按钮等元素。
2.拦截窗口消息,重写WndProc方法,拦截窗口标题绘制消息,由自己手工绘制
很多人会为了简便,采用第一种方式,不过缺点比较明显,对于我来说,最主要的一点就是真正的实现界面,里面的控件元素Dock会受到影响,不利于客户区域布局。
高手牛人会采用第二种方式,不是我这种Winform小白的菜,所以,我采用第三种方式,也是本篇文章的核心思想。
采用无边框窗口,设置窗口Padding.Top为标题栏高度,采用GDI+绘制标题栏元素。
这种方式的好处显而易见
具体实现窗体子控件Dock不受影响
无边框之后,重写窗体拖动事件不需要对标题栏每一个元素进行事件处理
标题栏高度可随时自定义
本文开头的几个截图,标题栏绘制代码如下
绘制标题文字、Logo图片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | private void DrawTitle(Graphics g) { var x = 6 + this .GetBorderWidth(); if ( this .ShowLogo) { g.SmoothingMode = SmoothingMode.AntiAlias; ImageAttributes imgAtt = new ImageAttributes(); imgAtt.SetWrapMode(System.Drawing.Drawing2D.WrapMode.TileFlipXY); using ( var image = this .Icon.ToBitmap()) { var rec = new Rectangle(x, ( this .captionHeight - 24) / 2, 24, 24); g.DrawImage(image, rec, 0, 0, image.Width, image.Height, GraphicsUnit.Pixel, imgAtt); } } if ( this .ShowTitle) { var font = this .titleFont == null ? this .Font : this .titleFont; var fontSize = Size.Ceiling(g.MeasureString( this .Text, font)); if ( this .CenterTitle) { x = ( this .Width - fontSize.Width) / 2; } else if ( this .ShowLogo) { x += 30; } using ( var brush = new SolidBrush( this .CaptionForeColor)) { g.DrawString( this .Text, font, brush, x, ( this .CaptionHeight - fontSize.Height) / 2 + this .GetBorderWidth()); } } } |
绘制最小化、最大化、关闭、帮助按钮
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 | private void DrawControlBox(Graphics g) { if ( this .ControlBox) { ImageAttributes ImgAtt = new ImageAttributes(); ImgAtt.SetWrapMode(System.Drawing.Drawing2D.WrapMode.TileFlipXY); var x = this .Width - 32; //var rec = new Rectangle(this.Width - 32, (this.CaptionHeight - 32) / 2 + this.BorderWidth, 32, 32); //var rec = new Rectangle(x, this.BorderWidth, 32, 32); if ( this .CloseButtonImage != null ) { closeRect = new Rectangle(x, 0, 32, 32); using ( var brush = new SolidBrush(closeHover ? this .ControlActivedColor : this .ControlBackColor)) { g.FillRectangle(brush, closeRect); } g.DrawImage( this .CloseButtonImage, closeRect, 0, 0, this .CloseButtonImage.Width, this .CloseButtonImage.Height, GraphicsUnit.Pixel, ImgAtt); x -= 32; } if ( this .MaximizeBox && this .WindowState == FormWindowState.Maximized && this .MaximumNormalButtonImage != null ) { maxRect = new Rectangle(x, 0, 32, 32); using ( var brush = new SolidBrush(maxHover ? this .ControlActivedColor : this .ControlBackColor)) { g.FillRectangle(brush, maxRect); } g.DrawImage( this .MaximumNormalButtonImage, maxRect, 0, 0, this .MaximumNormalButtonImage.Width, this .MaximumNormalButtonImage.Height, GraphicsUnit.Pixel, ImgAtt); x -= 32; } else if ( this .MaximizeBox && this .WindowState != FormWindowState.Maximized && this .MaximumButtonImage != null ) { maxRect = new Rectangle(x, 0, 32, 32); using ( var brush = new SolidBrush(maxHover ? this .ControlActivedColor : this .ControlBackColor)) { g.FillRectangle(brush, maxRect); } g.DrawImage( this .MaximumButtonImage, maxRect, 0, 0, this .MaximumButtonImage.Width, this .MaximumButtonImage.Height, GraphicsUnit.Pixel, ImgAtt); x -= 32; } if ( this .MinimizeBox && this .MinimumButtonImage != null ) { minRect = new Rectangle(x, 0, 32, 32); using ( var brush = new SolidBrush(minHover ? this .ControlActivedColor : this .ControlBackColor)) { g.FillRectangle(brush, minRect); } g.DrawImage( this .MinimumButtonImage, minRect, 0, 0, this .MinimumButtonImage.Width, this .MinimumButtonImage.Height, GraphicsUnit.Pixel, ImgAtt); x -= 32; } if ( base .HelpButton && this .HelpButtonImage != null ) { helpRect = new Rectangle(x, 0, 32, 32); using ( var brush = new SolidBrush(helpHover ? this .ControlActivedColor : this .ControlBackColor)) { g.FillRectangle(brush, helpRect); } g.DrawImage( this .HelpButtonImage, helpRect, 0, 0, this .HelpButtonImage.Width, this .HelpButtonImage.Height, GraphicsUnit.Pixel, ImgAtt); x -= 32; } } } |
窗体OnPaint事件,自绘标题栏
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | protected override void OnPaint(PaintEventArgs e) { base .OnPaint(e); #region draw caption using ( var brush = new SolidBrush( this .CaptionBackgroundColor)) { e.Graphics.FillRectangle(brush, captionRect); } this .DrawTitle(e.Graphics); this .DrawControlBox(e.Graphics); #endregion #region draw border ControlPaint.DrawBorder(e.Graphics, this .ClientRectangle, borderColor, ButtonBorderStyle.Solid); #endregion } |
采用Padding来约束子实现界面的元素布局位置
当我采用了无边框窗体来做自定义皮肤之后,由于去除了非客户区域(标题栏、边框),子实现窗体的坐标位置(0,0)实际上应该会覆盖我的标题栏,不过,反编译.NET源码之后,我发现Form类有一个Padding属性,这个属性继承自Control类,它的作用与CSS中的padding相同。所以,我决定使用这个技术来约束子实现界面的元素布局位置。
每次修改标题栏高度时,需要重新生成窗体的Padding属性
1 2 3 | private int captionHeight; [Category( "标题栏" ), Description( "标题栏高度" ), DefaultValue( typeof ( int ), "40" )] public int CaptionHeight { get { return this .captionHeight; } set { this .captionHeight = value; this .SetPadding(); } } |
每次修改边框宽度时,需要重新生成窗体的Padding属性
1 2 3 | private int borderWidth; [Category( "边框" ), Description( "边框宽度" ), DefaultValue( typeof ( int ), "1" )] public int BorderWidth { get { return this .borderWidth; } set { this .borderWidth = value; this .SetPadding(); } } |
最后,隐藏掉Padding属性,外部修改无效
1 | public new Padding Padding { get ; set ; } |
附加1:标题栏自绘按钮悬浮背景色修改和单击事件处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | protected override void OnMouseMove(MouseEventArgs e) { Point p = new Point(e.X, e.Y); captionHover = captionRect.Contains(p); if (captionHover) { closeHover = closeRect != Rectangle.Empty && closeRect.Contains(p); minHover = minRect != Rectangle.Empty && minRect.Contains(p); maxHover = maxRect != Rectangle.Empty && maxRect.Contains(p); helpHover = helpRect != Rectangle.Empty && helpRect.Contains(p); this .Invalidate(captionRect); this .Cursor = (closeHover || minHover || maxHover || helpHover) ? Cursors.Hand : Cursors.Default; } else { if (closeHover || minHover || maxHover || helpHover) { this .Invalidate(captionRect); closeHover = minHover = maxHover = helpHover = false ; } this .Cursor = Cursors.Default; } base .OnMouseMove(e); }<br> |
protected override void OnMouseClick(MouseEventArgs e)
{
var point = new Point(e.X, e.Y);
if (this.closeRect != Rectangle.Empty && this.closeRect.Contains(point))
{
this.Close();
return;
}
if (!this.maxRect.IsEmpty && this.maxRect.Contains(point))
{
if (this.WindowState == FormWindowState.Maximized)
{
this.WindowState = FormWindowState.Normal;
}
else
{
this.WindowState = FormWindowState.Maximized;
}
this.maxHover = false;
return;
}
if (!this.minRect.IsEmpty && this.minRect.Contains(point))
{
this.WindowState = FormWindowState.Minimized;
this.minHover = false;
return;
}
if (!this.helpRect.IsEmpty && this.helpRect.Contains(point))
{
this.helpHover = false;
this.Invalidate(this.captionRect);
CancelEventArgs ce = new CancelEventArgs();
base.OnHelpButtonClicked(ce);
return;
}
base.OnMouseClick(e);
}
附加2:处理无边框窗体用户调整大小
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | #region 调整窗口大小 const int Guying_HTLEFT = 10; const int Guying_HTRIGHT = 11; const int Guying_HTTOP = 12; const int Guying_HTTOPLEFT = 13; const int Guying_HTTOPRIGHT = 14; const int Guying_HTBOTTOM = 15; const int Guying_HTBOTTOMLEFT = 0x10; const int Guying_HTBOTTOMRIGHT = 17; protected override void WndProc( ref Message m) { if ( this .closeHover || this .minHover || this .maxHover || this .helpHover) { base .WndProc( ref m); return ; } if (! this .CustomResizeable) { base .WndProc( ref m); return ; } switch (m.Msg) { case 0x0084: base .WndProc( ref m); Point vPoint = new Point(( int )m.LParam & 0xFFFF, ( int )m.LParam >> 16 & 0xFFFF); vPoint = PointToClient(vPoint); if (vPoint.X <= 5) if (vPoint.Y <= 5) m.Result = (IntPtr)Guying_HTTOPLEFT; else if (vPoint.Y >= ClientSize.Height - 5) m.Result = (IntPtr)Guying_HTBOTTOMLEFT; else m.Result = (IntPtr)Guying_HTLEFT; else if (vPoint.X >= ClientSize.Width - 5) if (vPoint.Y <= 5) m.Result = (IntPtr)Guying_HTTOPRIGHT; else if (vPoint.Y >= ClientSize.Height - 5) m.Result = (IntPtr)Guying_HTBOTTOMRIGHT; else m.Result = (IntPtr)Guying_HTRIGHT; else if (vPoint.Y <= 5) m.Result = (IntPtr)Guying_HTTOP; else if (vPoint.Y >= ClientSize.Height - 5) m.Result = (IntPtr)Guying_HTBOTTOM; break ; case 0x0201: //鼠标左键按下的消息 m.Msg = 0x00A1; //更改消息为非客户区按下鼠标 m.LParam = IntPtr.Zero; //默认值 m.WParam = new IntPtr(2); //鼠标放在标题栏内 base .WndProc( ref m); break ; default : base .WndProc( ref m); break ; } } #endregion |
全类文件,不晓得咋上传附件,所以没传,要的可以找我QQ。
2016年6月22日编辑添加:
由于本人在北京出差,昨晚上飞机航班延误,根本没想到突然这么多人要源码,无法做到一一回应,请大家谅解,我已经将DForm类上传,感谢“大萝卜控”给我提示。
请大家点击 这里 下载。
里面有几张图片,大家可以随便弄下,我出差比较忙,回去之后,再放github,到时再开个单章通知大家。
关于阴影的部分,大家可以先注释掉代码,完整源码放出之后,就可以了。
还有哟,我QQ在文章某个地方有显示,没加码。
很多人要源码,以前做的找不到了,新发一个,用的东西差不多。拿去吧。
https://github.com/dongger/MomoForm
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库