WinForms下划线输入框自定义控件LineTextBox完整实现
简介面向 Winform 开发者的自定义控件源码包解决常规输入框矩形边框与扁平化界面不协调的问题提供一种仅保留底部单线条的轻量 TextBox 实现。压缩包共包含 35 个文件以 9 个 C# 源码文件及项目工程文件解决方案、工程配置、资源文件为主体附带编译生成的 dll 与 exe、调试符号及示例说明整体体积仅 53KB结构紧凑便于查阅。目前已有 514 人学习/下载。核心 LineTextBox 控件演示了继承 TextBoxBase 并重写 OnPaint 的具体做法先将背景色设置为与窗体一致再在底部绘制一条直线作为唯一边框从而取消传统外框获得简洁现代的观感同时预留了鼠标悬停、焦点切换等交互效果的扩展思路。示例工程配有测试窗体与说明文档开发者可快速运行预览效果也能直接复用控件类或在此基础上调整边框颜色、线条宽度及交互动画适配不同界面风格提升自定义控件开发效率。1. 控件整体设计与思路拆解在WinForms里做界面美化输入框绝对是最让人头疼的一块。默认的TextBox就是个灰底黑框放到深色背景上丑得没法看放到浅色背景上依然有那条突兀的边框线。很多人在登录界面、设置页面里想要那种极简的“下划线输入框”效果——没有边框、没有背景、只有底部一条直线。网上大多数方案是贴一张背景图片或者叠一个Panel模拟直线但这些办法都有硬伤缩放窗口线就错位、DPI一变就糊、焦点变化时不能变色提示。这里我直接给出一个从根上解决的方案自己写一个继承TextBox的自定义控件重写控件的绘制逻辑让控件内部真实地绘制一条下划线。这样做的好处非常明显——它是一个真正的TextBox保留所有原生输入行为光标闪烁、右键菜单、输入法、鼠标选择、撤销重做只是在显示层做了定制。你不需要改任何现有代码把TextBox替换成LineTextBox界面效果就出来了。这个方案的核心思路是两条线并行继承TextBox保留全部输入能力。通过重写OnPaint和底层Windows消息处理屏蔽系统自带的边框背景绘制再用GDI画一条直线。下面从控件属性设计、绘制实现、消息处理三个层面逐步展开每一步我都会给出可直接运行的代码和解释说明争取让你看完就能复制到自己项目里用。2. 核心细节解析与实操要点2.1 为什么直接继承TextBox而不是用UserControl先说一个常见误区。有人想通过UserControl里面放一个TextBox来包一层实现这种思路在简单场景能用但会带来一堆隐藏问题UserControl无法自动获得焦点需要额外处理Click事件、Tab键顺序需要额外配置、输入法上下文需要手动转发、右键菜单需要手动实现。等于你把TextBox最成熟的那部分功能全部砍掉然后自己从头修得不偿失。直接继承TextBox从面向对象角度也是最合理的。你只是在扩展TextBox的“外观”没有改变它的输入内核。所有关于输入法的好用的东西、快捷键、自动滚动、选择逻辑全都保留。这也是这个自定义控件方案最稳妥的基座后续所有问题排查也都围绕这一个类展开就行。判断标准很简单只要控件的行为和真正的输入框一致优先继承原生TextBox。只有需要完全重新定义交互逻辑时才考虑UserControl方案。2.2 控件属性设计把画直线需要的东西都暴露出来一个可复用的自定义控件属性一定要提前设计全。我按“最小可用常用扩展”的原则给控件定了这些公共属性public class LineTextBox : TextBox { private Color _lineColor Color.Gray; private Color _focusLineColor Color.FromArgb(63, 114, 220); private Color _backColor2 Color.White; private float _lineThickness 1.5f; private bool _drawFocusedLine true; public Color LineColor { get { return _lineColor; } set { _lineColor value; Invalidate(); } } public Color FocusLineColor { get { return _focusLineColor; } set { _focusLineColor value; Invalidate(); } } public float LineThickness { get { return _lineThickness; } set { _lineThickness Math.Max(0.5f, value); Invalidate(); } } public bool DrawFocusedLine { get { return _drawFocusedLine; } set { _drawFocusedLine value; Invalidate(); } } }这些属性会在绘制代码里被读取每个属性的setter里面都调用Invalidate()确保属性值改变后立即触发重绘。而LineThickness用Math.Max做了最小限制防止有人传负数或0进来导致绘制异常。这里还额外加了_backColor2这个字段它的作用在下一节会解释。属性具体怎么配、配色选什么值更耐看我放到后面“美化扩展”里统一说明先理解这些字段各自的职责就行。2.3 背景色陷阱为让白色背景真正生效做的特殊处理按正常思路一个白底的输入框Text BackColor White就完事了。但自定义TextBox会有个很隐蔽的问题当你把BorderStyle设为None之后控件的背景色有时候仍然是从父容器继承过来的尤其在设置了某些主题或者嵌入到Panel上时背景颜色会乱掉。所以我在构造函数里做了强制统一public LineTextBox() { BorderStyle BorderStyle.None; BackColor Color.White; SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); TabStop true; }关键就在SetStyle这一行。AllPaintingInWmPaint告诉系统直接在WM_PAINT里完成全部绘制减少闪烁OptimizedDoubleBuffer开启双缓冲。这两项组合后控件的刷新会平滑很多。不过光设置这两个还不够后面还要在消息循环里过滤掉系统对背景色的重置请求具体写法在第四部分消息处理中。这里先记住一个原则凡是自定义外观的控件尽量把AllPaintingInWmPaint开起来否则绘制线条时一定会出现肉眼可见的闪动。2.4 绘制一条线的两个时机非焦点状态和焦点状态下划线输入框的灵魂就是线会随焦点变色。一个漂亮的输入框失焦时细灰线聚焦时变主题色且稍微加粗。这种状态切换需要两个时机触发重绘获得焦点时调用Invalidate()让控件重画绘制代码使用高亮颜色。失去焦点时同样调用Invalidate()绘制代码回退到普通颜色。protected override void OnGotFocus(EventArgs e) { base.OnGotFocus(e); Invalidate(); } protected override void OnLostFocus(EventArgs e) { base.OnLostFocus(e); Invalidate(); }实现的逻辑很简单触发条件就是焦点变化。下一步就是最核心的绘制代码了。3. 绘线实现从OnPaint到底层消息的完整方案3.1 OnPaint绘制直线GDI画线的第一版实现WinForms的OnPaint是最直接的绘制入口在控件需要重画时系统会调用它。我们在这里用e.Graphics拿到画布对象直接画线protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; float y this.ClientSize.Height - _lineThickness / 2; Pen pen new Pen(Focused ? _focusLineColor : _lineColor, _lineThickness); y - 1; g.DrawLine(pen, 1, y, ClientSize.Width - 2, y); pen.Dispose(); }这条线画在控件的底部y坐标等于高度减去线宽的一半再减1目的是让线尽量紧贴控件底部边缘但又有1像素的呼吸空间视觉上更平衡。删除右侧-2和左侧1会看到线贴到边框上反而不美观。但这里有个隐藏问题如果只依赖OnPaint文字会有几率盖住线或者背景刷新时线被擦掉。因为TextBox内部用的是系统标准的文本框绘制逻辑它会先填充背景、再绘制文字我们后画的线虽然在最上层但某些消息触发的重绘可能不会走到OnPaint里来。所以还得再接一层底层消息处理保证所有重绘路径都走我们的绘制逻辑。3.2 嵌入WndProc消息循环根治刷新闪动和黑块问题自定义TextBox控件最容易遇到的现象是输入文字时线偶尔不显示或者控件变成一块黑底。原因是TextBox内部会处理WM_PRINTCLIENT、WM_ERASEBKGND这些消息来做局部刷新这些刷新路径绕过了OnPaint。解决办法是重写WndProc拦截关键消息把系统的背景擦除和默认文本绘制剥离开来protected override void WndProc(ref Message m) { const int WM_ERASEBKGND 0x0014; const int WM_PAINT 0x000F; const int WM_PRINTCLIENT 0x0318; switch (m.Msg) { case WM_ERASEBKGND: using (Graphics g Graphics.FromHdc(m.WParam)) { using (Brush b new SolidBrush(_backColor2)) { g.FillRectangle(b, ClientRectangle); } } m.Result IntPtr.Zero; return; case WM_PAINT: case WM_PRINTCLIENT: base.WndProc(ref m); using (Graphics g CreateGraphics()) { DrawLine(g); } m.Result IntPtr.Zero; return; } base.WndProc(ref m); } private void DrawLine(Graphics g) { g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; float y ClientSize.Height - _lineThickness / 2 - 1; using (Pen pen new Pen(Focused ? _focusLineColor : _lineColor, _lineThickness)) { g.DrawLine(pen, 1, y, ClientSize.Width - 2, y); } }这段代码里三个关键消息各司其职WM_ERASEBKGND消息是系统在重绘前擦除背景用的。默认情况下TextBox会用自己的背景处理填一遍但可能填成白色之外的系统色于是我们拦截它手动用_backColor2填充从源头杜绝了黑块或背景色不一致的问题。WM_PAINT是控件的主重绘入口。这里先调用base.WndProc让TextBox完成文字、光标等输入内容的绘制然后用CreateGraphics直接在最终画面上补画直线。这个方法比只用OnPaint可靠得多因为即使某些内部逻辑绕过OnPaint消息循环里也会统一补画。WM_PRINTCLIENT是打印和截图时调用的绘制消息也一并处理保证控件在PrintWindow这类调用下也正常。3.3 完整源码一个可以直接复制的LineTextBox类把上面所有部分拼起来就是一个功能完整的LineTextBox。下面给出完整的类文件源码直接放到项目里编译就能用using System; using System.Drawing; using System.Windows.Forms; namespace WinFormsThemes.Controls { public class LineTextBox : TextBox { private Color _lineColor Color.Gray; private Color _focusLineColor Color.FromArgb(63, 114, 220); private Color _backColor2 Color.White; private float _lineThickness 1.5f; private bool _drawFocusedLine true; public LineTextBox() { BorderStyle BorderStyle.None; BackColor Color.White; SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); TabStop true; } public Color LineColor { get { return _lineColor; } set { _lineColor value; Invalidate(); } } public Color FocusLineColor { get { return _focusLineColor; } set { _focusLineColor value; Invalidate(); } } public float LineThickness { get { return _lineThickness; } set { _lineThickness Math.Max(0.5f, value); Invalidate(); } } public bool DrawFocusedLine { get { return _drawFocusedLine; } set { _drawFocusedLine value; Invalidate(); } } protected override void OnGotFocus(EventArgs e) { base.OnGotFocus(e); Invalidate(); } protected override void OnLostFocus(EventArgs e) { base.OnLostFocus(e); Invalidate(); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); DrawLine(e.Graphics); } protected override void WndProc(ref Message m) { const int WM_ERASEBKGND 0x0014; const int WM_PAINT 0x000F; const int WM_PRINTCLIENT 0x0318; switch (m.Msg) { case WM_ERASEBKGND: using (Graphics g Graphics.FromHdc(m.WParam)) using (SolidBrush b new SolidBrush(_backColor2)) { g.FillRectangle(b, ClientRectangle); } m.Result IntPtr.Zero; return; case WM_PAINT: case WM_PRINTCLIENT: base.WndProc(ref m); using (Graphics g CreateGraphics()) { DrawLine(g); } m.Result IntPtr.Zero; return; } base.WndProc(ref m); } private void DrawLine(Graphics g) { if (g null) return; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; float y ClientSize.Height - _lineThickness / 2 - 1; using (Pen pen new Pen(Focused ? _focusLineColor : _lineColor, _lineThickness)) { g.DrawLine(pen, 1, y, ClientSize.Width - 2, y); } } } }这段代码我实测过N次直接在窗体上拖进来或者代码里new出来都能正常工作。需要强调的点是DrawLine方法同时被OnPaint和消息处理两个路径调用就算某次刷新只走了消息循环线也不会漏画。3.4 在窗体中使用这个自定义控件把上面的类放进项目后用起来非常直接private LineTextBox userNameBox; private LineTextBox passwordBox; private void InitializeCustomControls() { userNameBox new LineTextBox { Location new Point(50, 60), Size new Size(260, 32), Font new Font(微软雅黑, 10.5f), ForeColor Color.FromArgb(50, 50, 50), LineColor Color.FromArgb(160, 160, 160), FocusLineColor Color.FromArgb(63, 114, 220), LineThickness 1.5f }; passwordBox new LineTextBox { Location new Point(50, 110), Size new Size(260, 32), Font new Font(微软雅黑, 10.5f), UseSystemPasswordChar true, LineColor Color.FromArgb(160, 160, 160), FocusLineColor Color.FromArgb(63, 114, 220), LineThickness 1.5f }; Controls.Add(userNameBox); Controls.Add(passwordBox); }在菜单栏“生成”成功一次后LineTextBox会直接出现在工具箱顶部也能用鼠标拖拽。拖到窗体上之后在属性面板中就能看到FocusLineColor、LineColor、LineThickness这些属性了不用写一行代码就能配好。4. 场景延伸登录界面的完整示例4.1 配合标签和按钮的使用方式单独一个下划线输入框有了但实际做登录界面时还要考虑整体的组合设计。我把之前项目中封装的一套模式分享出来用Panel当容器左侧放Label中间放LineTextBox右侧可以放一个校验小图标。private Panel CreateInputGroup(string labelText, string placeholderText) { Panel panel new Panel { Size new Size(320, 40), BackColor Color.White }; Label lbl new Label { Text labelText, Font new Font(微软雅黑, 9.5f), ForeColor Color.FromArgb(90, 90, 90), Location new Point(0, 10), AutoSize true }; LineTextBox txt new LineTextBox { Location new Point(70, 12), Size new Size(250, 30), Font new Font(微软雅黑, 10f), PlaceholderText placeholderText, LineColor Color.FromArgb(190, 190, 190), FocusLineColor Color.FromArgb(0, 120, 215), LineThickness 1.8f }; panel.Controls.Add(lbl); panel.Controls.Add(txt); return panel; }这样组合出来的登录区域视觉上比普通TextBox干净很多各个控件之间用留白和细线区分整个窗体看起来就很现代。4.2 深色背景的适配技巧很多登录页背景是深蓝色或者深灰色这时候LineTextBox如果还是白色背景就会非常突兀。本身控件支持任意背景色只需在构造函数后手动设置两个背景相关字段LineTextBox txt new LineTextBox { BackColor Color.FromArgb(30, 35, 45), ForeColor Color.White, LineColor Color.FromArgb(90, 100, 115), FocusLineColor Color.FromArgb(0, 160, 220), LineThickness 1.6f };注意这时候还需要改一下内部背景填充色_backColor2。虽然它目前是私有字段但我建议你实际项目中把这个字段也改成公共属性叫做FillColor因为BackColor属性在某些消息路径中不一定生效为了保险起见深度定制时先把这个值改掉再重编译。深色背景下还容易遇到一个问题光标颜色和文字颜色一致导致看不清光标。需要额外处理ImeMode和系统光标或者使用CreateGraphics设置光标颜色。这个比较底层一般项目用不着先了解有这么回事就行。5. 常见问题与排查技巧实录这里我从实际使用中挑了5个高频问题整理成了速查表遇到问题直接对着看。现象根本原因快速方案输入时线突然消失默认TextBox内部消息走了其他路径检查是否重写了WndProc的WM_PAINT分支并调用CreateGraphics().DrawLine补画控件背景变成黑色WM_ERASEBKGND未处理拦截该消息用Graphics.FromHdc(m.WParam)填充目标背景色线位置偏高或偏低ClientSize高度计算问题调整DrawLine中的y坐标高度减线宽的一半再减1界面缩放后线错位缺少OnResize触发重绘重写OnResize方法并在其中调用Invalidate()焦点丢失后线不变灰色OnLostFocus未触发Invalidate确保重写OnLostFocus且内部调用base.OnLostFocus(e)5.1 为什么在有些机器上线变模糊出现这个情况首先是GDI的SmoothingMode设置问题。如果不开抗锯齿1.5像素的线在某些分辨率下会发虚。解决办法已经写进代码里了g.SmoothingMode AntiAlias。另外还要注意Pen的Alignment属性。默认Pen.Alignment是Center意味着线宽会平均分布在坐标上下两侧。如果你把y坐标定在底部往上2个像素线宽1.5像素视觉效果还比较舒适。要是线宽调成3像素以上建议给Pen加Alignment Inset或者手动把y坐标再多往下调一点否则线会偏向内容区域里面影响视觉平衡。5.2 缩放窗体后线错位WinForms在高DPI屏上经常出各种缩放问题热搜词里也提到“winform 窗体缩放 尺寸改不了”这种问题通常不是自定义控件本身引起的而是项目没有声明DPI感知。下面给两个明确的处理方式。方式一在Program.cs的Main方法最开始加上Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm());方式二如果不想改代码在项目属性-应用程序-清单文件里声明PerMonitorV2。改了DPI感知之后之前所有控件错位、字体模糊、线偏移的问题基本能消失。5.3 禁用状态下线样式处理实际业务中有很多只读或者禁用输入框的场景。如果不管这块禁用后输入框会变成系统灰白色线也跟着变灰看起来特别乱。我的经验是增加一个ReadOnlyColor属性并在Enabled变化时重新设置前景色和线色private Color _readOnlyLineColor Color.FromArgb(210, 210, 210); protected override void OnEnabledChanged(EventArgs e) { base.OnEnabledChanged(e); if (!Enabled) { _lineColor _readOnlyLineColor; BackColor Color.FromArgb(248, 248, 248); } else { BackColor _backColor2; Invalidate(); } }禁用状态用浅一点的颜色和背景做区分比系统默认的“灰成一坨”要好看得多。5.4 光标和选中状态的细节调整下划线样式的输入框选中背景默认是天蓝色在白色背景下还行但如果你的窗体整体配色是有色背景建议把选中颜色也同步调整。在控件构造函数里追加private Color _selectionColor Color.FromArgb(173, 214, 255); public Color SelectionColor { get { return _selectionColor; } set { _selectionColor value; } }然后在WndProc里拦截WM_CTLCOLOREDIT0x0133消息用SetTextColor和SetBkColor设置文字和背景色。这样选中的高亮颜色就跟窗体主题统一起来了。这个功能在纯白背景里看不出差别但在深色主题里非常明显。6. 后续扩展与实战建议到这里LineTextBox的核心已经写完了。如果你还想更进一步有两个方向很推荐第一个方向是做“带占位符的LineTextBox”。WinFroms自带的PlaceholderText属性在.NET 5以上才支持得比较好。如果你还在.NET Framework上可以考虑加一个Placeholder属性在绘制文字时检测Text是否为空且未聚焦若满足则用TextRenderer.DrawText画一条灰色提示文字这样输入框的交互感会更强。第二个方向是把线换成渐变线。有的设计稿里输入框底线是渐变色比如左边淡蓝色右边深蓝色。实现上就比画直线多几步用LinearGradientBrush替换SolidPen。虽然看着花哨但有些企业端项目确实要这种效果封装起来也不难。最后顺便说一个常被忽略的发布细节自定义控件编译后的DLL拷贝给其他项目使用时其他项目的目标框架必须不低于控件项目的框架版本。如果对方是.NET Framework 4.6.2而你的控件用了.NET 8那就算DLL复制过去也弹不出工具箱。打包的时候最好用NuGet或者直接交付源码避免框架不一致带来的麻烦。我个人在实际项目里把这个控件用在了一个几十个窗体的中后台系统里效果很稳定。唯一要注意的是如果窗体里的控件特别多不要在WM_PAINT里频繁new Pen和Brush每次绘制都创建销毁对GDI句柄有点压力。建议把Pen和Brush做成控件级别字段只在属性变化时重建这样长期运行也不会GDI泄漏。等你真的跑上几千个输入框的复杂界面会感谢这个提前准备的。本文还有配套的精品资源点击获取