简介:面向 WinForms 开发者的一份 C# 自定义控件示例工程,演示如何继承 System.Windows.Forms.TextBox 并通过 GDI+ 绘制圆角边框,解决标准控件外观单一、难以满足个性化界面需求的问题。压缩包共 13 个文件、约 13KB,以 8 个 cs 源码文件为主,配合 2 个 resx 资源文件以及 settings、config、csproj 等工程配置,完整覆盖自定义控件类、设计器代码、窗体资源与项目设置,可导入 Visual Studio 后直接编译运行。已有 413 人学习/下载。示例工程不仅实现了圆角 TextBox 的绘制逻辑,还包含重写 OnPaint/OnResize、自定义边框颜色与圆角半径属性、设计期支持等关键代码,并兼顾了绘制性能与不同 DPI 下的显示效果,可作为封装自定义 WinForms 控件的参考模板。读者通过阅读与调试该项目,可以掌握控件继承、GDI+ 绘图、属性扩展和设计器集成的完整思路,便于后续灵活构建更丰富的自定义界面元素。 在 WinForms 里写自定义控件,很多人第一反应是做 UI 美化,但我实际做上位机和工厂管理系统时,自定义 TextBox 更多是为了解决“行为问题”:水印提示、输入限制、焦点边框、日志类控件的自动清空,还有跟整套软件风格统一。TextBox 是 WinForms 里最基础的输入控件,但原生控件能改的东西很少,不改代码又满足不了业务,所以才有“自定义控件”这个需求。这篇内容从第一版能跑的水印文本框开始,讲到边框重绘、输入校验和高 DPI 适配,包括我在多个正式项目里踩过的坑,适合刚接触 C# 上位机开发、或者想系统整理自定义控件思路的朋友。
1. 动手之前:自定义 TextBox 到底要解决什么问题
1.1 原生 TextBox 在真实项目中不够用的四个场景
先说结论:绝大多数项目不需要从零画一个文本框,你需要的是给原生 TextBox 加“规则”。
我见到的第一个场景是登录界面。用户名、密码、IP 地址、端口号,每个输入框想要的提示文字不一样,WinForms 自带的 TextBox 没有 Placeholder 属性,于是很多新手就直接在 Form_Load 里写if (textBox1.Text == "") textBox1.Text = "请输入用户名";,然后焦点事件再清空。这种写法不是不能用,但控件一多就乱,每个输入框都要维护“当前是提示文字还是用户输入”的状态,很容易出错。
第二个场景是数字输入。上位机里参数设置全是数值,比如温度、速度、脉冲数。原生 TextBox 允许输入任意字符,你只能靠 KeyPress 事件或者验证事件手动拦,每个窗体都要写一遍,换一个项目又复制一遍。更麻烦的是,有些输入框要支持负数,有些要限定小数位,有些要限制范围,规则一变,所有事件代码都要跟着改。
第三个场景是界面风格统一。老板或者客户看到其他软件有圆角边框、聚焦变色的输入框,也想让自己的软件“高级一点”。原生 TextBox 的边框跟随系统主题,在 Windows 11 上是系统默认样式,在 Windows 7 上又是另一种样子,不同分辨率下观感差异很明显。
第四个场景容易被忽略:调试和日志。我做串口调试工具时,经常需要一个只读的 Log 文本框,要求自动滚动到底部、按级别显示颜色、超过一定行数自动截断。用原生 TextBox 也能做,但这些逻辑如果散在窗体代码里,后续维护就是灾难。
把这些场景归纳起来,自定义 TextBox 的核心目标不是“画一个更好看的框”,而是把“输入规则、显示规则、焦点行为”封装成一个可复用的控件,让业务代码只关心数据,不关心界面细节。
1.2 两条技术路线:继承 TextBox 还是组合 UserControl
明确了需求之后,第一步是选实现方式。WinForms 里做自定义控件通常有两条路:继承TextBox直接子类化,或者做一个UserControl,里面放一个私有 TextBox,再在外面包一层绘制逻辑。
这两条路没有绝对的对错,但选错了后期会很痛苦。我做过一个统计,我这边交付的项目里,凡是偏“行为控制”的,比如水印、输入限制、回车触发事件,基本都是继承 TextBox;凡是偏“视觉定制”的,比如圆角边框、背景渐变、图标前缀,基本都是组合 UserControl。
继承 TextBox 的优势是保住了原生控件的一切行为:光标、选中、复制粘贴、右键菜单、输入法兼容都在。缺点是绘制能力受限,因为 WinForms 的 TextBox 底层是封装了系统的 Edit 控件,OnPaint事件基本不生效,你想在它上面直接画水印、画边框,会被原生绘制盖掉。
组合 UserControl 的优势是绘制完全自己控制,想画什么画什么。缺点是内部那个 TextBox 是一个子控件,焦点管理、Tab 顺序、默认事件都要手动转发,不然外部使用方拿不到Text变化通知,或者焦点样式不对。
我的建议是:如果是做项目里的一次性控件,先想清楚核心需求偏哪边;如果是想做一套公司内部控件库,我会把两种基类都建出来,一个叫ExTextBox(继承 TextBox,管行为),一个叫ShellTextBox(组合 UserControl,管外观),实际业务里按需选择。
1.3 单独建一个控件库工程,而不是塞在窗体工程里
还有个工程层面的建议:自定义控件不要放在主窗体项目里,单独建一个类库项目。
WinForms 自定义控件一旦被窗体设计器引用,设计器会尝试实例化它。如果控件类代码和窗体代码在同一个程序集,改一个属性经常导致整个窗体打不开设计器,报错信息又非常晦涩,比如“未能加载文件或程序集”或者“对象引用未设置为对象的实例”。单独建一个MyCompany.WinControls类库,主项目引用它,设计器隔离性会好很多。
另外,控件库项目要记得把输出类型设为“类库”,目标框架和主程序保持一致。如果你公司还在用 .NET Framework 4.x,那就别在控件库里用 .NET 6/8 的语法,否则主项目引用后运行时会出兼容性问题。
2. 第一版实现:一个带水印和输入限制的 TextBox
2.1 水印输入框:调用系统级 Cue Banner 消息
先实现一个最基础也最实用的功能:水印。
所谓水印,就是输入框为空时显示一段灰色提示文字,用户一旦开始输入,提示文字自动消失。WinForms 原生 TextBox 没这个属性,但 Windows 的 Edit 控件本身支持,只是 WinForms 没暴露。我们通过SendMessage发送EM_SETCUEBANNER消息就能开启。
核心代码如下:
public class WatermarkTextBox : TextBox { private string _watermark = ""; private const int EM_SETCUEBANNER = 0x1501; [Browsable(true)] [Category("外观")] [DefaultValue("")] [Description("文本框为空时显示的提示文字")] public string Watermark { get => _watermark; set { _watermark = value ?? ""; if (IsHandleCreated) SendMessage(Handle, EM_SETCUEBANNER, 1, _watermark); } } protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); if (!string.IsNullOrEmpty(_watermark)) SendMessage(Handle, EM_SETCUEBANNER, 1, _watermark); } [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, string lParam); }这里有个细节必须注意:Watermark属性的 setter 里要先判断IsHandleCreated,因为控件的句柄不是在构造函数里就创建的。你可以在设计器里设置Watermark属性,设计器会生成代码,在InitializeComponent里调用 setter,这时候 Handle 还没创建,直接发消息会失败。所以OnHandleCreated里还要再补发一次。
很多网上的教程直接写SendMessage(this.Handle, ...),不判断句柄,结果运行时正常,设计器里却什么都显示不出来,就是这个原因。
2.2 把属性暴露给属性面板,像一个正规控件
光能显示水印还不够,既然做了自定义控件,就要让它用起来像标准控件:属性能在“属性”窗口里看到,能设置默认值,能显示中文描述。
上面代码里我已经加了一堆 Attribute:Browsable控制是否显示,Category控制分组,DefaultValue控制默认值,Description控制底部提示文字。这些看起来不起眼,但用了之后团队协作体验会好很多。你同事在属性面板里看到“外观”分组下有一个“Watermark”,填上“请输入用户名”,根本不用翻代码。
这里有个坑:DefaultValue只是告诉设计器“这个值等于默认值时可以不序列化”,它不会真的把属性初始化成那个值。所以字段声明时就要给好默认值,比如private string _watermark = "";。
为了让设计器可以编辑,字段类型要选string或者Color这种有自带编辑器类型。如果你自定义一个结构体,就得自己写TypeConverter,不然属性面板里只能展开子属性,没法直接输入。
2.3 数字输入限制:拦截不合法的按键字符
水印做完之后,第二常见需求是限制输入内容。我公司里做运动控制卡的同事,用我做的数字框输入“脉冲数”,要求只能输入数字和负号,不允许输入字母和中文。
最简单的做法是重写OnKeyPress,拦截不合法字符:
public class NumericTextBox : WatermarkTextBox { private bool _allowNegative = true; private bool _allowDecimal = true; public bool AllowNegative { get => _allowNegative; set => _allowNegative = value; } public bool AllowDecimal { get => _allowDecimal; set => _allowDecimal = value; } protected override void OnKeyPress(KeyPressEventArgs e) { base.OnKeyPress(e); char c = e.KeyChar; if (char.IsControl(c)) return; if (c == '-') { if (!_allowNegative) e.Handled = true; return; } if (c == '.') { if (!_allowDecimal || Text.Contains(".")) e.Handled = true; return; } if (!char.IsDigit(c)) e.Handled = true; } }这里的一些细节比看起来要重要。
char.IsControl(c)判断退格、删除、回车等控制键,必须放行,否则输入框连退格都用不了。复制粘贴不受当前光标位置限制也被拦截。
但是这是“按键级”校验,它有一个缺陷:通过鼠标右键菜单粘贴进来的文本不会被OnKeyPress拦截。用户从别处复制一段文字,右键粘贴进去,照样会混入字母。所以,除了 OnKeyPress,最好再做一个OnTextChanged兜底校验,或者重写WndProc处理 WM_PASTE。我在实际项目中通常用后者,有点麻烦,但更稳。
如果你只是做业务系统,对健壮性要求没那么极致,可以在OnValidating事件里做正则校验,提示用户重新输入。这种方式更简单,用户也更容易理解。
3. 从“能用”到“好看”:边框、焦点和重绘管线
3.1 为什么继承 TextBox 时 OnPaint 画不出东西
上一部分的WatermarkTextBox能正常水印,但它的边框还是系统默认的。你想给它画一个蓝色聚焦边框、一个灰色普通边框,对不起,OnPaint基本不干活。
原因是 WinForms 的 TextBox 是 Win32 原生 Edit 控件,系统负责绘制它的背景、边框和文本。你在用户态重写 OnPaint,绘制的内容会被原生窗口的重绘盖掉,尤其当控件获得焦点、鼠标划过、文字变动时,系统都会重新绘制整个控件。
有一个变通方案是拦截WM_NCPAINT消息,在非客户区绘制自定义边框。但这条路的复杂度远超普通业务开发的承受范围,因为不同 Windows 版本对非客户区的布局细节不一样,还涉及DwmDefWindowProc,很容易画出诡异的残缺边框。作为一个长期维护的项目,我不建议把大量代码花在这件事上。
如果在真实项目里被逼着要做一个好看的单行输入框,我的选择一直是组合方案:用 UserControl 做外壳,内部放一个 Dock.Fill 的 TextBox,外壳负责画边框和背景。
3.2 用组合控件实现聚焦边框和圆角
组合控件的思路是这样的:UserControl 上放一个TextBox,去掉 TextBox 自己的边框,让它贴着 UserControl 边界,然后 UserControl 的Paint事件里自己画边框。用户看到的是一个整体,边框颜色跟随焦点状态变化。
代码骨架如下:
public partial class ShellTextBox : UserControl { private Color _borderColor = Color.FromArgb(200, 200, 200); private Color _focusBorderColor = Color.FromArgb(0, 120, 215); private TextBox innerBox; public ShellTextBox() { InitializeComponent(); innerBox = new TextBox { BorderStyle = BorderStyle.None, Dock = DockStyle.Fill, Font = new Font("Microsoft YaHei UI", 9.5f) }; this.Padding = new Padding(4); this.Controls.Add(innerBox); innerBox.Enter += (s, e) => { Invalidate(); }; innerBox.Leave += (s, e) => { Invalidate(); }; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); using (Pen pen = new Pen(innerBox.Focused ? _focusBorderColor : _borderColor)) { Rectangle rect = ClientRectangle; rect.Width -= 1; rect.Height -= 1; e.Graphics.DrawRectangle(pen, rect); } } }这个方案最容易被忽略的是Padding和内部 TextBox 边距的配合。TextBox 设置BorderStyle.None后,文字会紧贴边缘,所以外壳要留出 3 到 5 像素的 Padding,否则文字就会贴在边框上,视觉效果很难看。
圆角也很容易做,在 OnPaint 里用GraphicsPath画圆角矩形当边框,再给 UserControl 和内部 TextBox 设置透明背景。不过 WinForms 的透明是真的假透明,它只是把父容器背景色刷上去,如果外壳下面是图片背景,透明效果会失效。所以圆角文本框这种需求,在 WinForms 里要接受一些限制。
3.3 控件闪烁与双缓冲:哪些控件能用,哪些不能用
组合控件还有一个隐藏优势:你可以放心开双缓冲。
UserControl是标准的 .NET 控件,支持SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true)来减少闪烁。但继承自原生 Edit 控件的TextBox并不适合开双缓冲,因为它的绘制不完全走 .NET 的管道。网上一搜会看到有人让你对 TextBox 调用SetStyle减少闪烁,实测下来大概率无效,甚至会导致光标残影。
我在写组合控件时,通常会对外壳 UserControl 开启双缓冲,把边框绘制放到 OnPaint 里;内部那个 TextBox 保持原生绘制。这样的好处是:边框和背景是 .NET 画的,放心用双缓冲;文字和光标是系统画的,保持原生性能。
3.4 高 DPI 和控件宽度自适应
WinForms 在高 DPI 下有一个经典的坑:窗体设置了AutoScaleMode = AutoScaleMode.Dpi,自定义控件的字体没有跟随系统缩放,控件大小却跟着变了,结果文字挤在一角。
对自定义 TextBox 来说,内部 TextBox 的字体应该绑定到 UserControl 的 Font。在组合控件中,innerBox.Font = Font,并且当控件的 Font 变化时,要同步更新内部字体。你可以重写OnFontChanged:
protected override void OnFontChanged(EventArgs e) { base.OnFontChanged(e); innerBox.Font = Font; }另外,如果程序要支持高分屏,尽量用Font = new Font("Microsoft YaHei UI", 9f)而不是SystemFonts.DefaultFont。微软雅黑在小字号下渲染效果比宋体好,高分屏下更明显。
4. 真实项目里的坑:排查方法与工程建议
4.1 常见问题速查表
我整理了这几年被问得最多的几个问题,直接列成表格,方便你自查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 水印不显示 | Handle 未创建就发 EM_SETCUEBANNER | 在 OnHandleCreated 里补发 |
| 数字框能粘贴字母 | 只做了 OnKeyPress | 重写 WndProc 拦 WM_PASTE 或 OnTextChanged 兜底 |
| 控件闪得厉害 | TextBox 开了双缓冲 | 只对外壳 UserControl 开双缓冲,内部 TextBox 保持原生 |
| 设计器打不开 | 控件代码和窗体同项目,构造时抛异常 | 单独类库项目;构造里不访问外部资源 |
| 聚焦边框不更新 | Enter/Leave 事件里没有 Invalidate | 事件里调用 Invalidate 或 Refresh |
| 高分屏下控件变形 | Font 未同步 | 重写 OnFontChanged,同步内部控件字体 |
| PropertyGrid 改了值没反应 | 自定义属性没写 Setter 通知 | setter 里触发 Invalidate 或事件 |
4.2 构造函数里别碰 Handle,也别碰外部依赖
自定义控件最常见的崩溃来源,不是控件逻辑写错了,而是构造函数执行时的环境不对。
WinForms 窗体设计器在加载控件时,会先创建控件实例,再设置属性。如果你在构造函数里读取配置文件、查询数据库、调用外部服务,设计器一加载就炸,而且报错信息经常指向设计器生成的.Designer.cs文件,让人误以为是设计器坏了。
自定义 TextBox 的构造函数里,只做两件事:初始化字段、注册事件。其它的一律放到OnLoad、OnHandleCreated或者使用方主动调用的方法里。这个习惯能避免 90% 的设计器崩溃问题。
另外注意OnHandleCreated被调用的时机不只是控件创建时。当控件的Visible变成 true、Parent改变、窗体显示时,Handle 可能重新创建。所以OnHandleCreated里的代码要做幂等处理,别重复订阅事件、重复初始化资源。
4.3 让控件跟随容器字体和全局样式
如果你的软件做了一个“系统设置”界面,允许用户切换全局字体大小,那么所有自定义控件都要能响应这个变化。WinForms 里没有统一的事件广播,但有一个简单约定:窗体 font 变化时,容器会向子控件传播FontChanged事件。
继承自 TextBox 的控件默认就会跟随父容器字体;组合控件里的内部 TextBox 是手动添加的,默认不会自动跟随。所以我总会在组合控件里做一层转发:
protected override void OnParentChanged(EventArgs e) { base.OnParentChanged(e); if (Parent != null) innerBox.Font = Parent.Font; }这个方法不算完美,但配合OnFontChanged,能让控件在多数业务系统里保持一致的外观。
4.4 个人使用习惯和总结
如果你让我给一个最直接的建议,那就是:不要把“继承 TextBox”和“组合 UserControl”分出高下,两个都要会。行为优先时用继承,视觉效果优先时用组合。每次开发前先想三分钟,输入框到底需要改变什么,是内容的规则,还是外围的样子。
还有一个更简单的替代方案,很多项目其实不需要自定义控件。整个窗体统一用一个TableLayoutPanel做布局,所有 TextBox 都设置BorderStyle = FixedSingle,再用Enter和Leave事件改边框颜色,写一个辅助类也能做到统一效果。只有当这种辅助方案开始变得难以维护时,才值得把代码抽成控件。
WinForms 虽然老,但它在工控、企业内部管理系统这些场景里依然很能打。自定义 TextBox 这类小控件,是性价比最高的投入,一个控件写好了,能在十几个窗体里复用,省下的时间远超开发它本身花的时间。后面如果再遇到“WinForm 从入门到精通”的问题,就从自己封装第一个控件开始,比看一百遍教程都有效。
本文还有配套的精品资源,点击获取