ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C# Winform自定义水印TextBox:从绘制原理到上位机实战

C# Winform自定义水印TextBox:从绘制原理到上位机实战 简介面向C# WinForms开发者的自定义控件示例工程核心演示如何通过继承System.Windows.Forms.TextBox并重写绘制方法实现带圆角、可配置边框颜色与圆角半径的TextBox控件。示例项目结构完整包含Form1窗体测试界面、MyTextBox自定义控件类及其设计器代码、Program入口和Properties资源目录读者可对照代码快速理解自定义控件的实现步骤与设计器集成方式。压缩包共13个文件以cs源码为主8个辅以resx资源文件、settings配置、csproj工程文件和App.config整体仅13KB轻量易读适合有一定WinForms基础、希望提升界面质感的C#开发者参考。目前已有413人学习浏览代码涉及GDI圆角绘制、OnPaint/OnResize重写、自定义属性添加及DPI兼容性等关键知识点对想封装个性化控件或深入理解WinForms自绘机制的开发者有直接帮助。 刚做C# Winform的时候我总觉得自定义控件是件特别“高大上”的事直到某次给客户做设备参数界面需求单上写着“输入框没内容时要有灰色提示文字边框要能变红不能输入的字段背景不能变灰”——就这三条原生TextBox一条都办不到。C# Winform自定义控件TextBox这条路就是被这类看似不起眼的需求逼着走出来的。这篇文章我会把我实际做过的一个水印TextBox完整拆开从实现路线、核心代码到焦点、DPI、跨线程这些坑一次性讲透。还在用Label叠水印的兄弟看完这一篇应该能直接换思路了。1. 内置 TextBox 的“不够用”清单从水印到边框颜色先说说为什么原生控件顶不住。很多人觉得TextBox只是用来输文字功能齐全就行真正做业务界面的时候才发现问题一抓一把。我总结下来做上位机和业务系统时高频诉求主要是下面这几条。水印/占位提示比如账号输入框里显示灰色“请输入用户名”获得焦点或输入内容后自动消失。原生TextBox连这个属性都没有。边框颜色和粗细原生只有Fixed3D、FixedSingle、None三种FixedSingle还是一种很旧的平面样式。想换成主题色、悬浮高亮、告警红色完全没有入口。只读和禁用状态ReadOnly之后背景变成系统灰很多场景下不但丑还会让用户误以为设备坏了。输入限制端口、IP、MAC、小数点位这类输入虽然可以用MaskedTextBox或者KeyPress里写if但每个窗体都写一遍代码重复得吓人。整体视觉统一同一个项目里输入框边框颜色要跟按钮、Tab控件统一原生控件各画各的根本管不住。1.1 业务系统里最常见的几个界面诉求我做过的设备管理和上位机项目里水印和状态色是出现频率最高的。设备参数面板有些输入框是“选填项”比如允许用户填备注但大多数用户不知道这个框是干什么的。放一个水印“选填最多50字”比旁边加一个灰标签清爽很多。还有一类场景是“输入框当状态显示框”。设备连接状态、运行状态、告警状态需要根据逻辑实时变色连接正常边框绿色断开边框红色参数异常边框黄色。同时背景要保持白色不能因为Disable变成灰色。用原生TextBox做这些代码会很别扭要么叠Label要么在窗体级Paint事件里手动画框控件一多布局一调整就全线失控。1.2 为什么这几条用原生控件很难收场根本原因在于Winform的TextBox只是Win32 Edit控件的一层薄封装。BorderStyle、BackColor这些属性是系统给的样式由系统主题决定普通属性改不动。你手动改BackColor只要焦点一变化系统随时可能用默认颜色重绘导致颜色“闪回”。想从根上解决只有一个办法让控件自己接管绘制。Windows窗体控件设计时留了后门——通过SetStyle开启UserPaint重写OnPaint或者直接在WndProc里拦截WM_PAINT、WM_NCPAINT这些消息。理解了这一点自定义TextBox就不会再停留在“改改属性”的阶段而是真正把绘制权拿过来。网上很多人用Label叠水印就是因为不用理解绘制原理上手快。但Label跟着控件移动、显隐、事件触发全靠手动同步窗体缩放一次就错位用两三次还行做成通用控件必露馅。2. 动手前先选路线继承、组合还是全自绘要做一个能进公司公共组件库的TextBox我建议先别急着写代码把路线选对。路线选错了后面每个需求都等于返工。2.1 三种实现路线的原理与取舍第一种是继承TextBox保留原生的文字编辑、光标、右键菜单、输入法能力只对看得见的部分做“手术”。水印、边框、背景、输入限制全都在这个基础上叠加。优点是文本交互不用重新实现兼容性最好缺点是要跟Windows消息打交道比如WM_NCPAINT、WM_PRINTCLIENT不熟的人容易写飘。第二种是UserControl组合内部放一个BorderStyle.None的TextBox外边自己画边框和水印再把TextBox的Text、Font、Multiline等属性一层层暴露出去。优点是绘制完全可控水印想画哪画哪不用理会系统重绘缺点是属性转发真的很烦每加一个新属性都要在UserControl里加一遍。而且用户控件自带独立的句柄和背景处理不好会有两层背景色。第三种是全自绘根本不依赖原生TextBox的绘制逻辑完全用GDI画出输入框和文字连鼠标点击、光标闪烁、输入法都要自己处理。这条路我只在写特殊控件时用比如转盘、仪表盘、炫酷开关用来做普通输入框性价比太低。方案开发成本可定制度文本行为适配适合场景继承TextBox中中最好水印、边框、输入限制UserControl组合低-中高好全新外观的输入框全自绘高最高需要自己实现特殊形状/动画控件2.2 我的选型习惯多数场景优先继承自绘边框这几年做下来的经验是做一个实际要用的TextBox第一选择永远是继承。原因很简单用户对输入框的“默认行为”期待很高鼠标选择、CtrlC/V、双击选词、右键菜单、IME中文输入这些行为全在Win32 Edit内部继承方案一个都不用动。为了少碰WM_NCPAINT我会把BorderStyle设为None然后在客户区自己画边框。这样整个控件都归系统OnPaint绘制逻辑简单还顺便避开了非客户区重绘闪烁的问题。代价是输入光标和文字区域要留出1-2像素的Padding否则边框会压住文字。后面第三部分就是完整的实现代码。如果你只是想要一个圆角输入框不太关心输入限制和状态联动那用UserControl组合会更轻松但请做好暴露十几个属性的心理准备。3. WatermarkTextBox 完整实现水印、边框和输入限制一次到位这一节直接进入代码。我习惯把这个控件叫WatermarkTextBox把下面的类文件放进项目编译后拖到窗体上就能用。3.1 控件属性与基础结构先看类的基本骨架。水印文字、边框颜色、悬浮边框颜色这些都做成公开属性方便在设计器和代码里配置。这里我用了DefaultValue特性不仅是为了让设计器正确序列化也是为了让PropertyGrid属性面板里显示得更清爽。public class WatermarkTextBox : TextBox { private string _watermark string.Empty; private Color _borderColor Color.FromArgb(160, 160, 160); private Color _hoverBorderColor Color.FromArgb(0, 120, 215); private bool _hovered; public WatermarkTextBox() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); BorderStyle BorderStyle.None; Padding new Padding(2); } [DefaultValue()] public string Watermark { get _watermark; set { _watermark value; Invalidate(); } } [DefaultValue(typeof(Color), 160, 160, 160)] public Color BorderColor { get _borderColor; set { _borderColor value; Invalidate(); } } [DefaultValue(typeof(Color), 0, 120, 215)] public Color HoverBorderColor { get _hoverBorderColor; set { _hoverBorderColor value; Invalidate(); } } }属性变化后一定要调Invalidate否则界面不会刷新。如果你在PropertyGrid里看到某个属性又不想让使用者改可以加[ReadOnly(true)]这比把属性藏起来更直观——使用者能看到但知道这是受控的。顺带提一句PropertyGrid“只能看不能改”的诉求很多时候就是靠ReadOnly特性解决的不一定要重写网格控件。3.2 水印绘制为什么在 Paint 事件里画最稳SetStyle开启了UserPaint之后OnPaint就会被触发。这里有一个很多人踩过的误区直接重写OnPaint但不在里面调用base.OnPaint(e)结果文字、光标全没了。正确的做法是先调base.OnPaint(e)让原生TextBox把当前文字和背景绘制完再叠加水印。只要Text为空且没有获得焦点就用水印文字填充输入区域。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (string.IsNullOrEmpty(Text) !Focused Enabled) { var rect new Rectangle( Padding.Left 1, Padding.Top 1, ClientSize.Width - Padding.Left - Padding.Right - 2, ClientSize.Height - Padding.Top - Padding.Bottom - 2); TextRenderer.DrawText(e.Graphics, _watermark, Font, rect, SystemColors.GrayText, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); } }然后不要忘了在TextChanged、GotFocus、LostFocus、SizeChanged里触发Invalidate否则条件变化后水印不会自动消失或出现。protected override void OnTextChanged(EventArgs e) { base.OnTextChanged(e); Invalidate(); } protected override void OnGotFocus(EventArgs e) { base.OnGotFocus(e); Invalidate(); } protected override void OnLostFocus(EventArgs e) { base.OnLostFocus(e); Invalidate(); }为什么很多文章推荐在WM_PAINT消息里画水印因为某些旧系统或特殊显示环境下TextBox内部绘制时序会乱水印闪一下就被覆盖。我自己的实测是开启OptimizedDoubleBuffer并且先调base.OnPaint的情况下Overlay画法在Win10/11上是稳定的。如果你在旧系统上遇到水印闪烁再考虑WM_PAINT加内存DC的方案也不迟。3.3 边框与悬浮态真正决定观感的部分BorderStyle设为None后控件没有原生边框OnPaint就可以自由发挥。这里画一个矩形外框鼠标悬浮或获得焦点时换成高亮色。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); // 水印绘制代码同上... using (var pen new Pen(_hovered ? _hoverBorderColor : _borderColor)) { var rc new Rectangle(0, 0, ClientSize.Width - 1, ClientSize.Height - 1); e.Graphics.DrawRectangle(pen, rc); } } protected override void OnMouseEnter(EventArgs e) { base.OnMouseEnter(e); _hovered true; Invalidate(); } protected override void OnMouseLeave(EventArgs e) { base.OnMouseLeave(e); _hovered false; Invalidate(); } protected override void OnEnter(EventArgs e) { base.OnEnter(e); _hovered true; Invalidate(); } protected override void OnLeave(EventArgs e) { base.OnLeave(e); _hovered false; Invalidate(); }注意DrawRectangle的矩形宽高要减1否则最右边和最下边的线条会被裁掉一半。这个细节很多人对着屏幕看了半天才反应过来。如果你想要圆角边框可以把GraphicsPath写出来并设置Region但Region会裁剪子控件多行TextBox的滚动条也可能受影响我一般不做圆角输入框只要颜色干净就够了。3.4 输入限制与只读背景修正做上位机免不了输入IP、端口、小数。把这些校验封装进控件里比在每个窗体KeyPress里写if好用太多。private Regex _inputRegex; public void SetInputPattern(string pattern) { _inputRegex string.IsNullOrEmpty(pattern) ? null : new Regex(pattern); } protected override void OnKeyPress(KeyPressEventArgs e) { base.OnKeyPress(e); if (_inputRegex null || e.KeyChar (char)8) return; if (!_inputRegex.IsMatch(e.KeyChar.ToString())) { e.Handled true; System.Media.SystemSounds.Beep.Play(); } }用的时候端口输入框可以这样portBox.SetInputPattern(^[0-9]$)。注意这个写法只匹配单个按键字符如果要校验“输入完成后整个值是否符合规则”最好在LostFocus或TextChanged里做整体正则因为KeyPress拿不到完整的输入内容。只读背景修正也很简单。当ReadOnly或Enabled变化时手动把BackColor设置成业务想要的浅灰或白色别让系统默认的灰色出来捣乱。protected override void OnReadOnlyChanged(EventArgs e) { base.OnReadOnlyChanged(e); BackColor ReadOnly ? Color.FromArgb(245, 245, 245) : Color.White; }上一台设备状态展示框这个细节特别重要只读输入框在客户眼里就是一张“状态卡”灰不溜秋很容易被误判成故障。4. 实战中绕不开的四个坑焦点、DPI、重绘和线程代码跑通只是第一步。放到真实项目里尤其是上位机这种长期运行的场景下面几个坑几乎是必踩的。4.1 焦点与光标在自绘模式下的状态把BorderStyle设为None后输入区域和控件边界重叠鼠标点击边框附近时系统可能会因为点击位置被判定为边缘而无法聚焦。解决方案之一是重写OnMouseDown点击时主动Focus()。另一个实际遇到的问题水印文字还在时用户点击输入框光标已经进去了但水印还没有马上消失。顺序写错就会出现0.2秒左右的水印和光标重叠观感很差。所以OnGotFocus里必须先Invalidate再走base的逻辑确保重绘先触发再向用户展示可输入状态。4.2 高 DPI 屏幕下文字模糊的来龙去脉Winform默认不是PerMonitorV2 DPI感知笔记本缩放到125%、150%时GDI绘制出来的水印和边框会发虚。这涉及进程的DPI Awareness设置。最简单的做法是在Main入口调用SetProcessDpiAwareness或者在App.config里声明PerMonitorV2。设置之后Winform会按实际DPI缩放Graphics就能拿到正确的DpiX和DpiY。[STAThread] static void Main() { if (Environment.OSVersion.Version.Major 6) { SetProcessDpiAwareness(2); // PerMonitorV2 } Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); }另外水印绘制要始终基于控件的Font不要用静态new Font去构造否则DPI变化时水印不会跟着缩放。我在很多项目里都见过这种问题100%缩放下看着正常一插上外接显示器或者拔掉笔记本文字就发虚。把Invalidate和Font一起处理好能省很大排查劲。4.3 边框重绘闪烁的根源与双缓冲处理自绘控件最常见的毛病就是闪烁。根源在于系统默认在清空背景和绘制内容之间会把旧的画面擦掉再画新的一擦一画中间屏幕就闪。解决方案是SetStyle里开启OptimizedDoubleBuffer和AllPaintingInWmPaint这在3.1里已经做了。注意如果开启了AllPaintingInWmPaint最好同时处理WM_ERASEBKGND消息否则系统可能在你的绘制前又擦一遍背景。测试时我遇到过控件边缘有残影的情况最后是拦掉EraseBackground解决的。protected override void OnEraseBkgnd(EventArgs e) { // 不擦除交给 OptimizedDoubleBuffer 处理 }这里要提醒一句多行TextBox开了双缓冲后滚动时可能出现残影。所以我自己的习惯是Multiline为true时在构造函数里动态关掉AllPaintingInWmPaint根据场景灵活切换。4.4 上位机串口数据跨线程更新控件的正确姿势最后是线程。Winform的控件只能在创建它的UI线程上操作串口接收、TCP回调、PLC通信这些都在后台线程直接textBox.Text xxx大概率抛InvalidOperationException。很多新手会用this.Invoke去绕但如果你控件还没创建句柄或者窗体已经关闭直接用Invoke一样会崩。我封装了一个通用方法放在控件里上位机项目里所有文本更新都走它public void SafeSetText(string value) { if (IsDisposed || !IsHandleCreated) return; if (InvokeRequired) { BeginInvoke(new Action(() SafeSetText(value))); return; } Text value; }这里用BeginInvoke而不是Invoke是为了避免后台线程阻塞等待UI线程导致串口接收缓冲区堆积。串口数据频率高的时候Invoke会明显降低吞吐用BeginInvoke让调用方立即返回数据在UI空闲时统一刷新。还要提醒一点如果你的后台线程里同时调用了非托管DLL并且回调里直接操作TextBox有时候抛出的不是跨线程异常而是AccessViolationException。这种崩溃往往发生在窗体关闭、句柄失效之后。SafeSetText里的IsDisposed和IsHandleCreated判断在这种场景是保命的。5. 把自定义 TextBox 放进真实项目上位机状态框与业务表单做一个控件不是终点放进真实项目里用起来才能知道它好不好。分享几个我实际用过的场景。5.1 设备状态输入框的联动场景工控上位机里我常把输入框当“状态显示框”用。比如一台设备有连接状态、运行状态、告警状态分别用三个只读WatermarkTextBox展示。连接正常时边框绿色断开时红色参数异常时黄色。切换状态只需要在通信线程里安全调用statusBox.SafeSetText(已连接); statusBox.BorderColor Color.FromArgb(0, 160, 0);因为BorderColor变化会触发Invalidate界面立刻就能刷新。用原生TextBox做不到这一点总不能用一组Label叠在框上变色吧。这种组合让“状态展示”和“普通输入”在视觉上保持一致整个界面也不会因为一个红框一个绿框显得杂乱。5.2 与 ToolTip、ErrorProvider 配合的验证体验业务表单里水印只是提示真正的验证还差一步——错误提示。我会在LostFocus时做校验错误时把边框变红再配合ErrorProvider在右侧显示图标。protected override void OnValidating(CancelEventArgs e) { base.OnValidating(e); if (!string.IsNullOrEmpty(ValidationRegex) !Regex.IsMatch(Text, ValidationRegex)) { _errorProvider?.SetError(this, ErrorMessage); BorderColor Color.FromArgb(220, 53, 69); e.Cancel true; } else { _errorProvider?.SetError(this, string.Empty); BorderColor _borderColor; } }把ValidationRegex和ErrorMessage两个属性加进控件校验就变成了控件本身的能力不用每个窗体都去调ErrorProvider。像IP地址、MAC、手机号这些规则写一次封装好团队其他人拖过来直接用。对.NET老项目来说这种“收敛重复逻辑”的方式比引入一整套第三方输入框库更稳妥也不用背一堆用不到的依赖。5.3 还能继续扩展的方向做到这里一个“能在多个项目复用的自定义TextBox”已经成型。我建议你在这个基础上再补三样。第一是获取焦点时自动全选。重写OnGotFocus调用SelectAll()对“反复修改同一个数值”的输入场景非常友好。第二是粘贴清理在OnTextChanged里过滤粘贴内容防止用户从Excel复制带格式文本进来。第三是IME模式端口、MAC这类输入框把ImeMode设为Disable避免中文输入法弹出来干扰。我个人在实际项目里的体会是自定义控件不是越复杂越好Winform这种老框架稳定、可控、让团队其他人少踩坑才是核心价值。先把一个水印TextBox打磨到位你会把消息循环、GDI绘制、DPI、线程这些Windows GUI的地基全部过一遍。后面再去做高级控件基本就是复制经验了。本文还有配套的精品资源点击获取
返回列表