简介:在窗体应用开发中,简洁现代的界面设计常需要自定义控件。此资源提供一款仅显示底部一条直线的文本框控件源码,通过继承基础文本框类并重写绘画方法,先以窗体同色填充背景,再用细笔绘制底边,实现单线条输入框,适合轻量化表单或极简风格界面,避免默认边框的突兀感。资源包共35个文件,以9个C#源代码文件为核心,另含4个动态库、调试符号、工程文件及说明文档,压缩后仅53KB,便于学习阅读。除核心控件实现外,还附带示例程序,可直接运行查看效果,并参考其中对鼠标悬停、焦点切换等事件的重写方式,掌握自定义控件从外观到交互的完整写法。已有516人学习浏览,是窗体自绘控件入门的实用参考,也可直接提取源码改造复用。
1. 一个只显示一条直线的 TextBox:从需求到控件的完整落地
接手过一个内部系统的登录界面改造,需求只有一句:把输入框做成只显示底部一条线的极简风格。一开始以为把 BorderStyle 改成 None 再画条线就行,真正动手才知道 WinForms 的 TextBox 没那么听话——系统边框、焦点态、提示文字、DPI 缩放,任何一个环节不处理,那条直线都会以各种姿势翻车。这份 TextBox控件一条直线源码就是为这个场景准备的:一个继承自 TextBox 的自定义控件,重写绘制逻辑,让输入框干净地只留下一条横线,同时保留光标、选中、右键菜单这些原生能力。适合正在做 WinForms 现代化界面的开发者,或者想把自绘控件机制搞懂的人,拿过去改改就能用在登录页、搜索框、设置表单上。
2. 整体设计与技术选型:为什么这个控件值得用继承而不是从零画
2.1 继承 TextBox 还是从 Control 重写:一条线的两种实现路线
要做"只显示一条直线"的输入框,表面看有两条路:一是继承 Control 从零画一个文本框,二是继承现成的 TextBox 做绘制层改造。前者自由度最高,想画成什么样都行,但代价是光标、输入法、选中态、右键菜单全部要自己实现,工程量翻倍;后者保留了 WinForms 文本框几十年积累的原生交互,只需要在绘制层动手脚。
这条源码选的是继承 TextBox,这是成熟项目里最常见的做法。原因很实际:登录框也好、搜索框也好,用户对输入交互的预期是"和普通输入框一致",谁也不想遇到中文输入法候选框位置不对、光标不闪烁这类基础问题。继承 TextBox 等于把这些行为全部白嫖到手,要改的只是"画出来长什么样"这一层。
另一个设计决策是关闭系统边框。TextBox 自带的 BorderStyle 有 None、FixedSingle、Fixed3D 三种,要让下划线完全可控,必须设成 None,否则系统边框会先画一层,自己再画一条线,两层叠在一起就是重影。源码的构造函数里对这个做了显式处理,后面会讲。
2.2 源码目录结构:一个类文件干完所有事
这份源码的工程结构很精简,核心只有一个控件类,其余是承载演示的窗体骨架。
UnderlineTextBox/ ├── UnderlineTextBox.cs // 自定义控件主类,所有绘制和交互逻辑都在这里 ├── UnderlineTextBoxDemo/ // 演示工程 │ ├── Form1.cs // 演示窗体,放置控件并展示效果 │ ├── Form1.Designer.cs // 窗体设计器代码 │ ├── Program.cs // 入口 │ └── UnderlineTextBoxDemo.csproj控件类单独拧出来放在上层,意味着你可以把 UnderlineTextBox.cs 直接拖进任意一个 WinForms 工程,不需要引用整个解决方案。演示工程的作用不是展示复杂用法,而是验证一个最简单的场景:拖进去、改几行属性、跑起来看到那条线。
整个控件不依赖任何第三方包,纯 .NET Framework / .NET Core WinForms 自带 API 实现。这对想改造成自己项目一部分的人来说很重要——没有包版本冲突,没有 NuGet 依赖地狱,拷过去就能编。
2.3 绘制架构:三层内容叠加的 OnPaint 策略
控件最终呈现出来的效果由三层绘制叠加而成:背景层、文字层、下划线层。背景层直接清屏填充 BackColor;文字层用 TextRenderer 画真实文本,没输入时画占位提示文字;下划线层在最底部用 Pen 画一条横线,按焦点状态切换颜色和粗细。
这里有个 WinForms 的老玄学必须点出来:TextBox 默认不是自绘控件,你单纯重写 OnPaint 不会生效,因为系统窗口接管了绘制。要让自绘代码跑起来,必须在构造函数调用 SetStyle 打开 UserPaint 标志,同时配合 AllPaintingInWmPaint 和 OptimizedDoubleBuffer 减少闪烁。源码里正是这样处理的,第一次写这种控件的人最容易在这个点卡住。
3. 核心代码拆解:从构造到消息处理的完整链路
3.1 构造与属性定义:暴露给使用者的可配置项
先看构造函数和属性这块。控件的公共属性决定了使用者在设计器里能调什么,源码里把颜色、提示文字、线条粗细全部做成了可配置项。
public partial class UnderlineTextBox : TextBox { private Color _underlineColor = Color.FromArgb(160, 160, 160); private Color _focusUnderlineColor = Color.FromArgb(64, 158, 255); private string _placeholderText = string.Empty; private Color _placeholderColor = Color.FromArgb(180, 180, 180); private int _underlineThickness = 2; public UnderlineTextBox() { // 关键:打开自绘标志,否则 OnPaint 永远不会被调用 SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); // 关掉系统边框,避免重影 BorderStyle = BorderStyle.None; // 初始高度预留绘制区域 Height = 28; } [Category("Appearance")] public Color UnderlineColor { get => _underlineColor; set { _underlineColor = value; Invalidate(); } } [Category("Appearance")] public Color FocusUnderlineColor { get => _focusUnderlineColor; set { _focusUnderlineColor = value; Invalidate(); } } [Category("Appearance")] public string PlaceholderText { get => _placeholderText; set { _placeholderText = value; Invalidate(); } } [Category("Appearance")] public Color PlaceholderColor { get => _placeholderColor; set { _placeholderColor = value; Invalidate(); } } [Category("Appearance")] public int UnderlineThickness { get => _underlineThickness; set { _underlineThickness = Math.Max(1, value); Invalidate(); } } }构造函数里 SetStyle 的三行是核心。UserPaint 告诉系统这个控件由托管代码绘制,OnPaint 才会被调用;AllPaintingInWmPaint 让所有绘制消息统一走 OnPaint,避免系统先画一遍又让 OnPaint 画一遍造成闪烁;OptimizedDoubleBuffer 是双缓冲,绘制频繁刷新时不会肉眼可见地闪。
属性全部用 Category 标记,在 Visual Studio 设计器里会自动归类到"外观"分组。每个 setter 里都调 Invalidate(),强制触发重绘,这样在设计器里改属性的同时,设计视图会实时刷新,不用重新编译就能看到效果。UnderlineThickness 的 setter 还做了下限保护,Math.Max(1, value) 防止有人填 0 把线画没。
3.2 OnPaint 重写:文字、提示、下划线的三合一绘制
绘制主体在 OnPaint 里,这一段是整套控件的核心。它把前面说的三层内容一次画完。
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.Clear(BackColor); Rectangle textRect = ClientRectangle; textRect.Height -= _underlineThickness; // 真实输入文字 if (Text.Length > 0) { TextRenderer.DrawText(g, Text, Font, textRect, ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); } // 提示文字:仅当没有输入且设置了占位符时出现 else if (!string.IsNullOrEmpty(_placeholderText)) { TextRenderer.DrawText(g, _placeholderText, Font, textRect, _placeholderColor, TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); } // 下划线:焦点用高亮色,失焦用普通色 Color lineColor = Focused ? _focusUnderlineColor : _underlineColor; using (Pen pen = new Pen(lineColor, _underlineThickness)) { int y = Height - _underlineThickness; g.DrawLine(pen, 0, y, Width, y); } base.OnPaint(e); }textRect 的高度先减掉下划线的厚度,否则文字会跟线贴在一起。TextRenderer.DrawText 和 Graphics.DrawString 的区别值得留意:DrawText 用的是 GDI 的文本渲染,对中文字体渲染更锐利,而且 TextFormatFlags.VerticalCenter 能让文字在矩形内垂直居中,手动算坐标的麻烦全省了。
焦点判断直接用 TextBox 自带的 Focused 属性。获取焦点画高亮蓝、失去焦点画灰色,这是极简输入框常见的交互反馈。Pen 对象放在 using 里,每帧绘制用完就释放,这个习惯能避免 GDI 对象泄漏——WinForms 程序跑久了出现"创建图形对象失败"的异常,十有八九是 Pen 和 Brush 没释放。
最后一个细节是 base.OnPaint(e),它在所有自绘完成后调用。有人习惯把 base.OnPaint 放最前面,在这个控件里会导致系统先画一层造成闪动,所以放在尾部,确保自绘内容完整覆盖。
3.3 消息处理:拦截 WM_PAINT 之外的关键消息
OnPaint 负责静态画面的呈现,但控件的交互行为还需要在消息层兜底。源码里重写了 WndProc 处理一组和输入体验相关的 Windows 消息。
protected override void WndProc(ref Message m) { const int WM_PAINT = 0x000F; const int WM_ERASEBKGND = 0x0014; const int WM_NCPAINT = 0x0085; switch (m.Msg) { // 拦截背景擦除,避免绘制前先清一次屏造成闪动 case WM_ERASEBKGND: return; // 非客户区绘制消息直接吃掉,改为由 OnPaint 统一处理 case WM_NCPAINT: return; default: base.WndProc(ref m); break; } }WM_ERASEBKGND 是闪屏的罪魁祸首。系统默认在重绘前先擦除背景,擦一次、画一次,视觉上就是闪。直接 return 掉这个消息,配合构造里的 OptimizedDoubleBuffer,闪烁问题从根上断掉。
WM_NCPAINT 在设置了 Self-Driving 绘制的控件里会干扰整体效果,吃掉后让它走 OnPaint 统一绘制,能避免下划线边缘出现像素级别的残影。这两个消息是最常见的坑,网上搜"WinForms 自绘控件闪烁",八成帖子都在讨论它。
需要注意,WndProc 里没有处理 WM_PAINT,因为 UserPaint 模式会把刷新统一调度到 OnPaint,再拦截一次反而多余。只拦 ERASEBKGND 和 NCPAINT 足够。
4. 使用与验证:在真实窗体上跑出那条线
4.1 最小运行配置:三步跑起 Demo
拿到代码后想最快看到效果,按三步操作:用 Visual Studio 打开演示工程,直接按 F5 编译运行,主窗体上就会出现一个已经放好的 UnderlineTextBox,点击输入框时下划线从灰色变蓝色,空白时显示提示文字。
如果你想在现有项目里用这个控件,更典型的路径是:打开你自己的窗体设计器,把 UnderlineTextBox.cs 拖进工程,编译一次,控件就会出现在工具箱顶部的组件栏里,然后像拖普通 TextBox 一样拖到窗体上即可。
拖上去之后有个容易忽略的点:控件的默认高度是 28 像素,如果你把 Anchor 或 Dock 设成自动拉伸,下划线会跟着变。登录表单里一般期望固定高度,建议在设计器里把 Anchor 设置为 Left | Right,锁住垂直方向。
4.2 属性对照表:每个公开属性管什么
控件在属性面板里暴露的内容不多,但每项都能直接影响观感,对照关系如下:
| 属性 | 类型 | 作用 | 建议取值 |
|---|---|---|---|
| UnderlineColor | Color | 失焦时下划线颜色 | 灰色系,如 160,160,160 |
| FocusUnderlineColor | Color | 聚焦时下划线颜色 | 品牌高亮色,如 64,158,255 |
| PlaceholderText | string | 无输入时显示的提示文字 | 如"请输入用户名" |
| PlaceholderColor | Color | 提示文字颜色 | 比正文浅一到两阶 |
| UnderlineThickness | int | 下划线粗细(像素) | 1 到 3 之间 |
| Font | Font | 输入文字和提示文字的字体 | 与全局字体一致 |
| ForeColor | Color | 输入文字颜色 | 深灰或近黑色 |
属性面板里 Font 和 ForeColor 是 TextBox 自带的,但在这个控件里它们同时影响输入文字和提示文字的显示,改的时候要意识到这一点。设计器里实时预览是这项源码体验做得比较好的地方——因为每个 setter 都触发了 Invalidate,改完属性窗口的内容,设计界面马上刷新。
4.3 行为验证:从输入到失焦的完整测试路径
控件跑起来以后,验证的重点不是"能不能画出线",而是自绘模式下交互有没有被破坏。我一般按下面这条路径过一遍:
第一步,初始状态检查。窗体加载后控件无输入,显示浅灰色提示文字,下划线为普通灰。第二步,聚焦检查。用鼠标点击或 Tab 键进入控件,下划线切换为高亮色,文字光标正常闪烁,提示文字消失。第三步,输入检查。输入中文、英文、数字混合内容,确认字符显示清晰无重绘残影,输入法候选框定位正常。第四步,复制粘贴检查。Ctrl+C、Ctrl+V 操作可用,右键菜单弹得出来。第五步,失焦检查。点击窗体其他区域,下划线变回灰色,但输入的文字保留。
这套路径能覆盖 90% 的自绘控件翻车点。如果其中有任何一步不对,问题基本都出在消息处理那层,回到 3.3 节的 WndProc 里排查。
5. 避坑排查:自绘 TextBox 最容易翻车的五个场景
5.1 下划线被系统边框重影覆盖
现象:下划线画出来了,但上面叠着一层系统默认的立体边框,看起来又脏又乱。
原因:BorderStyle 没有设为 None。TextBox 的默认边框是 Fixed3D 或 FixedSingle,系统在绘制边框时占了底部几个像素,和自绘的下划线叠加。
解决:在构造里显式设置 BorderStyle = BorderStyle.None。如果是在设计器里拖的控件,还要在设计器属性面板里把 BorderStyle 改掉,因为设计器序列化出来的赋值发生在构造之后,会覆盖构造里的设置。
5.2 提示文字在输入后仍然显示
现象:已经输入了字符,占位提示文字还压在输入内容后面,两层文字重叠。
原因:判断是否显示提示文字时,漏掉了对焦点状态的组合判断,或者只判断了 Text 是否为空。
解决:OnPaint 里的提示分支严格按"Text 长度为 0 且设置了 PlaceholderText"来走。代码里用的是Text.Length > 0先判断真实文本,else if 才轮到提示文字,这个顺序保证不会重叠。
5.3 DPI 缩放后下划线偏离底部
现象:100% 缩放下线位置正确,系统缩放到 125% 或 150% 时,下划线要么跑到控件外面,要么离文字越来越近。
原因:WinForms 默认的 AutoScaleMode 是 Font,DPI 变化时控件高度被缩放,但 OnPaint 里用的是物理像素坐标,Height 属性此时返回的是缩放后的值,叠加机制乱了。
解决:一种做法是让 OnPaint 里所有坐标都从 ClientRectangle 推导,不要写死数字。更彻底的方案是在窗体级别设置 AutoScaleMode = Dpi,并把控件的 MinimumSize 设为一个跟随 DPI 换算的值。实践中建议至少把 y 坐标写成ClientRectangle.Bottom - _underlineThickness,而不是Height - _underlineThickness,后者在缩放时偶尔会差一两像素。
5.4 快速输入时下划线闪动
现象:连续打字时整条线轻微闪动,同时文字区域偶发残影。
原因:WM_ERASEBKGND 没拦掉,系统在每次重绘时先擦背景再画前景,双缓冲设置没有完全生效。
解决:确认 WndProc 里 WM_ERASEBKGND 是直接 return,不要再调用 base。同时检查构造里的 SetStyle 是否包含 OptimizedDoubleBuffer。这两个条件同时满足,闪烁基本能压干净。
5.5 设计器里控件显示异常,运行时却正常
现象:设计器里控件一团乱,文字不显示,下划线位置也不对;F5 运行起来一切正常。
原因:设计器环境下 OnPaint 的时机和运行时不同,有些属性在设计器构造阶段还是默认值,导致绘制分支走了错误路径。
解决:给控件类加[DesignerCategory("code")]特性,并在属性 setter 里用 DesignMode 属性做一次判断。虽然不是所有情况都能完全规避,但能显著减少设计器里红叉或者空白控件的概率。遇到这种情况,最省心的办法是先编译一次再打开设计器,让属性序列化充分。
6. 二次开发:把它升级成带浮动标签的输入框
拿到这份源码,最常见的改造需求就是"下划线输入框 + 浮动标签"——输入框为空且失焦时,标签显示在输入框内部;聚焦或输入后,标签平滑移到上边框位置。这个效果在 Web 端很多组件库里都有,WinForms 里自己实现也不算复杂。
改动集中在 OnPaint 和新增一个属性上。新增PlaceholderLabelText属性,逻辑改成:有内容或聚焦时,提示文字用更小的字号画到顶部区域;无内容且未聚焦时,提示文字按正常字号画在中间。
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.Clear(BackColor); bool hasText = Text.Length > 0; float ratio = hasText || Focused ? 1f : 0f; // 简化版:不处理动画过程 Rectangle textRect = ClientRectangle; textRect.Height -= _underlineThickness; if (hasText || Focused) { // 浮动态:小号字体画在顶部 Font smallFont = new Font(Font.FontFamily, Font.Size * 0.7f, Font.Style); TextRenderer.DrawText(g, _placeholderText, smallFont, new Rectangle(textRect.Left, 2, textRect.Width, 18), _focusUnderlineColor, TextFormatFlags.Left | TextFormatFlags.NoPadding); smallFont.Dispose(); } if (hasText) { TextRenderer.DrawText(g, Text, Font, textRect, ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); } else if (!Focused) { TextRenderer.DrawText(g, _placeholderText, Font, textRect, _placeholderColor, TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); } using (Pen pen = new Pen(Focused ? _focusUnderlineColor : _underlineColor, _underlineThickness)) { g.DrawLine(pen, 0, Height - _underlineThickness, Width, Height - _underlineThickness); } base.OnPaint(e); }这里把浮动逻辑拆成了两步:有内容或聚焦时画顶部小号标签;再单独处理正文或占位符。为了让"移动"看起来更顺滑,可以引入一个 float 类型的_labelProgress字段,在 Enter 和 Leave 事件里用 Timer 驱动从 0 到 1 的插值,控制标签的 Y 坐标和字号。
验证这套改造是否成功,和前面验证基础控件的方法一致:聚焦、输入、失焦三种状态各看一遍,确认标签位置正确、文字不重叠、失焦后有内容的输入框标签仍然保持浮动。再验证一次 DPI 125% 和 150% 下的显示,因为浮动标签涉及绝对坐标,是最容易在缩放下越界的地方。
改完这个控件再回头看,那次做登录框的经历让我学到一个习惯:遇到 WinForms 自定义控件,永远先确认它是自绘还是系统绘制,再动手改代码。绕开这个前提,后面所有调整都是空中楼阁。从那时起,我每次接到自绘控件相关的需求,都会强制走一遍"关边框、开自绘、拦消息、测 DPI"这四步,踩坑的概率低了大半。希望这些内容帮到你。
本文还有配套的精品资源,点击获取