简介:这份资源面向Winform桌面开发初学者与需要优化表单交互的开发者,解决原生TextBox缺少placeholder提示、无法在空内容时展示引导文字的问题。包内通过继承TextBox并重写OnPaint、OnGotFocus、OnLostFocus、OnTextChanged等方法,实现水印文本的绘制与焦点、内容变化时的自动显隐,并支持自定义水印文字与颜色。资源共25个文件,以8个cs源码文件为核心,配合resx资源、csproj工程、settings配置及exe、pdb等编译产物,压缩包约44KB,结构完整可直接打开运行。目前已有254人学习下载,适合作为自定义控件入门范例。读者可从中掌握重写原生控件、GDI+绘制文字、事件驱动刷新等实用技巧,并在此基础上扩展多语言、字体样式适配与尺寸变化处理,快速将水印效果应用到实际项目中。
1. 从 TextBox 到屏幕:WinForm 水印到底在解决什么问题
做过 WinForm 表单的人大概都遇到过这种场景:一个登录框,用户名输入框里灰灰地写着「请输入手机号」,鼠标一点进去文字就没了,清空后又回来。这个效果就是最朴素的 TextBox 水印。但标题里连着出现「textbox水印」「winform水印」「水印」三个词,说明需求远不止一个输入框提示——它可能是一整块窗体背景上铺满半透明的公司名,可能是打印预览时叠在报表上的「内部资料」,也可能是给一批截图批量打上多行多列的防伪文字。
这三类需求在 WinForm 里的实现路径完全不同:TextBox 水印靠的是控件绘制消息,窗体水印靠的是背景重绘或分层窗口,而多行多列文字水印则要自己算网格坐标再逐行绘制。很多人第一次做的时候直接往 TextBox 里塞默认文字,结果用户提交表单时把提示语也提交上去了,这就是典型的翻车现场。这篇笔记就按「输入框水印 → 窗体级水印 → 多行多列批量水印」这条线,把每一步的代码、参数和踩过的坑讲清楚,适合正在用 C# WinForm 做桌面工具、需要给界面或输出图加标识的开发者。
2. TextBox 水印:从 SendMessage 到重写 WndProc 的三种做法
2.1 为什么不能直接给 Text property 赋提示文字
最省事的做法是在窗体 Load 事件里写textBox1.Text = "请输入用户名",然后在 Enter 事件里清空。这个方案在单窗口、单输入框时能跑,但只要出现下面任意一种情况就会出问题:用户直接点「确定」按钮而没有点进输入框,提交逻辑读到的就是提示文字;程序需要读取 Text 做校验,提示语会被当成真实输入;多个输入框互相切换焦点时,清空和恢复的逻辑会打架。
根本原因是 Text 属性既是显示内容又是数据载体,把提示语混进去就污染了数据。正确的思路是让提示语只参与绘制,不进入 Text。WinForm 的 TextBox 底层是 Win32 的 EDIT 控件,它原生支持一种叫「cue banner」的提示机制,通过发送EM_SETCUEBANNER消息就能设置,显示的文字不会出现在 Text 里,获得焦点时自动隐藏,失焦且为空时自动恢复。这是最干净的做法。
2.2 用 EM_SETCUEBANNER 消息给单行 TextBox 加水印
先看代码,再解释参数。
using System; using System.Runtime.InteropServices; using System.Windows.Forms; public static class TextBoxWatermark { // Win32 消息常量,EM_SETCUEBANNER 用于设置 EDIT 控件的提示横幅 private const int EM_SETCUEBANNER = 0x1501; [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, string lParam); /// <summary> /// 给 TextBox 设置水印文字 /// </summary> /// <param name="textBox">目标控件</param> /// <param name="text">提示文字,传 null 或空串可清除</param> /// <param name="showOnFocus">true 表示获得焦点后仍显示,一般传 false</param> public static void SetWatermark(TextBox textBox, string text, bool showOnFocus = false) { if (textBox == null) throw new ArgumentNullException(nameof(textBox)); // wParam 为 1 时焦点状态下保留提示,0 时焦点状态下隐藏 SendMessage(textBox.Handle, EM_SETCUEBANNER, (IntPtr)(showOnFocus ? 1 : 0), text); } }调用方式是在窗体 Load 里写TextBoxWatermark.SetWatermark(textBox1, "请输入手机号");。这里的关键参数是wParam,传 0 表示获得焦点就隐藏提示,传 1 表示一直显示,绝大多数场景用 0。lParam直接传字符串,因为声明时用了CharSet.Unicode,中文不会乱码。需要注意的是,这个方法只对单行 TextBox 有效,多行 TextBox(Multiline = true)底层不是标准 EDIT 的 cue banner 路径,发这个消息没反应,这是第一个容易踩的坑。
2.3 多行 TextBox 与自定义控件的水印重写方案
多行 TextBox 或者你自己继承的控件,就得走重绘路线。核心是拦截WM_PAINT消息,在控件画完背景、还没画文字之前,用Graphics.DrawString把提示语画上去。下面是一个可复用的派生类。
using System.Drawing; using System.Windows.Forms; public class WatermarkTextBox : TextBox { private string _watermark = string.Empty; private Color _watermarkColor = Color.Gray; public string Watermark { get => _watermark; set { _watermark = value ?? string.Empty; Invalidate(); } } public Color WatermarkColor { get => _watermarkColor; set { _watermarkColor = value; Invalidate(); } } protected override void WndProc(ref Message m) { base.WndProc(ref m); // 0x000F 是 WM_PAINT,0x0005 是 WM_SIZE,尺寸变化也要重绘 if ((m.Msg == 0x000F || m.Msg == 0x0005) && !string.IsNullOrEmpty(_watermark) && Text.Length == 0) { using (Graphics g = CreateGraphics()) { // 用 TextRenderer 而不是 DrawString,字体渲染更接近系统原生 TextRenderer.DrawText(g, _watermark, Font, new Point(1, 1), _watermarkColor, TextFormatFlags.Left | TextFormatFlags.Top); } } } }这段代码里WndProc先调base.WndProc让控件完成自身绘制,再叠加水印,顺序反了会被控件背景覆盖。TextRenderer.DrawString和Graphics.DrawString的区别在于前者用 GDI 渲染,和 WinForm 控件文字风格一致,后者用 GDI+,在 ClearType 下会偏细。坐标(1, 1)是为了对齐 TextBox 默认的 1 像素内边距,写(0, 0)会显得文字贴边。多行 TextBox 还要注意TextFormatFlags.WordBreak,否则长提示语不会换行。
2.4 焦点切换时水印闪烁的排查
用重写方案时,很多人会遇到「点进输入框水印消失,但光标闪一下水印又冒出来」的现象。原因是WM_PAINT在获得焦点后仍会触发,而判断条件只看了Text.Length == 0。解决办法是在判断里加上!Focused,即只有失焦且内容为空时才画水印。另外,如果窗体开了双缓冲而控件没开,快速切换焦点时水印会有残影,给控件设置SetStyle(ControlStyles.OptimizedDoubleBuffer, true)能缓解。这两个点不复杂,但不注意就会以为是「玄学闪烁」,其实是重绘时机没控好。
3. 窗体级水印:背景重绘、分层窗口与打印叠加
3.1 窗体水印的三种落地形态与选型
窗体级水印和 TextBox 水印不是一个量级的问题。常见需求有三类:一是整个窗体背景铺一层淡色文字,比如「试用版」;二是水印要盖在所有控件之上,比如防截屏的工号标识;三是只在打印或导出图片时叠加,界面上不显示。这三类的技术选型完全不同。
背景铺字最简单,重写OnPaintBackground或OnPaint在窗体背景上画字即可,但控件会盖住它。要盖在控件之上,得用透明分层窗口(Layered Window)或者一个覆盖全窗体的透明 Panel。只在打印时叠加,则应该把绘制逻辑放到PrintDocument的PrintPage事件里,和界面渲染解耦。选型错了会出现「界面上好好的,一打印水印没了」或者「水印盖住了按钮点不动」这类问题。
3.2 用 OnPaint 在窗体背景绘制平铺水印
先看背景平铺的实现,这是最常用也最不容易出问题的一种。
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); DrawTiledWatermark(e.Graphics, this.ClientSize); } private void DrawTiledWatermark(Graphics g, Size size) { string text = "内部资料 禁止外传"; using (Font font = new Font("微软雅黑", 16, FontStyle.Bold)) using (Brush brush = new SolidBrush(Color.FromArgb(30, 128, 128, 128))) { // 旋转 -30 度,形成斜向平铺 g.TranslateTransform(size.Width / 2f, size.Height / 2f); g.RotateTransform(-30); SizeF textSize = g.MeasureString(text, font); // 行列间距取文字尺寸的 1.5 倍,避免重叠 float stepX = textSize.Width + 80; float stepY = textSize.Height + 60; // 覆盖范围要超出窗体对角线,否则边角会漏 int cols = (int)(size.Width / stepX) + 4; int rows = (int)(size.Height / stepY) + 4; for (int i = -rows / 2; i <= rows / 2; i++) { for (int j = -cols / 2; j <= cols / 2; j++) { g.DrawString(text, font, brush, j * stepX, i * stepY); } } } }参数上,Color.FromArgb(30, 128, 128, 128)的第一个参数是透明度,30 左右既能看清又不干扰阅读,超过 80 就会盖住正文。旋转用TranslateTransform先把原点移到窗体中心再RotateTransform,这样旋转是绕中心而不是绕左上角,否则水印会偏到一边。行列数用size.Width / stepX + 4是为了让平铺范围超出可视区,因为旋转后四个角需要额外覆盖,只按宽高算会在角落留白。stepX和stepY在文字尺寸基础上加固定值,是为了控制疏密,加得越多越稀。
3.3 让水印盖在控件之上:透明覆盖层的做法
背景水印会被按钮、面板挡住,如果要盖在控件之上,可以在窗体上放一个Dock = Fill的 Panel,背景设透明,重写它的OnPaint画水印,并把Enabled设为 false 避免拦截鼠标。但 WinForm 的透明 Panel 有个老问题:它只对父容器背景透明,如果下面还有别的控件,透过去看到的是窗体背景而不是那些控件。真正要盖住所有控件,得用分层窗口或者干脆在窗体OnPaint里最后画,但OnPaint在控件绘制之前执行,所以还是会被盖。
比较稳的做法是重写窗体的WndProc,拦截WM_PAINT,在base.WndProc之后再用Graphics.FromHwnd(this.Handle)画一次水印。这样水印画在所有子控件之上。代价是每次重绘都要多画一遍,窗体复杂时会有轻微闪烁,配合双缓冲能压下去。这个方案适合防截屏标识这类必须置顶的场景,普通背景水印没必要上。
3.4 打印与导出图片时的水印叠加
打印场景的水印不能依赖界面绘制,因为PrintDocument走的是另一套绘制流程。正确做法是在PrintPage事件里,先让业务内容画完,再调用同一套DrawTiledWatermark方法,把e.Graphics和打印区域尺寸传进去。这里有个单位换算的坑:界面绘制用的是像素,打印用的是百分之一英寸,e.Graphics的PageUnit默认是Display,直接套用像素坐标会导致水印大小和位置全乱。稳妥的做法是在打印前设置e.Graphics.PageUnit = GraphicsUnit.Millimeter,然后所有坐标按毫米算,或者干脆用e.MarginBounds的像素值配合PageScale换算。导出图片时则用Bitmap加Graphics.FromImage,流程和界面绘制一致,但要注意Bitmap的分辨率,96 DPI 导出的图放大后会糊,需要按目标尺寸重新计算字号。
4. 多行多列文字水印:网格计算与批量输出
4.1 多行多列水印的坐标模型
「多行多列文字水印」这个热搜词背后,通常是要给一批图片或一整个画布铺满规则排列的水印文字,比如给产品图打上「样品」字样矩阵。它的核心是一个二维网格坐标模型:给定画布宽高、水印文字、字号、行列间距,算出每个水印的绘制位置。和 3.2 的平铺类似,但多行多列更强调规则对齐,往往不旋转,或者旋转角度固定为 0,追求整齐的矩阵效果。
坐标模型可以抽象成:起始偏移(offsetX, offsetY)、水平步长stepX、垂直步长stepY、列数cols、行数rows。第 i 行第 j 列的水印位置就是(offsetX + j * stepX, offsetY + i * stepY)。难点在于stepX和stepY怎么定:太小会重叠,太大又铺不满。经验值是stepX = 文字宽度 + 文字宽度的 0.5 倍,stepY = 文字高度 + 文字高度的 1 倍,这样横向紧凑、纵向透气,视觉上不压抑。
4.2 批量给图片加水印的完整脚本
下面这段代码把网格水印应用到一张图片上,可以循环处理整个文件夹。
using System.Drawing; using System.Drawing.Drawing2D; using System.IO; public static class BatchWatermark { /// <summary> /// 给单张图片添加多行多列水印 /// </summary> /// <param name="inputPath">源图路径</param> /// <param name="outputPath">输出路径</param> /// <param name="text">水印文字</param> /// <param name="fontSize">字号,按图片宽度自适应建议 24-48</param> /// <param name="alpha">透明度 0-255,建议 40-80</param> public static void Apply(string inputPath, string outputPath, string text, float fontSize = 32, int alpha = 60) { using (Image src = Image.FromFile(inputPath)) using (Bitmap bmp = new Bitmap(src.Width, src.Height)) using (Graphics g = Graphics.FromImage(bmp)) { g.DrawImage(src, 0, 0, src.Width, src.Height); g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; using (Font font = new Font("微软雅黑", fontSize, FontStyle.Bold)) using (Brush brush = new SolidBrush(Color.FromArgb(alpha, 255, 255, 255))) { SizeF ts = g.MeasureString(text, font); float stepX = ts.Width + ts.Width * 0.5f; float stepY = ts.Height + ts.Height * 1.0f; // 从负偏移开始,保证左上角也有水印 for (float y = -ts.Height; y < src.Height + ts.Height; y += stepY) { for (float x = -ts.Width; x < src.Width + ts.Width; x += stepX) { g.DrawString(text, font, brush, x, y); } } } bmp.Save(outputPath, System.Drawing.Imaging.ImageFormat.Png); } } }调用时遍历文件夹即可:foreach (var f in Directory.GetFiles(dir, "*.jpg")) BatchWatermark.Apply(f, Path.Combine(outDir, Path.GetFileName(f)), "样品");。参数上,alpha用 60 左右在深色和浅色背景上都能看清,白色文字配深色图、黑色文字配浅色图,如果背景颜色不确定,可以给文字加一圈描边,用GraphicsPath把文字转成路径再DrawPath描边加填充,这样任何背景都清晰。fontSize不要写死,按图片宽度算,比如fontSize = src.Width / 30f,小图用 32 会糊成一片。
4.3 水印读取与重命名:从文件名反推水印内容
热搜里有个「水印读取+重命名」,这是个反向需求:已经有一批带水印的图,水印文字里包含了编号或日期,需要把水印内容提取出来重命名文件。纯图像识别成本高,实际项目里更常见的做法是水印文字本身有规律,比如固定前缀加序号,用 OCR 识别后再正则提取。如果水印是自己程序加的,那更简单——加水印时同步写一个同名 txt 记录水印内容,重命名时读 txt 就行,不用识别。
如果确实要 OCR,Tesseract 是常见选择,但中文识别率受字体、旋转、透明度影响很大。我的经验是:水印文字如果旋转了,先做霍夫变换矫正;如果透明度低,先做对比度拉伸;识别区域只裁水印所在的那一行,不要整图识别。识别出来再用正则匹配数字部分,比如Regex.Match(result, @"\d{6}")提取六位编号。这套流程识别率能到 90% 以上,但纯 OCR 方案始终有误差,重要场景还是建议加水印时留元数据。
4.4 网格水印的性能与内存注意点
批量处理几百张图时,最容易出问题的是内存。Image.FromFile会锁定文件,处理完不释放就没法删除或覆盖原图,所以要么用using及时释放,要么用FileStream读成字节再Image.FromStream,后者不锁文件。另外Bitmap和Graphics都是非托管资源,循环里不Dispose会很快吃满内存,几百张图就能让程序崩掉。还有一个细节:Graphics.DrawImage默认用低质量插值,缩放图片时会糊,设置g.InterpolationMode = InterpolationMode.HighQualityBicubic能改善,但会慢一些,批量处理时要在质量和速度之间权衡。
5. 避坑与排查:水印开发中最容易翻车的五个点
5.1 水印文字被当成真实输入提交
现象是用户没填内容直接提交,后台收到「请输入用户名」。原因是用 Text 属性存了提示语。解决是改用EM_SETCUEBANNER或重写绘制,让提示语不进入 Text。如果已经用了 Text 方案,至少要在提交前判断Text == 提示语就视为空,但这是补救,不如一开始就用对方法。
5.2 多行 TextBox 水印不显示
现象是单行 TextBox 水印正常,改成 Multiline 后水印没了。原因是EM_SETCUEBANNER对多行 EDIT 控件无效。解决是改用 2.3 的重写WndProc方案,或者干脆用一个 Label 叠在 TextBox 上,失焦且为空时显示 Label,获得焦点或非空时隐藏 Label。Label 方案最简单,但要注意 Label 的Enabled = false避免拦截点击。
5.3 窗体水印在最大化或缩放后错位
现象是窗体初始大小水印正常,最大化后水印只铺了左上角一块。原因是绘制时用了固定的行列数,没有随ClientSize重新计算。解决是在OnPaint里每次都根据当前ClientSize算行列数,并在Resize事件里调Invalidate()触发重绘。如果用了双缓冲,还要确保ResizeRedraw = true。
5.4 打印水印大小和界面不一致
现象是界面上水印大小合适,打印出来要么巨大要么极小。原因是打印的Graphics单位是百分之一英寸,界面是像素,两者没换算。解决是在PrintPage里显式设置e.Graphics.PageUnit,或者用e.Graphics.DpiX把像素换算成打印单位。最稳的是所有水印尺寸都按目标区域的百分比算,不写死像素值。
5.5 批量处理时内存暴涨或文件被占用
现象是处理几十张图后程序卡死或报「文件正由另一进程使用」。原因是Image.FromFile锁定了源文件,且Bitmap、Graphics没释放。解决是用using包裹所有 GDI+ 对象,读图改用File.ReadAllBytes加Image.FromStream,处理完立即Dispose。如果还要覆盖原文件,先存到临时文件再替换,避免读写冲突。
6. 进阶:把水印做成可配置、可验证的独立模块
走到这里,基本的水印需求都能覆盖了。但如果要在多个项目里复用,建议把水印逻辑抽成一个独立模块,用配置对象驱动,而不是散落在各个窗体的OnPaint里。我一般会定义一个WatermarkOptions类,包含文字、字体、字号、颜色、透明度、旋转角度、行列间距、是否平铺这些字段,然后提供一个IWatermarkRenderer接口,界面水印、打印水印、图片水印各自实现。这样换项目时只改配置,不动绘制代码。
验证水印效果有个实用技巧:写一个单元测试,用固定尺寸的空白 Bitmap 调渲染器,然后检查指定坐标的像素颜色是否符合预期透明度。比如水印文字覆盖的位置,像素 alpha 应该大于 0,空白位置应该等于背景色。这样每次改绘制逻辑跑一遍测试,能快速发现「水印画到画布外」或「透明度算错」这类回归问题。比肉眼一张张看靠谱得多。
还有一个容易被忽略的点:水印的字体要嵌入或回退。如果用了「微软雅黑」而目标机器没有这个字体,GDI+ 会回退到宋体,排版全变。稳妥做法是用系统 guaranteed 存在的字体,比如「SimSun」或「Arial」,或者把字体文件随程序发布,用PrivateFontCollection加载。这个坑我在给客户做导出工具时踩过,本机测试完美,客户机器上水印全挤成一团,查了半天才发现是字体缺失。
最后说个习惯:任何水印功能上线前,我一定会在最小窗口尺寸、最大化和 125% 缩放下各截一张图对比。WinForm 在高 DPI 下的缩放行为很微妙,AutoScaleMode设成Dpi还是Font会影响水印坐标,不实测根本发现不了。希望帮到你。
本文还有配套的精品资源,点击获取