简介:面向Winform开发者的自定义控件源码,主要解决TextBox常规矩形边框与极简界面不协调的问题,让输入框仅保留底部一条直线,适用于桌面工具、设置面板等需要弱化输入框视觉重心的场景。压缩包内共35个文件,以9个C#源码文件、2个工程文件和一个解决方案为主体,另含编译生成的dll、exe、pdb以及说明文本、资源文件,整体仅53KB,便于快速查看和学习。核心LineTextBox类重写OnPaint,先以BackColor填充整个客户区,再用Pen沿下边缘绘制细线,线条颜色、位置及宽度都可按项目风格调整;同时通过重写焦点、鼠标事件,可继续实现悬停高亮或选中状态变化。配套FormTest演示窗体展示了控件的添加与运行效果,说明文本便于使用者了解工程组成,适合开发者直接引用工程或移植其中绘制逻辑。已有516人浏览学习,适合有一定Winform基础、希望快速获得自定义控件参考实现的开发者。
1. 只画一条线的输入框:WinForms 界面去土味最划算的一步
把 WinForms 程序从老式系统风格改成扁平化界面时,最让人头疼的不是按钮扁平化,而是文本输入框。原生 TextBox 带着系统主题边框和白色背景,往深色面板上一放,整块 UI 立刻回到 2010 年。有人把 BorderStyle 设为 None,结果输入框直接消失,用户根本不知道该往哪儿点。这个自定义控件项目的思路很简单:让输入框保留文本录入的一切能力,同时在底部画一条直线作为视觉边界,聚焦时线变色。它适合正在做 Material Design 风格 WinForms 工具、或者想把内部管理系统界面收拾干净的人,直接编译成 DLL 拖进工具箱,一条线就解决了输入框和背景的违和感,不用安装任何第三方 UI 库。
2. 控件架构:为什么这条线必须画在 WM_PAINT 里
2.1 继承 TextBox 而不是 UserControl
写这个控件时最需要想清楚的一件事是继承谁。很多人一提到"自定义输入框"就下意识地去拖一个 Panel,再往里面塞一个 TextBox,让 Panel 当背景、TextBox 当输入区,最后在 Panel 上画线。这个方案我早年也用过,结果问题不断:Panel 和 TextBox 的焦点事件要手动同步,TextBox 的位置和宽高要跟着 Panel 跑,稍微调一下字体就会错位,DPI 变化时更是灾难。所以这个项目选择了直接继承 TextBox,这是更合格的做法。
继承 TextBox 最大的好处是保留了原生编辑能力:光标闪烁、鼠标选区、右键菜单、Ctrl+C/V、输入法组合,这些都是 Win32 EDIT 控件自带的,我们一行代码都不用写。如果用 UserControl 从零拼一个,这些能力全得自己实现,光是让光标正确闪烁就要花掉大半天,还不一定做得比系统稳定。
从项目源码的结构也能看出来,整个控件只有一个 cs 文件,类名是 UnderlineTextBox,没有额外的设计器类,说明作者把核心逻辑都收敛在继承关系里。构造时设置 BorderStyle 为 None,再在消息处理里补画底线,本质是在原生 TextBox 外皮上做了一层"视觉包装",而不是另起炉灶。
2.2 绘制链路:UserPaint、WM_PAINT 与 WM_NCPAINT 的选择
这是整个项目最关键的技术决策。WinForms 里的 TextBox 不是普通 Control,它内部封装的是 Win32 的 EDIT 控件,绘制路径和普通控件不一样。普通 WinForms 控件重写 OnPaint 就能自定义外观,但 TextBox 不走这条路——它的文本绘制完全由系统内部完成,OnPaint 在默认情况下根本不会被调用。
最常见的翻车方式是直接设置 SetStyle(ControlStyles.UserPaint, true),指望 OnPaint 被触发后在底部画线。实际上把 UserPaint 设为 true 之后,TextBox 会把整个绘制过程交给你,但文本、光标、选区这些系统绘制也会被拦下来,结果就是文字消失、光标不闪,界面惨不忍睹。这个项目没有踩这个坑,而是选择在 WndProc 里拦截 WM_PAINT 消息,这是一个信号:作者很明白 TextBox 的绘制秘密。
WM_PAINT 是 EDIT 控件绘制文本的主通道,消息先传到 DefWndProc 让系统画完文本和光标,然后再执行我们自己画的底线。这样文本绘制不受影响,底线又刚好画在文本的上层。而 WM_NCPAINT 只负责非客户区,BorderStyle 设为 None 之后非客户区高度基本为零,画线空间不足,还会被系统边框裁剪,所以也不适合。三种方案的取舍可以简单概括:
| 绘制入口 | 对 TextBox 的效果 | 结论 |
|---|---|---|
| OnPaint + UserPaint | 文本自己画、光标失灵 | 不推荐 |
| WM_NCPAINT 画非客户区 | 边框置空后无空间 | 不推荐 |
| WndProc 拦截 WM_PAINT | 系统画完文本再补线 | 本控件采用 |
2.3 源码文件布局与设计时支持
整个工程结构非常精简,一个类文件加一个 Demo 窗体就够。核心类的命名空间可以按你自己的项目名起,类名保持 UnderlineTextBox 不变即可,这样拖到工具箱后显示的名字直观,不会出现一堆 TextBox1 之类混淆。
文件布局大致是这样:UnderlineTextBox.cs 里放控件类,内部包含几个可读写的公开属性和一个 WndProc 重写;DemoForm.cs 是演示窗体,放几个不同状态的输入框供测试。为了让属性在 Visual Studio 设计器里更好用,公开属性上都加了 Category 和 Description 特性,这样在属性面板的"外观"分组里就能直接看到线颜色、聚焦颜色和线宽三项,不需要打开代码改字段。
设计时支持这个点容易被忽略,但很值得说。自定义控件如果不处理设计器行为,打开窗体设计器时可能会白屏或报错。这个项目由于构造逻辑简单,只是设置 BorderStyle 和 Height,不创建资源也不读写外部文件,所以天然能在设计器里工作。如果是更复杂的控件,一般要加一个 Designer 类来控制设计时的绘制。
3. 核心实现拆解:从构造函数到每次重绘的完整链路
3.1 字段、属性与构造:三个颜色参数和一条线宽
核心的绘制参数只有三个:线的颜色、聚焦时的颜色、线的宽度。把它们做成公开属性而不是写死在绘制方法里,是让控件能被复用的第一步。我在看这类源码时习惯先找这些字段,因为它们决定了控件的扩展边界。
public class UnderlineTextBox : TextBox { private Color _lineColor = Color.FromArgb(160, 160, 160); private Color _focusLineColor = Color.FromArgb(0, 120, 215); private int _lineWidth = 2; private bool _focused; public UnderlineTextBox() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); BorderStyle = BorderStyle.None; Height = 30; } [Category("外观")] [Description("输入框底部直线的颜色")] public Color LineColor { get => _lineColor; set { _lineColor = value; Invalidate(); } } [Category("外观")] [Description("输入框获得焦点时底部直线的颜色")] public Color FocusLineColor { get => _focusLineColor; set { _focusLineColor = value; Invalidate(); } } [Category("外观")] [Description("底部直线的粗细,单位像素")] public int LineWidth { get => _lineWidth; set { _lineWidth = Math.Max(1, value); Invalidate(); } } // 其余方法见后文 }这里有几个细节值得展开。首先,SetStyle 里没有加 UserPaint,只加了 AllPaintingInWmPaint 和 OptimizedDoubleBuffer,这是防闪烁的关键。AllPaintingInWmPaint 把擦除和绘制合并到一次消息里处理,OptimizedDoubleBuffer 让底层用缓冲区,两条配合起来,线不会在输入文字时闪。其次,构造函数里把 BorderStyle 设为 None 是去掉系统边框,Height 设为 30 则是给底部留出画线空间,具体留多少由字体决定,可以在 Demo 里跑起来后再微调。
属性值改变后都调用 Invalidate,是告诉控件"你的外观变了,请重绘"。但注意这里 Invalidate 会触发整个客户区的重绘,按当前写法是可以接受的,因为 TextBox 的客户区通常只有几十像素高。如果做大型控件,更精细的做法是用 Invalidate(Rectangle) 局部刷新,这个在避坑章节里再具体说。
3.2 WndProc 里的绘制入口:画线的具体代码
看这个项目最核心的一段代码,是 WndProc 方法和一个独立的画线方法。前面说过,TextBox 的 OnPaint 不可靠,所以这里走的是 WndProc 拦截 WM_PAINT 消息的路线。
protected override void WndProc(ref Message m) { base.WndProc(ref m); if (m.Msg == 0x000F) // WM_PAINT { DrawUnderline(); } } private void DrawUnderline() { using (Graphics g = Graphics.FromHwnd(Handle)) { g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; using (Pen pen = new Pen(_focused ? _focusLineColor : _lineColor, _lineWidth)) { int y = Height - _lineWidth / 2 - 1; g.DrawLine(pen, 1, y, Width - 1, y); } } }这段代码的逻辑顺序是有讲究的。先调用 base.WndProc 让系统完成文本、光标、选区的绘制,等这些工作全部结束后,才在客户区的底部画线。这样保证线一定压在文本最下面一层,不会和文字互相覆盖。y 坐标的计算用 Height 减去半个线宽再减 1,画出来是一条贴合下边缘的直线,不会因为线宽是奇数而出现半像素模糊。
Graphics.FromHwnd(Handle) 是直接从控件句柄取绘图对象,这比通过 OnPaint 拿 e.Graphics 更直接,也是在这个场景下推荐的做法。用 using 包裹可以确保 Pen 和 Graphics 被及时释放,否则 GDI 对象会越积越多,程序运行几个小时后界面绘制明显变慢,这是 GDI 泄漏的典型现象。
消息编号 0x000F 就是 WM_PAINT,这里硬编码数字其实是常见做法,因为 System.Windows.Forms.Message 没有直接暴露 WM_PAINT 的常量。如果想写得可读性更好,可以在类里加一个 const int WM_PAINT = 0x000F,然后在条件里用常量名比较,效果完全一样。
3.3 焦点跟踪与局部失效:让线只重绘该重绘的地方
焦点状态是第二个需要处理的事。需求要求聚焦时线变色,那控件就得知道自己当前有没有焦点。TextBox 本身有 Focused 属性,但直接在 DrawUnderline 里去读 Focused 会遇到一个时序问题:WM_PAINT 消息到来的时候,焦点事件的传递可能还没有完成,读到的不一定是新状态。所以项目里用了两个事件重写主动记录状态。
protected override void OnEnter(EventArgs e) { _focused = true; base.OnEnter(e); Invalidate(); } protected override void OnLeave(EventArgs e) { _focused = false; base.OnLeave(e); Invalidate(); } protected override void OnSizeChanged(EventArgs e) { base.OnSizeChanged(e); if (Height < _lineWidth * 4) { Height = _lineWidth * 4; } }OnEnter 和 OnLeave 是在焦点真正进入和离开时触发的,这两个方法里更新 _focused 字段,再调用 Invalidate 让系统在下一个绘制周期按新颜色画线。这里必须调用 base 的方法,否则会破坏基类的焦点事件链,导致 Tab 键切换焦点时出问题。OnSizeChanged 里的保护逻辑是防止有人在设计器里把高度拖得太小,导致线画到控件外面去。
有人会问,为什么不直接在 DrawUnderline 里用 this.Focused?因为 OnEnter 触发时,Focused 属性已经变成 true,理论上直接用也行。但用 _focused 字段的真正价值在于:如果你后续想扩展"鼠标悬停也变色",只需要在这个字段的赋值逻辑里加一个状态源,不用去动绘制逻辑。这是一个很小的设计习惯,但让控件后续好改很多。
4. 运行、集成与常见问题排查:从编译到不闪烁
4.1 编译项目并放入工具箱的完整步骤
拿到源码后第一步是把项目编译成 DLL,然后拖进 Visual Studio 的工具箱。先打开 csproj 工程,确认目标框架和你正在写的 WinForms 项目一致,直接生成解决方案,在输出目录里就能找到 UnderlineTextBox.dll。
接下来在工具箱空白处右键,选择"选择项",在弹出的对话框中点"浏览",定位到刚才生成的 DLL,确定后工具箱里就会出现 UnderlineTextBox。拖到窗体上时,设计器会把它当普通 TextBox 对待,属性面板里能直接看到 LineColor、FocusLineColor、LineWidth 三个新增属性。如果你是把 cs 文件直接放进现有项目而不是引用 DLL,那更简单,编译后控件会自动出现在工具箱的"你的项目"分组里。
放上窗体后记得先 F5 跑一遍,不要只在设计器里看。设计器里的绘制和运行时会有细微差别,最典型的是设计时焦点不在控件上,所以看到的是非聚焦颜色;运行时点进去才会变色。这是正常现象,不是代码 bug。
4.2 拖进设计器后线不显示:三个高频踩坑点
现象一:拖进窗体后,控件看起来就是普通文本框,底部没有线。
原因:BorderStyle 没有被设为 None。如果你是在现有 TextBox 基础上改的,或者构造里没执行 BorderStyle = None,系统默认边框会占住底部,把画出来的线盖住。
解决:在构造函数里强制设置 BorderStyle = BorderStyle.None,并且不要在设计器里改回其他值。同时确认线画的 y 坐标大于等于控件高度的一半,如果线被画到 Height 之外,肉眼当然看不见。
现象二:文字输入时线一闪一闪,拖动窗体时线出现残影。
原因:这是典型的双缓冲没设置好,或者 Invalidate 范围过大。有些实现会在文本变化时对整个控件 Invalidate,导致绘制抖动明显。
解决:检查 SetStyle 是否包含 AllPaintingInWmPaint 和 OptimizedDoubleBuffer;另外画线只涉及底部几个像素高的区域,重绘时尽量用 Invalidate(new Rectangle(0, Height - _lineWidth, Width, _lineWidth)) 局部刷新,把重绘面积降到最小。
现象三:焦点切进去后线没有变颜色,或者退出焦点后颜色没变回来。
原因:焦点跟踪状态 _focused 没有正确同步,或者 Invalidate 调用太早,WM_PAINT 还没走到绘制逻辑,线就重绘完了。
解决:确认 OnEnter 和 OnLeave 都先更新 _focused 再 Invalidate。如果是在 WndProc 里直接读 this.Focused,尝试改成用 _focused 字段,因为消息到达时焦点状态可能还在切换。
现象四:放在高分屏或系统缩放 150% 的环境下,线错位,和文字底部重叠。
原因:DPI 缩放后字体变大,控件高度没有跟着调整,原来预留的底部空间被文本占掉,线就和文字叠在一起。
解决:在构造函数里不要写死 Height,而是用 Font.Height 计算:PreferredHeight = Font.Height + _lineWidth * 3。再重写 OnFontChanged 和 OnDpiChanged,在字体变化时重新计算高度,并调用 Invalidate。
现象五:设计器打开窗体时报错,提示找不到 UnderlineTextBox 或者对象引用未设置。
原因:工程目标框架不一致,或者 cs 文件没有编译进当前项目。常见于把 DLL 拷贝到别处,但依赖的系统组件版本不同。
解决:用"添加现有项"把 cs 文件直接包含进项目,让编译器重新构建,减少 DLL 引用带来的版本匹配问题。如果坚持引用 DLL,确认目标框架和调用项目一致。
4.3 运行时的交互细节:光标、右键菜单与密码框行为
由于控件的绘制是附加在 WM_PAINT 之上的,原生 TextBox 的所有交互能力都被保留。右键菜单、剪贴板快捷键、拖拽文本,这些都不用额外写。密码模式下设置 UseSystemPasswordChar = true 后,底线照样绘制,密码圆点还是系统画的,两者互不干扰。
一个容易被忽略的点是:当输入超长文本、出现水平滚动条时,底线绘制会不会受影响。由于 DrawUnderline 使用的是控件自身坐标,画线的 y 坐标固定,与文本滚动位置无关,所以滚动不会让线跑掉。这一点在实现时不用额外处理,但如果未来有人改成 OnPaint + UserPaint 方案,滚动时文本和线就可能错位,这也是不推荐 UserPaint 方案的又一个理由。
5. 二次开发:给它加占位文字、密码框与动画
拿到这个控件后,最常见的改造需求是加一个占位提示(Placeholder),也就是输入框空白且未聚焦时,显示一行灰色的"请输入账号"。实现方式依旧可以在 WndProc 里做:在 base.WndProc 之后、DrawUnderline 之前判断一下 Text 是否为空且 _focused 为 false,如果成立就用 Graphics.DrawString 画一行提示文字。注意提示文字的绘制位置要和文本的左边距对齐,EDIT 控件默认有 2 到 3 像素的内边距,绘制时也得空出同样的距离,否则字会歪。
第二个实用的改造是让聚焦动画更顺滑。当前的线是瞬间变色的,如果想做成 Material Design 那样聚焦时线从中间向两侧展开的动画,可以把 _focused 的状态改成用一个 float 字段 _progress 来控制线的长度和颜色。每次 OnEnter 触发后启动一个 Timer,每帧让 _progress 从 0 增加到 1,再在 DrawUnderline 里按 _progress 计算线的起点和终点,比如起点从 Width / 2 开始、终点向两边扩散。Timer 的 Interval 建议设在 16 毫秒左右,即约 60 帧的刷新率,整个过程大约 200 到 300 毫秒就够,太长会影响输入手感。
第三个容易改的是增加 ReadOnly 和 Enabled 状态下不同的线的颜色。很多设计规范要求在禁用状态下线变淡,可以在属性里再加一个 DisabledLineColor,在绘制时根据 Enabled 状态选择画笔颜色。这个改动只需要在 DrawUnderline 里加一个三元判断,不用动绘制逻辑。
做完任何一项改动后,我都建议跑一个验证清单:正常输入文字、焦点切换、密码模式、长文本滚动、缩放 150% 高分屏、禁用状态,各看一遍线的位置和颜色。这些场景覆盖了 TextBox 绘制的大部分边界情况,比只看设计器要靠谱得多。
最终这个控件的价值不在于代码量,而在于它把"文本框视觉替换"这个需求收敛到一个文件里,没有任何多余依赖。从那以后我每次在 WinForms 里做自定义输入框,都强制走一遍"确定绘制入口 → 预留绘制空间 → 控制失效重绘范围 → 高分屏验证"四步流程,确实少踩了很多没有意义的坑,希望帮到你。
本文还有配套的精品资源,点击获取