ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Winform自定义TextBox水印控件开发实战:从原理到边界场景

2026/9/28 21:14:40 拓冰建站 浏览量
Winform自定义TextBox水印控件开发实战:从原理到边界场景 简介Winform开发中TextBox原生控件没有内置水印功能这里提供的完整自定义实现正好弥补了这一缺口。它面向需要为输入框增加提示信息的.NET桌面应用开发者效果与HTML5的placeholder类似未输入时显示灰色提示文字获取焦点或输入后自动消失。压缩包共25个文件核心为8个C#源码文件包含重写TextBox绘制的WatermarkTextBox类、窗体及程序入口代码同时附有工程文件csproj、资源文件resx以及可执行的exe便于直接运行查看效果整体仅44KB轻量易用。已有254人学习该资源。其中不仅给出完整实现代码还涵盖了OnPaint绘制、TextChanged与焦点事件的刷新处理等关键细节并考虑了控件尺寸变化、字体样式与颜色适应性等扩展优化方向适合初中级开发者快速集成或二次定制。1. 先想清楚Winform原生TextBox为什么没有水印这个缺口值不值得自己补登录框的请输入用户名提示、搜索框里的默认关键词、查询条件区的占位说明——这是textbox水印最典型的三类场景。Winform原生TextBox没有Placeholder属性你要么在旁边摆个Label要么等用户输入之后再校验提示体验都差一截。做winform水印的主流思路是继承TextBox重写绘制逻辑本文按这条路线把WaterTextBox从原理到落地拆开讲覆盖焦点变化、掩码模式、多行文本框、设计器序列化这几个必踩的环节。适合正在做C/S项目登录界面、搜索框、表单录入区的开发也适合想给自己的控件库补充基础控件的朋友。整体代码量不大但坑全在边界场景里读完能直接用。2. 重写WaterTextBox从OnPaint到TextChanged的完整水印逻辑2.1 继承TextBox而不是UserControl选型理由先立住第一次接触水印需求时我的第一反应是做一个UserControl上面叠一个TextBox和一个LabelLabel显示提示语TextBox为空时Label可见。这个方案在视觉上没错但用起来浑身难受UserControl壳子把原生TextBox的右键菜单、撤销栈、下拉联想、复制粘贴行为全挡掉了一层焦点事件和键盘消息要手动转发稍有疏漏就出现快捷键失灵这种玄学问题而且拖到窗体上还会多一层容器布局锚定都要重新调。继承System.Windows.Forms.TextBox是更干净的一条路。水印本质上不是额外控件而是TextBox自己绘制的一层提示文字。继承之后TextBox原有的输入、剪贴板、IME、多行、密码掩码全部保持原样我们只是在它绘制自己的时候多画几个字。这个思路和HTML5的placeholder实现逻辑一致占位提示不是替代input而是input内部的一种状态展示。另外一个选型理由是维护成本。水印控件一旦封装好整个项目的登录框、搜索框、下拉筛选框都能复用。如果做成UserControl每次拖一个新实例都要检查Label是否对齐、是否被遮挡而继承了TextBox之后属性面板里直接多出WatermarkText和WatermarkColor两个属性设计器里所见即所得业务方提水印颜色加深一点的需求改一个属性就完事。2.2 核心绘制OnPaint里画水印的坐标与文字层次先看WaterTextBox.cs的完整版本这个类就是水印.rar里的核心文件。下面代码是为了让水印在运行时和设计器里都能正确显示我在项目里实际用的版本using System; using System.ComponentModel; using System.Drawing; using System.Windows.Forms; public class WaterTextBox : TextBox { private string _watermarkText ; private Color _watermarkColor Color.Gray; private Font _watermarkFont; public WaterTextBox() { _watermarkFont new Font(Font, FontStyle.Italic); SetStyle(ControlStyles.OptimizedDoubleBuffer, true); } [Browsable(true)] [Category(Appearance)] [Description(水印提示文本)] [DefaultValue()] public string WatermarkText { get { return _watermarkText; } set { _watermarkText value; Invalidate(); } } [Browsable(true)] [Category(Appearance)] [Description(水印文字颜色)] public Color WatermarkColor { get { return _watermarkColor; } set { _watermarkColor value; Invalidate(); } } [Browsable(true)] [Category(Appearance)] [Description(水印字体默认使用控件字体的斜体)] public Font WatermarkFont { get { return _watermarkFont; } set { _watermarkFont value; Invalidate(); } } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (string.IsNullOrEmpty(Text) string.IsNullOrEmpty(_watermarkText)) return; if (!string.IsNullOrEmpty(Text)) return; Rectangle rect ClientRectangle; rect.Inflate(-2, -2); using (SolidBrush brush new SolidBrush(_watermarkColor)) { StringFormat format StringFormat.GenericDefault; format.Alignment StringAlignment.Near; format.LineAlignment StringAlignment.Center; format.Trimming StringTrimming.EllipsisCharacter; format.FormatFlags | StringFormatFlags.NoWrap; e.Graphics.DrawString(_watermarkText, _watermarkFont, brush, rect, format); } } 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(); } protected override void OnFontChanged(EventArgs e) { base.OnFontChanged(e); if (_watermarkFont ! null) _watermarkFont.Dispose(); _watermarkFont new Font(Font, FontStyle.Italic); Invalidate(); } protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); Invalidate(); } }代码逻辑分三层属性定义、绘制逻辑、刷新时机。属性层把水印文本、颜色、字体开放给使用者设置任何属性都会触发Invalidate让控件立即重绘。绘制层是核心判断——文本为空且水印文本非空时才在ClientRectangle基础上向内缩进2像素绘制避免文字贴到边框上。这里有一个关键细节StringFormat.GenericDefault要经过FormatFlags | StringFormatFlags.NoWrap处理否则水印太长时会自动换行把TextBox的多行布局打乱。LineAlignment设为Center是为了让单行TextBox里水印垂直居中和多行TextBox的顶部对齐需求保持一致。绘制区域用ClientRectangle而不是Bounds两者差别在于ClientRectangle不包含边框和标题栏坐标从客户区左上角算起这样在Fixed3D边框下水印也不会被边框吃掉。2.3 三个刷新触发点TextChanged / GotFocus / LostFocus 的分工水印控件的核心难点不在画字而在什么时候重新画。我来拆一下这三个事件的职责OnTextChanged处理的是输入状态变化。用户敲了第一个字符Text从空变成非空水印就得消失用户把内容删光Text变回空水印要重新出现。这是水印和普通文本最本质的区别水印显示与否完全由Text是否为空决定不记焦点状态。OnGotFocus和OnLostFocus处理的是焦点引起的视觉变化。如果用原生placeholder做参照HTML5的placeholder在输入框聚焦且为空时依然显示。但很多产品经理会要求聚焦后就隐藏水印失焦且没输入时再显示这时候就必须在焦点事件里触发刷新。我在上面的代码里没有强制聚焦隐藏只做了Invalidate具体行为由外部通过设置WatermarkText为空来控制。如果你的业务要求聚焦即隐藏在OnGotFocus里加一个if (Focused) Invalidate()同时在OnPaint里加if (Focused _hideOnFocus) return;就行了。OnFontChanged和OnHandleCreated是容易被忽略的两个刷新点。前者处理字体切换比如用户在属性面板把Font从宋体9pt改成微软雅黑10pt水印字体要跟着变后者处理控件句柄重建比如在窗体加载过程中控件被反复创建销毁句柄重建后必须重绘。把Invalidate都挂在生命周期事件上能避免大部分水印不见了的偶发问题。3. 把WaterTextBox接进Form1设计器支持与项目落地的完整流程3.1 项目文件清单.csproj、Designer.cs、resx 各管什么水印.rar里是一个完整的Winform项目我打开后整理了一下文件结构都和标准Winform项目模板对应文件作用是否需要改动WaterTextBox.cs水印控件核心类核心按业务需求改属性Form1.cs主窗体逻辑代码添加水印控件的使用示例Form1.Designer.cs窗体设计器生成的布局代码一般不手改由设计器维护Form1.resx窗体的资源文件存图标、二进制资源通常不动Program.cs程序入口Main函数不动Properties/Resources.Designer.cs项目级资源强类型包装不动Properties/Settings.Designer.cs项目配置强类型包装不动Properties/Settings.settings配置文件不动1.重写TextBox.csproj项目文件记录编译项命名有点怪但不影响obj/ 和 bin/编译中间产物与输出直接忽略这个项目的名字叫1.重写TextBox.csproj是当初随手建的demo不影响使用。obj和bin是编译生成的临时目录可以放心删掉再重新生成。整个项目的核心就是WaterTextBox.cs一个文件其他都是Winform项目标配——这正好说明水印控件的侵入性很低把它拷贝到你现有项目的任意目录添加到.csproj里就能用。3.2 在Form1里实例化水印控件属性配置与运行效果把WaterTextBox拖到窗体上之后Form1.cs里会生成对应的实例化代码。下面是完整的落地流程从声明到添加到Controls集合public partial class Form1 : Form { private WaterTextBox txtUsername; private WaterTextBox txtPassword; public Form1() { InitializeComponent(); InitWatermarkTextBoxes(); } private void InitWatermarkTextBoxes() { txtUsername new WaterTextBox { Location new Point(30, 30), Size new Size(200, 25), WatermarkText 请输入用户名, WatermarkColor Color.Gray, Font new Font(微软雅黑, 9F) }; txtPassword new WaterTextBox { Location new Point(30, 65), Size new Size(200, 25), WatermarkText 请输入密码, WatermarkColor Color.Silver, UseSystemPasswordChar true }; Controls.Add(txtUsername); Controls.Add(txtPassword); } }这段代码重点在于两个水印TextBox的属性配置差异。用户名框的水印颜色用Color.Gray密码框用Color.Silver这是为了视觉上区分主次提示。UseSystemPasswordChar true让密码框输入时显示掩码字符而WatermarkText属性本身依然在水印层绘制——这就是第4章要展开的密码框场景。这里有个新手常犯的错误直接在设计器里新建控件然后手动往Form1.cs里写初始化逻辑结果发现运行时水印不显示。原因是InitializeComponent执行后控件虽然被创建了但WatermarkText还没赋值就执行了第一次OnPaint这时候水印为空自然画不出来。赋值之后虽然有Invalidate刷新但如果代码写在InitializeComponent()之前控件都还没new出来属性赋值就会抛空引用。我一般把水印初始化统一放进InitWatermarkTextBoxes在InitializeComponent之后调用顺序永远可控。3.3 设计时水印显示DesignMode判断与属性序列化把控件拖到设计器里的时候如果不做特殊处理水印文字是看不到的。因为设计器里控件处于DesignMode状态Text为空但WatermarkText也还没有被赋值。为了让设计器里也能预览水印需要在OnPaint里处理设计时状态同时给属性加上序列化特性protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); bool designMode DesignMode || LicenseManager.UsageMode LicenseUsageMode.Designtime; if (designMode string.IsNullOrEmpty(_watermarkText)) _watermarkText 水印提示文字; if (string.IsNullOrEmpty(Text) !string.IsNullOrEmpty(_watermarkText)) { // 绘制水印 } }DesignMode属性在构造函数里判断不准确但在OnPaint阶段已经可用。LicenseManager.UsageMode LicenseUsageMode.Designtime是更严格的判断方式能覆盖从工具箱拖拽控件的场景。这样设置之后在窗体设计器里选中WaterTextBox即使不设置WatermarkText也能看到一个灰色的水印提示文字方便预览位置和颜色效果。属性序列化有一个坑要提前处理WatermarkText如果用了[DefaultValue()]设计器会在属性值等于默认值时跳过序列化。一旦你在设计器里手动改了WatermarkText设计器会把赋值写进Form1.Designer.cs。但WatermarkFont和WatermarkColor没有DefaultValue标签时会被无条件序列化导致Designer.cs里出现大量冗余代码。我的做法是给WatermarkColor加上[DefaultValue(typeof(Color), Gray)]并在构造函数里用Color.Gray初始化这样设计器能正确判断默认值。4. 边界场景密码框、多行文本框与字体尺寸自适应4.1 密码框模式水印与掩码字符的共存逻辑密码框是水印控件最容易翻车的场景。业务上的期望是密码输入框没有内容时显示请输入密码的提示一旦用户点击输入立刻隐藏提示并显示掩码圆点。表面上看WatermarkText为空时才绘制水印Text为空时画水印这个逻辑在密码框里好像天然成立实际上坑在焦点状态和掩码的交互上。UseSystemPasswordChar true时TextBox内部会把所有字符渲染成掩码符号。水印是我们自己用DrawString画的不受掩码影响所以水印本身能正常灰色显示。真正的问题是用户点击密码框后发现水印消失了但光标落在第一个掩码字符的位置视觉上没有停留感——他以为是自己的点击把水印选掉了实际上是因为焦点进入后TextBox进入了输入态。这个不算bug但产品经理大概率会跑来问你为什么水印闪了一下就没了。如果业务要求聚焦后水印依然保留直到用户真正输入第一个字符才消失那就要改判断条件。我一般会在控件里增加一个HideWatermarkOnFocus属性默认false保持HTML5 placeholder的行为一致private bool _hideWatermarkOnFocus true; public bool HideWatermarkOnFocus { get { return _hideWatermarkOnFocus; } set { _hideWatermarkOnFocus value; Invalidate(); } } protected override void OnPaint(PaintEventArgs e) { // 在判断环节加上焦点条件 if (_hideWatermarkOnFocus Focused) return; if (string.IsNullOrEmpty(Text) !string.IsNullOrEmpty(WatermarkText)) { // 绘制水印 } }密码框场景还要注意回车提交的联动。登录窗体一般在密码框里按下Enter直接触发登录水印控件本身不需要处理键盘事件但如果你重写OnKeyDown来做Enter触发登录的窗体逻辑记得先调用base.OnKeyDown(e)否则水印的Invalidate刷新链会断。4.2 多行模式绘制区域从ClientRectangle到文本区的修正TextBox设置Multiline true之后水印绘制区域就不一样了。单行模式下ClientRectangle的高度和一行文字高度接近水印垂直居中即可。多行模式下ClientRectangle的高度远大于文字高度默认的LineAlignment Center会让水印出现在整个控件的垂直正中这在水印只有一行时看起来居中没问题但如果你的水印文本有两行甚至三行就会顶到第一行的位置视觉上偏上。处理办法是用GetPositionFromCharIndex计算第一行文字的基线位置把水印绘制区域压缩到第一行的高度范围内protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (Multiline) { Rectangle firstLineRect new Rectangle( ClientRectangle.Left 2, ClientRectangle.Top 2, ClientRectangle.Width - 4, Font.Height 4); using (SolidBrush brush new SolidBrush(_watermarkColor)) { e.Graphics.DrawString(_watermarkText, _watermarkFont, brush, firstLineRect); } } else { // 原有单行绘制逻辑 } }多行模式下Font.Height是当前字号的行高加上4像素上下padding第一行水印的位置就和用户实际输入的文字位置对齐了。另一种常见做法是把TextAlign设置为ContentAlignment.TopLeft但那是ContentControl的属性TextBox不适用别混了。多行TextBox还有一个特殊场景设置了ScrollBars ScrollBars.Vertical后出现滚动条绘制区域右侧会被滚动条占掉一段。ClientRectangle本身不包含滚动条区域吗这里注意Winform的TextBox作为EDIT控件包装ClientRectangle已经扣除了边框但滚动条是覆盖在客户区右侧的水印如果太长会画到滚动条底下。我的处理是把绘制区域宽度再减SystemInformation.VerticalScrollBarWidth保证水印不会和滚动条重叠。4.3 字体、缩放与边框水印坐标的三种自适应水印坐标不能写死。Location、Size变化时ClientRectangle会自动跟着变所以rect.Inflate(-2, -2)这种相对计算没有问题。但有几个隐藏的坐标问题会让水印跑偏边框样式的影响。BorderStyle.Fixed3D和FixedSingle在ClientRectangle之外绘制边框水印向内缩进2像素没问题。但如果改成BorderStyle.None控件看起来是一个扁平的输入区水印还缩进2像素的话会显得偏左。遇到这种控件我会把缩进改成4像素视觉上更均衡。字号缩放的影响。OnFontChanged里我们重建了水印字体为当前字体的斜体但多行模式下第一行绘制区域的Font.Height用的是同一个Font对象没问题。如果外部代码直接改了this.Font而没有触发FontChanged事件水印字体就和实际输入字体不匹配水印看起来胖一圈。DPI缩放的影响。100%缩放下一切正常125%或150%缩放下TextBox会自动放大但水印字体如果用了new Font(微软雅黑, 9F)这种固定点数会和水印控件本身的大小不成比例。正确做法是水印字体默认用Font的引用或者用Font.FontFamily创建同族字体_watermarkFont new Font(Font.FontFamily, Font.Size, FontStyle.Italic);这样字号跟随控件字体走DPI变化时控件字体整体缩放水印也跟着缩放不会出现控件变大了水印还是小字的情况。4.4 界面美化扩展从水印到自定义圆弧文本框热搜里经常看到winform 自定义圆弧文本框 带图片这类需求本质上和水印是同一个套路继承TextBox用OnPaint附加绘制用Region裁剪形状。水印控件做顺手之后扩展这个思路能一次解决WPF那套圆角输入框在Winform里实现不了的问题。圆角文本框要用Region裁剪控件外形同时在OnPaint里画圆角边框组合代码如下protected override void OnResize(EventArgs e) { base.OnResize(e); using (GraphicsPath path CreateRoundedRectanglePath(ClientRectangle, 8)) { Region new Region(path); } } private GraphicsPath CreateRoundedRectanglePath(Rectangle bounds, int radius) { GraphicsPath path new GraphicsPath(); int d radius * 2; path.AddArc(bounds.X, bounds.Y, d, d, 180, 90); path.AddArc(bounds.Right - d, bounds.Y, d, d, 270, 90); path.AddArc(bounds.Right - d, bounds.Bottom - d, d, d, 0, 90); path.AddArc(bounds.X, bounds.Bottom - d, d, d, 90, 90); path.CloseFigure(); return path; }Region裁剪之后控件四个角变成圆弧但输入光标和水印都会被裁剪。水印因为本来就在内部缩进绘制不受影响光标在靠近右下角时可能被裁剪掉一截这是圆角控件的通病后续可以通过重写OnKeyPress里调整光标位置来缓解。这个扩展演示了一个通用结论只要核心绘制逻辑是独立的水印、圆角、背景图这些美化需求都可以叠加上去而不互相干扰。5. 避坑与常见问题排查水印不显示、残留、闪烁的根源5.1 水印残留输入框清空后水印不出现或输入时水印还挂在文字上现象用户把输入框里的内容全部删掉水印没有恢复或者输入第一个字符后水印还残留了半秒钟才消失。原因OnPaint里的判断依赖string.IsNullOrEmpty(Text)但如果删除内容时TextBox的TextChanged没有触发刷新旧的水印帧就留在画面上。另一个原因是我在第2章代码里写的OnTextChanged里用Invalidate()而有些设备在快速输入时Invalidate被合并刷新不及时。解决把Invalidate()改成Refresh()强制同步重绘。Refresh()会立即触发OnPaint虽然性能比Invalidate差一点但对水印这种轻量绘制来说完全够用。如果仍然残留检查是否在OnPaint里提前return了——if (!string.IsNullOrEmpty(Text)) return;这个判断没问题但要确保它在base.OnPaint(e)之后而不是替代它。5.2 设计器把水印文本写死进Form1.Designer.cs一改就乱现象在设计器里设置了WatermarkText然后直接手改Form1.Designer.cs里的赋值代码下次打开设计器报错或属性被重置。原因Designer.cs由设计器序列化生成手改后设计器重新加载时发现属性值和设计器缓存不一致会以设计器为准覆盖你的改动。水印文本是字符串没有资源文件包装设计器序列化到Designer.cs时不会自动转义换行和引号手改复杂文本很容易破坏语法。解决不要在Designer.cs里维护水印文本把WatermarkText的初始化全部放到InitWatermarkTextBoxes这样的方法里。设计器里改了属性没问题但最终以代码里的值为准。另外一个习惯性操作水印文本如果来自数据库或配置文件直接在设计器里预览时用固定示例文本运行时再替换成真实提示。做法是给水印控件增加一个ApplyWatermarkFromResource方法从Resources.resx或配置中心读取文本避免字符串散落在多份文件里。5.3 密码框启用状态与背景色导致水印不可见现象WaterTextBox设置了Enabled false或者ReadOnly true之后水印要么看不见要么被背景色盖住。原因禁用状态下TextBox的BackColor会变成浅灰色ForeColor变成浅灰灰色水印和背景融为一体。ReadOnly状态下背景也会变灰。我自己在做一个查询条件区时把搜索框设成ReadOnly用于展示结果水印直接消失了排查了半天才发现是颜色对比度问题。解决在OnPaint里根据Enabled和ReadOnly动态调整水印颜色Color effectiveColor _watermarkColor; if (!Enabled || ReadOnly) { effectiveColor SystemColors.GrayText; }SystemColors.GrayText是为禁用状态设计的系统色在浅灰背景上依然有辨识度又不会和正常输入文字混淆。如果业务要求禁用状态下完全隐藏水印就在OnPaint前面加条件判断两者都行但一定要做成显式逻辑不要依赖TextBox自己去切换。5.4 水印文字带重影、闪烁临时加载时文字残影乱跳现象输入框在窗体加载或切换页面时水印文字出现重影、闪烁甚至短暂显示一段重叠的残影。原因TextBox默认的背景绘制不是双缓冲的。Invalidate()触发的OnPaint在背景擦除和前景绘制之间有窗口期系统先把旧画面擦成背景色再画水印这个时间差在低性能机器或频繁刷新时被放大就是肉眼可见的闪烁。重影则是因为OnPaint里base.OnPaint(e)已经画了一遍文字我们又在上面画水印如果判断条件没生效水印叠加在输入文字上。解决三个方案按优先级来。一是构造函数里加SetStyle(ControlStyles.OptimizedDoubleBuffer, true)让水印在离屏缓冲里画完再一次性贴上去二是绘制前判断Text为空再画水印避免叠加三是如果控件被放在TabPage或Panel里父容器的DoubleBuffered属性也要开否则子控件重绘时父容器先擦底仍会闪一下。面板类控件默认不开双缓冲需要用反射或继承Panel重写来开启。6. 进阶技巧用WndProc拦截WM_PAINT消除水印闪烁附三步验证清单6.1 WndProc拦截WM_PAINT把水印画在系统绘制之后OnPaint方案在绝大多数Winform项目里够用但如果你的界面用到了主题美化、自绘窗体或者控件的背景是通过WM_ERASEBKGND消息绘制的OnPaint里的水印可能被后续的系统绘制盖掉。这时候要换一个思路拦截WM_PAINT消息在系统画完整个TextBox之后追加水印。我在一个重绘了所有控件皮肤的项目里用过这个方案效果稳定代码量也不大protected override void WndProc(ref Message m) { base.WndProc(ref m); const int WM_PAINT 0x000F; if (m.Msg WM_PAINT) { if (string.IsNullOrEmpty(Text) !string.IsNullOrEmpty(WatermarkText)) { using (Graphics g CreateGraphics()) { using (SolidBrush brush new SolidBrush(_watermarkColor)) { g.DrawString(WatermarkText, _watermarkFont, brush, new PointF(ClientRectangle.Left 2, ClientRectangle.Top 2)); } } } } }这段代码的关键点是把水印绘制放在base.WndProc(ref m)之后确保系统先完成TextBox自身的绘制水印以最后画的内容身份出现在最上层。CreateGraphics()拿到的Graphics绘制内容不会持久保持一旦控件重绘就会被擦掉所以必须在每次WM_PAINT后都重新画。两个代价要知道一是用CreateGraphics绘制的文字在控件被部分遮挡再恢复时系统只重绘露出的区域水印会被截断需要自己处理WM_PRINTCLIENT这类消息补画。二是在密码框和多行模式下坐标计算依然是第4章那套逻辑WndProc只是改变绘制时机不改变绘制规则。所以这个方案适合当作兜底优先还是用OnPaint翻车了再上WndProc。6.2 三步验证清单交付前强制检查每次在项目里集成完水印我都按这份清单走一遍比测试人员随手点两下靠谱得多。第一步焦点验证。鼠标依次点击用户名框、密码框、其他任意控件确认水印在聚焦时、失焦时、有输入时、清空后的四种状态都符合预期。第二步边界验证。设置密码框UseSystemPasswordChar为true输入密码再删光确认掩码、水印、光标三者不打架把窗体DPI调到125%重启程序确认水印字号和位置正常。第三步颜色验证。分别设置BackColor为白、浅灰、深蓝确认水印可见度没问题再把控件设为Disabled和ReadOnly确认水印颜色自动切换了。三个验证点都过了这个水印控件在C/S项目里基本就站得住。我之前有个项目上线之后业务方反馈搜索框提示文字在深色主题下看不清查到最后是皮肤组件重绘了TextBox背景水印还是用的Gray。从那以后我给每个需要换肤的窗体都单独测一遍水印对比度配色不达标就直接换系统色不再用写死的Gray。把验证动作固定成习惯之后水印相关的bug基本绝迹了。希望帮到你。本文还有配套的精品资源点击获取