简介这份资源面向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 nametextBox目标控件/param /// param nametext提示文字传 null 或空串可清除/param /// param nameshowOnFocustrue 表示获得焦点后仍显示一般传 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 有效多行 TextBoxMultiline 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_PAINT0x0005 是 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 nameinputPath源图路径/param /// param nameoutputPath输出路径/param /// param nametext水印文字/param /// param namefontSize字号按图片宽度自适应建议 24-48/param /// param namealpha透明度 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 就行不用识别。如果确实要 OCRTesseract 是常见选择但中文识别率受字体、旋转、透明度影响很大。我的经验是水印文字如果旋转了先做霍夫变换矫正如果透明度低先做对比度拉伸识别区域只裁水印所在的那一行不要整图识别。识别出来再用正则匹配数字部分比如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会影响水印坐标不实测根本发现不了。希望帮到你。本文还有配套的精品资源点击获取