简介:这份C# WinForm软件UI设计模板面向希望提升桌面应用界面质感的开发者,围绕WinForm与DevExpress控件组合,提供可参考、可复用的界面搭建思路,适合具备一定C#基础、需要快速完成管理系统或工具类软件UI设计的人群。压缩包共51个文件,约2.09MB,以cs源码、resources与resx资源、config配置、png界面截图及exe演示程序为主,另含sln解决方案和dll依赖,便于直接打开示例项目查看控件组织与页面布局。已有5050人学习下载,说明其在WinForm UI入门与模板复用方面具有一定参考价值。读者可从中获得Demo示例工程、界面效果图、项目配置与资源组织方式,并据此理解DevExpress控件在数据展示、菜单工具栏、皮肤样式与数据绑定中的实际用法,减少从零搭建界面的时间,也能作为课程设计、毕业项目或内部工具界面的起步模板。
1. 换一次主色要改两天:WinForm UI 设计模板到底该管哪几件事
去年接手一个内部管理工具,三十多个窗体,按钮颜色、字体、圆角都是在设计器里一个个点出来的。甲方提了个需求:主色从蓝换成绿。我改到第十七个窗体时发现,有两个窗体的按钮是运行时动态生成的,还有三个用了继承窗体,父窗体一改子窗体跟着变、但子窗体上的局部改动又覆盖不回去,最后列表格边框的 GridColor 也没放过我。
那次之后我开始维护一套自己的 C# WinForm 软件UI设计模板,它其实由四样东西组成:一份集中的颜色与尺寸令牌、一个无边框窗体的基类、一组二次封装过的常用控件,外加一份换肤时要走的广播链路。这套东西不解决“好不好看”的问题,它解决的是“改一处要动多少个文件”。适合还在用原生控件撑业务界面、又不想整套换框架的团队。下面按我当时落地的顺序讲:先把主题层立住,再啃窗体骨架,最后收在控件封装和踩坑上。
2. 主题层:把 9 个颜色令牌和 1 个字体收进可换的容器
2.1 属性面板硬改样式的三个后遗症
WinForm 设计器会把你在属性面板里设的每一个值序列化进*.Designer.cs,写成一串this.button1.BackColor = System.Drawing.Color.FromArgb(...)。这意味着三件事同时发生:颜色值散落在几十个文件里;这些值是编译期常量,运行时没有“当前主题”这个概念;继承窗体里父类的赋值和子类的赋值混在同一个 InitializeComponent 调用链上,谁覆盖谁全靠调用顺序。
更麻烦的是,控件属性没有层叠概念。CSS 里你写一条button { color: red },所有按钮自动生效;WinForm 里没有这个机制,Control.BackColor是实例属性,父容器设了背景色,子控件照样是默认的 Control 灰。有人试过用Application.OpenForms遍历所有窗体去刷,结果是打开顺序一变、样式就漏。
真正能收敛的只有一条路:把所有视觉参数从设计器里抽出来,放进一个可替换的对象,再让控件自己去这个对象里取值。这就是主题层要干的事,它本质上是一份“设计令牌”的运行时分发器。
2.2 ThemePalette:一份能序列化的颜色令牌表
令牌不用多,九个颜色加两个尺寸就够撑起大部分业务界面。我第一版列了二十多个字段,后来发现一半没人用,反而让换肤配置变得难维护。下面这张表是现在还在用的版本,浅色和深色两套值直接放在代码里,需要主题包时再从 JSON 反序列化覆盖。
| 令牌 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| Primary | 主按钮、选中态、强调线 | #4080FF | #5B9BFF |
| PrimaryHover | 主按钮悬停、链接悬停 | #6098FF | #7AAEFF |
| PrimarySoft | 表格选中行、标签底色 | #E8F0FF | #24344F |
| Surface | 窗体与卡片背景 | #FFFFFF | #1E2126 |
| SurfaceAlt | 表头、隔行、工具条 | #F6F7F9 | #262A31 |
| Border | 分隔线、输入框描边 | #DEE2E9 | #343A44 |
| TextPrimary | 正文 | #20242C | #E6E9EF |
| TextSecondary | 辅助说明、占位符 | #6E7684 | #98A1B0 |
| Danger | 删除、错误提示 | #DC4444 | #F06A6A |
尺寸类令牌只有两个:CornerRadius(圆角半径,逻辑像素)和BaseFont(基准字体)。把它们和颜色放在同一个类里,是因为换肤的时候圆角和字体往往一起换——深色模式下字体常常要减一档字号、圆角要收一点,分成两个对象反而容易漏。
public sealed class ThemePalette { public string Name { get; set; } = "Light"; public Color Primary { get; set; } = Color.FromArgb(0x40, 0x80, 0xFF); public Color PrimaryHover { get; set; } = Color.FromArgb(0x60, 0x98, 0xFF); public Color PrimarySoft { get; set; } = Color.FromArgb(0xE8, 0xF0, 0xFF); public Color Surface { get; set; } = Color.White; public Color SurfaceAlt { get; set; } = Color.FromArgb(0xF6, 0xF7, 0xF9); public Color Border { get; set; } = Color.FromArgb(0xDE, 0xE2, 0xE9); public Color TextPrimary { get; set; } = Color.FromArgb(0x20, 0x24, 0x2C); public Color TextSecondary { get; set; } = Color.FromArgb(0x6E, 0x76, 0x84); public Color Danger { get; set; } = Color.FromArgb(0xDC, 0x44, 0x44); public Color Disabled { get; set; } = Color.FromArgb(0xB9, 0xBF, 0xC9); // 逻辑像素,绘制时再乘 DPI 系数 public int CornerRadius { get; set; } = 6; // 基准字体只创建一次,换肤时替换旧实例要记得 Dispose public Font BaseFont { get; set; } = new Font("Microsoft YaHei UI", 9F, FontStyle.Regular, GraphicsUnit.Point); }字段命名按“语义”而不是“颜色”来定,Primary而不是Blue,这样深色模式下整套值替换掉,代码里一个引用都不用动。BaseFont用一个具体字体族是现实妥协,不同机器上这个字体可能缺失,构造时会静默回退到系统默认字体,所以字体名称不要参与任何逻辑判断。
提示:
Font实现了IDisposable,如果你在换肤时用new Font(...)生成新实例,旧实例必须释放。静态单例里挂着的默认字体建议不要 Dispose,让它跟随进程生命周期,否则引用它的控件在重绘时会抛异常。
2.3 换肤事件与递归应用:从 Form 到 ToolStripItem
主题对象有了,接下来要解决“谁在什么时候把值推给控件”。这里我不建议用轮询,也不建议在每个控件的OnPaint里现取ThemeManager.Current——前者浪费,后者会让控件的Invalidate触发链变得不可控。做法是事件广播:主题一变,通知所有实现了接口的控件刷一次。
public static class ThemeManager { public static ThemePalette Current { get; private set; } = new ThemePalette(); // 主题变更广播,控件在构造或 HandleCreated 时订阅 public static event EventHandler ThemeChanged; public static void Apply(ThemePalette palette) { if (palette == null) throw new ArgumentNullException(nameof(palette)); Current = palette; ThemeChanged?.Invoke(null, EventArgs.Empty); } // 从根容器向下递归,逐个调用 IThemeable.ApplyTheme public static void ApplyTree(Control root) { if (root == null) return; if (root is IThemeable t) t.ApplyTheme(Current); // 倒序遍历,防止在 ApplyTheme 里动态增删控件导致索引越界 for (int i = root.Controls.Count - 1; i >= 0; i--) ApplyTree(root.Controls[i]); // ToolStripItem 不是 Control,不在 Controls 集合里,要单独走一遍 if (root is ToolStrip strip) ApplyItems(strip.Items); } private static void ApplyItems(ToolStripItemCollection items) { foreach (ToolStripItem item in items) { if (item is IThemeable ti) ti.ApplyTheme(Current); if (item is ToolStripDropDownItem drop) ApplyItems(drop.DropDownItems); } } }三个地方值得说清楚。第一,倒序遍历Controls:ApplyTheme里如果碰巧创建或销毁了子控件,正向遍历会跳过元素甚至抛ArgumentOutOfRangeException,这是我在一个带动态标签页的窗体上翻过的车。第二,ToolStripItem体系独立于Control体系,菜单项、工具条按钮都不在Controls树里,必须单独递归,而且ToolStripDropDownItem的下拉项是懒加载的、只有展开过才有实例。第三,ThemeChanged是静态事件,订阅了不取消就是内存泄漏,控件在Disposed里要记得-=。
调用侧就变成了两行:换肤时ThemeManager.Apply(dark);,然后对当前主窗体ThemeManager.ApplyTree(mainForm);。新开的窗体在OnLoad里调一次ApplyTree(this)即可。
2.4 逻辑单位:为什么所有尺寸都要过一遍 Dpi.Px
颜色解决了,尺寸还没解决。设计器是按 96 DPI 存坐标的,一个Size = new Size(96, 32)的按钮,在 150% 缩放的屏幕上如果系统做了位图拉伸,边缘会糊;如果不拉伸,按钮会小一圈,文字挤在一起。这两种情况我都见过,前者出现在没开 DPI 感知的进程里,后者出现在开了感知但代码里全写死像素的项目里。
模板里的统一约定是:所有尺寸常量按逻辑像素写,绘制前乘一次缩放系数。为此准备一个极薄的工具类:
public static class Dpi { // DeviceDpi 在 96 DPI 下为 96,150% 缩放下为 144 public static float Scale(Control c) { int dpi = c == null ? 96 : c.DeviceDpi; return dpi / 96f; } // 逻辑像素转设备像素,四舍五入避免累积半像素导致 1px 线消失 public static int Px(Control c, int logical) { return (int)Math.Round(logical * Scale(c)); } // 字体按点数本来就是逻辑单位,只需按缩放比例放大字号 public static Font ScaleFont(Control c, Font baseFont) { float size = baseFont.Size * Scale(c); return new Font(baseFont.FontFamily, size, baseFont.Style, GraphicsUnit.Point); } }DeviceDpi是 .NET Framework 4.7 之后才有的属性,老项目上可能拿不到,退路是读Graphics.FromHwnd(Handle).DpiX,代价是每次要建 Graphics 对象,别在OnPaint里反复调。ScaleFont里返回的是新Font实例,调用方负责释放,这一点很容易忘,我一般只在窗体的OnDpiChangedAfterParent里调它,而不是每次绘制。
3. 无边框窗体:八方向缩放、拖拽和最大化的三处硬骨头
3.1 FormBorderStyle.None 之后你失去了什么
把FormBorderStyle设成None,视觉上是干净了,但同时丢掉了四样系统级能力:窗口边缘的八方向缩放热区、标题栏拖拽、双击标题栏最大化、以及最大化时对任务栏的避让。这四样没有一样能靠属性补回来,全要自己在WndProc里处理。
我见过两种绕法。一种是保留Sizable边框,然后用Region把边框裁掉——问题是非客户区还在,标题栏高度、系统菜单、边框阴影都跟着回来了,裁剪只是视觉欺骗,鼠标移到边缘时光标形状还是会暴露。另一种是用Padding加一层自绘边框模拟——缩放能用,但拖拽和最大化避让依然缺失。
所以模板里的BaseForm走的是完整自绘路线:FormBorderStyle.None+ 重写WndProc。代价是所有系统行为都要自己实现一遍,好处是每一像素都可控,深色模式下不会出现系统标题栏那种割裂感。
3.2 WM_NCHITTEST:八个方向的命中测试怎么写
Windows 判断鼠标落在窗口哪个部位,靠的是WM_NCHITTEST消息的返回值。默认实现会返回HTCLIENT(客户区),我们把它改成边缘时返回HTLEFT之类的常量,系统就会自动接管后续的拖拽缩放,连鼠标光标形状都不用自己设。
public class BaseForm : Form { private const int WM_NCHITTEST = 0x0084; private const int WM_NCLBUTTONDOWN = 0x00A1; private const int WM_GETMINMAXINFO = 0x0024; private const int HTCLIENT = 1; private const int HTCAPTION = 2; private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13; private const int HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17; // 缩放热区宽度,逻辑像素 protected virtual int ResizeBorder => 6; protected override void WndProc(ref Message m) { if (m.Msg == WM_NCHITTEST) { base.WndProc(ref m); // 系统已判定为非客户区(比如滚动条),直接采纳 if ((int)m.Result != HTCLIENT) return; int lp = m.LParam.ToInt32(); short sx = unchecked((short)(lp & 0xFFFF)); short sy = unchecked((short)((lp >> 16) & 0xFFFF)); Point p = PointToClient(new Point(sx, sy)); int b = Dpi.Px(this, ResizeBorder); bool left = p.X <= b; bool right = p.X >= ClientSize.Width - b; bool top = p.Y <= b; bool bottom = p.Y >= ClientSize.Height - b; if (top && left) m.Result = (IntPtr)HTTOPLEFT; else if (top && right) m.Result = (IntPtr)HTTOPRIGHT; else if (bottom && left) m.Result = (IntPtr)HTBOTTOMLEFT; else if (bottom && right) m.Result = (IntPtr)HTBOTTOMRIGHT; else if (left) m.Result = (IntPtr)HTLEFT; else if (right) m.Result = (IntPtr)HTRIGHT; else if (top) m.Result = (IntPtr)HTTOP; else if (bottom) m.Result = (IntPtr)HTBOTTOM; return; } base.WndProc(ref m); } }几个参数要说清楚。ResizeBorder用逻辑像素 6 是手感折中值:太小了鼠标难抓,太大了窗体内容区会被误判成缩放区。在高分屏上它必须过Dpi.Px,否则 200% 缩放下热区只剩 6 个物理像素,基本抓不住。
LParam里的坐标是屏幕坐标,低 16 位是 X、高 16 位是 Y,而且都是带符号的 16 位整数——多显示器场景下副屏坐标可能是负数,用(int)(lp & 0xFFFF)会得到 65530 这种值,必须走unchecked((short)...)转回来。这个坑我在一台左侧副屏的机器上卡了半天。
先调base.WndProc再判断返回值这一步不能省。窗体上如果有ScrollableControl,滚动条区域本身就应该是非客户区行为,我们要让系统先判,判成客户区再自己接管,否则滚动条会被当成缩放热区。
3.3 标题栏拖拽与双击最大化
边缘缩放交给系统了,中间那块标题栏拖拽还得自己来。系统的标准做法是发一条WM_NCLBUTTONDOWN消息、参数用HTCAPTION,等价于“假装用户按住了标题栏”,之后系统自己接管拖动循环。
[DllImport("user32.dll")] private static extern bool ReleaseCapture(); [DllImport("user32.dll", CharSet = CharSet.Auto)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, int wParam, int lParam); // 在自定义标题栏的 MouseDown 里调用 protected void BeginDragMove() { ReleaseCapture(); // 释放当前鼠标捕获,否则拖动会卡顿 SendMessage(Handle, WM_NCLBUTTONDOWN, HTCAPTION, 0); }ReleaseCapture必须先调。WinForm 控件默认在MouseDown时会捕获鼠标,如果不释放,系统进入拖动循环后收到的鼠标消息还是发回原控件,表现为窗体跟手但一顿一顿的。这个现象很玄学,第一次遇到时我以为是重绘性能问题,查了两天才定位到。
双击最大化同理,在标题栏的DoubleClick里判断WindowState后切换Maximized/Normal即可。真正麻烦的是最大化状态下的“还原”位置:系统默认会记住上次的位置,但无边框窗体在最大化时如果做过Region裁剪,还原后圆角可能错位,需要在OnResize里重新计算。
3.4 圆角、阴影与最大化边界:三种方案的取舍
圆角这块有三条路,各有代价,模板里我最终选了第一条。
| 方案 | 实现 | 优点 | 代价 |
|---|---|---|---|
| Region 裁剪 | GraphicsPath转Region赋给Form.Region | 代码最少,兼容性最好 | 边缘无抗锯齿,深色背景下有明显锯齿 |
| 分层窗口 | UpdateLayeredWindow逐像素绘制 | 圆角和阴影都能做到位 | 重绘要走位图,控件渲染复杂度陡增 |
| 系统圆角 | DwmSetWindowAttribute设置圆角偏好 | 效果最自然,带系统阴影 | 仅在较新系统版本生效,旧系统要降级 |
Region方案的锯齿问题在浅色背景上不明显,深色背景下一眼就能看出来,缓解办法是把圆角半径调大一点、让锯齿分布更分散,或者干脆在圆角外面再包一层与父容器同色的边。分层窗口方案我试过一版,控件一多就开始掉帧,最后放弃了。
阴影有个取巧写法,在CreateParams里加类样式位:
protected override CreateParams CreateParams { get { const int CS_DROPSHADOW = 0x00020000; CreateParams cp = base.CreateParams; cp.ClassStyle |= CS_DROPSHADOW; return cp; } }它只对部分窗体样式生效,而且和Region裁剪一起用时阴影会被裁掉一部分,实际效果不如预期,别指望它撑起整个视觉层次。
最大化盖住任务栏是必踩的坑。无边框窗体最大化时系统不再按工作区约束,直接铺满整块屏幕。标准解法是拦WM_GETMINMAXINFO,把ptMaxPosition和ptMaxSize改成显示器工作区。
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] private struct MINMAXINFO { public POINT ptReserved; public POINT ptMaxSize; public POINT ptMaxPosition; public POINT ptMinTrackSize; public POINT ptMaxTrackSize; } protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); // 让最大化边界跟随当前显示器工作区 MaximizedBounds = Screen.FromHandle(Handle).WorkingArea; }MaximizedBounds是 WinForm 提供的托管入口,比手写WM_GETMINMAXINFO省事。注意WorkingArea的坐标系在多显示器且缩放比例不一致时可能不是物理像素,如果发现最大化后偏了几个像素,退回到MonitorFromWindow加GetMonitorInfo取rcWork那条路,单位就是纯物理像素。
4. 控件模板:圆角按钮、输入框、表格的统一样式怎么封
4.1 从 Control 派生还是从 Button 派生
这是封装控件时的第一个岔路口。从Control派生,一切自绘,自由度最高,但你要自己实现点击、键盘响应、焦点管理、无障碍支持、DialogResult联动,工作量大概是从Button派生的五倍。从Button派生,这些行为白送,代价是必须把父类的绘制彻底关掉,否则会出现“系统按钮底色 + 自绘圆角”叠在一起的鬼影。
模板里的取舍是:按钮、输入框这类有复杂交互语义的,从原生控件派生;纯装饰性的容器、分隔线、标签,从Control派生。判断标准很简单——这个控件需不需要响应Tab键、需不需要参与AcceptButton逻辑、需不需要被屏幕阅读器识别。只要有一个答案是“要”,就别从Control重写。
从Button派生时,构造函数里这几行是必须的:
SetStyle(ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.ResizeRedraw, true); FlatStyle = FlatStyle.Flat; FlatAppearance.BorderSize = 0;UserPaint把绘制权交给我们,OptimizedDoubleBuffer加AllPaintingInWmPaint消除闪烁,ResizeRedraw保证尺寸变化时整体重绘而不是只重绘暴露区域。FlatStyle = Flat加BorderSize = 0是为了让父类不去画那圈系统边框——少了这两行,OnPaint里画得再漂亮,边缘还是会露出一条灰线。
4.2 RoundButton:圆角、hover、焦点框的最小实现
状态机只需要四个:常态、悬停、按下、禁用。用字段记状态、在OnPaint里按状态取色,比在每个事件里直接改BackColor可控得多——后者会触发额外的Invalidate,而且状态切换时容易漏掉某个分支。
public class RoundButton : Button, IThemeable { private enum BtnState { Normal, Hover, Pressed, Disabled } private BtnState _state = BtnState.Normal; // true 为主按钮(实心),false 为次按钮(描边) public bool IsPrimary { get; set; } = true; public RoundButton() { SetStyle(ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.ResizeRedraw, true); FlatStyle = FlatStyle.Flat; FlatAppearance.BorderSize = 0; Cursor = Cursors.Hand; Size = new Size(96, 32); } protected override void OnMouseEnter(EventArgs e) { SetState(BtnState.Hover); base.OnMouseEnter(e); } protected override void OnMouseLeave(EventArgs e) { SetState(BtnState.Normal); base.OnMouseLeave(e); } protected override void OnMouseDown(MouseEventArgs e) { SetState(BtnState.Pressed); base.OnMouseDown(e); } protected override void OnMouseUp(MouseEventArgs e) { SetState(BtnState.Hover); base.OnMouseUp(e); } protected override void On <p> <a href="https://download.csdn.net/download/bye_cherry/10004744" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>