简介:这份资源面向Winform桌面开发初学者与需要优化表单交互的开发者,解决原生TextBox缺少placeholder提示、无法在空内容时显示引导文字的问题。包内通过继承TextBox并重写OnPaint、OnGotFocus、OnLostFocus、OnTextChanged等方法,实现水印文本的绘制与焦点、内容变化时的自动显隐,并支持自定义水印文字与颜色。资源共25个文件,以cs源码、resx资源、csproj工程文件为主,另含exe、pdb、cache等编译与调试产物,压缩包约44KB,结构完整可直接打开运行。目前已有254人学习下载,适合作为自定义控件入门范例。读者可从中掌握重写控件、GDI+绘制文字、事件驱动刷新等核心思路,并在此基础上扩展多语言、字体样式适配与尺寸变化处理,快速将水印效果应用到实际项目中。
1. 从 Winform 原生 TextBox 没有 Placeholder 说起
做 Winform 表单的人大概都遇到过这个场景:登录框、搜索框、筛选条件输入框,空着的时候光秃秃一片,用户根本不知道这里该填手机号还是订单号。Web 端一个placeholder属性就解决的事,到了 Winform 里,原生TextBox翻遍属性面板也找不到对应项。这个水印.rar资源包,干的就是把这件事补上——通过继承System.Windows.Forms.TextBox重写一个WaterTextBox控件,让 Winform 文本框也能在空内容时显示灰色提示文字,聚焦输入后自动消失。包里带的是完整可编译的 VS 工程,WaterTextBox.cs、Form1.cs、Program.cs加Properties、obj、bin一整套,拿到手直接能跑,也能把WaterTextBox.cs单独抠出来丢进你自己的项目。适合谁?正在做 Winform 业务表单、被产品经理追着要「输入框要有提示语」的 C# 桌面端开发者,尤其是还在用 .NET Framework 老项目、不想引第三方 UI 库的那批人。
2. 拆开 WaterTextBox.cs:重写 TextBox 的四个关键方法
2.1 为什么是继承 TextBox 而不是套 Panel
先讲选型。Winform 里做水印,常见有三条路:一是拿一个Label盖在TextBox上面,靠Visible切换;二是用Panel包一层,在Panel的Paint事件里画字;三是直接继承TextBox,重写绘制和事件。前两种的毛病在于控件层级变复杂,Tab键顺序、Dock布局、Anchor缩放都容易出玄学问题,尤其是表单里控件一多,Label盖上去之后点击穿透还得额外处理。继承TextBox的好处是它对外仍然是一个标准TextBox,Text、MaxLength、PasswordChar这些属性原样可用,放进TableLayoutPanel或者FlowLayoutPanel里布局行为跟原生控件完全一致,不会因为多包一层就改变尺寸计算逻辑。
资源包里的WaterTextBox.cs走的就是继承路线。核心思路一句话:控件内容为空且没有焦点时,在客户区自己画一段提示文字;一旦有内容或者拿到焦点,就不画。围绕这句话,需要重写四个方法——OnPaint负责画,OnTextChanged、OnGotFocus、OnLostFocus负责在状态变化时触发重绘。这四个方法构成了整个控件的骨架,缺一个都会出现「字该消失没消失」或者「输入后水印还糊在文字上」的问题。
2.2 OnPaint 里画水印的正确姿势
先看绘制逻辑。很多人第一反应是重写OnPaint然后base.OnPaint(e)之后直接DrawString,这没错,但顺序和裁剪要讲究。base.OnPaint先把背景和边框画完,你再往上叠字,这样水印不会盖住边框。资源里的写法大致是这样:
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); // 先让基类把背景、边框画完 // 只有内容为空时才绘制水印 if (string.IsNullOrEmpty(this.Text)) { // 内缩 2px,避免文字贴着边框 Rectangle rect = this.ClientRectangle; rect.Inflate(-2, -2); // 用 TextRenderer 而不是 Graphics.DrawString TextRenderer.DrawText( e.Graphics, _watermarkText, this.Font, rect, Color.Gray, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); } }这里有个血泪经验:示例代码里常用e.Graphics.DrawString,但DrawString走的是 GDI+ 文本渲染,跟 Winform 原生TextBox内部用的 GDI 渲染不是一套,结果就是水印文字和用户输入的文字在字体粗细、基线位置上对不齐,看起来像两个字体。换成TextRenderer.DrawText就统一到 GDI 了,视觉上跟真实输入文字一致。参数上TextFormatFlags.VerticalCenter让文字垂直居中,Left让它左对齐,如果你的TextBox设了TextAlign = HorizontalAlignment.Center,这里也要跟着换成HorizontalCenter,否则水印偏左、输入文字居中,切换时会有明显跳动。
2.3 事件联动:TextChanged、GotFocus、LostFocus 一个都不能少
光有OnPaint不够,因为TextBox不会无缘无故重绘。用户敲第一个字符时,Text变了,但控件不一定触发Paint,水印就可能残留。所以要在OnTextChanged里调Invalidate()强制重绘:
protected override void OnTextChanged(EventArgs e) { base.OnTextChanged(e); this.Invalidate(); // 内容变化,重绘以决定是否显示水印 } protected override void OnGotFocus(EventArgs e) { base.OnGotFocus(e); this.Invalidate(); // 获得焦点,隐藏水印 } protected override void OnLostFocus(EventArgs e) { base.OnLostFocus(e); this.Invalidate(); // 失去焦点且为空,重新显示水印 }逻辑说明:Invalidate()只是标记区域无效,真正的重绘在消息循环空闲时发生,所以连续输入不会每敲一个字就卡一下。参数上没什么可调的,但要注意别在OnTextChanged里直接调Refresh(),Refresh是同步立即重绘,输入法联想、粘贴大段文本时会明显掉帧。资源里用的是Invalidate,这点是对的。
2.4 把 WaterTextBox 拖进 Form1 的两种接法
资源包里的Form1.Designer.cs已经演示了怎么用。第一种是纯代码接法,在Form1构造函数里手动new出来加到Controls:
public Form1() { InitializeComponent(); var wmBox = new WaterTextBox { WatermarkText = "请输入订单号", Location = new Point(20, 20), Width = 240 }; this.Controls.Add(wmBox); }第二种是编译后在工具箱里出现WaterTextBox,直接拖。前提是你的自定义控件和窗体在同一个程序集,或者单独编成 DLL 后右键工具箱「选择项」导入。资源包是同一工程,所以编译一次就能在工具箱顶部看到。WatermarkText这个属性需要在WaterTextBox.cs里用public string WatermarkText { get; set; }暴露出来,并且set里加一句Invalidate(),否则运行时改水印文字不会立即刷新。
3. 编译运行与集成到现有项目的完整流程
3.1 打开工程先确认目标框架
拿到水印.rar解压后,目录结构是标准的 VS 工程:WaterTextBox.cs、WaterTextBox.Designer.cs、Form1.cs、Form1.Designer.cs、Form1.resx、Program.cs、AssemblyInfo.cs、Settings.settings、Resources.resx等,外加bin、obj、x86和1.重写TextBox.csproj。用 VS2022 打开.csproj时,先看项目属性里的「目标框架」。老工程常见是.NET Framework 4.0或4.5,如果你本机只装了 .NET 6/8 的 SDK,会提示需要安装对应框架的开发包。这不是代码问题,是环境问题,装个 .NET Framework 4.x Developer Pack 即可。
打开后先别急着改代码,直接Ctrl+Shift+B编译一次。如果报WaterTextBox.Designer.cs里找不到某个资源,多半是Form1.resx和Form1.cs的命名空间对不上,检查.csproj里的RootNamespace和文件里的namespace是否一致。资源包是能直接编过的,编不过通常是解压时路径带了中文或空格导致resx引用错位,换个纯英文短路径再试。
3.2 把 WaterTextBox.cs 移植进你自己的项目
多数人不是要跑这个 Demo,而是要把控件搬进自己的业务工程。步骤很直接:
- 把
WaterTextBox.cs复制到你项目的任意文件夹,比如Controls。 - 在解决方案资源管理器里右键项目 → 添加 → 现有项,选中它。
- 改一下文件里的
namespace,跟你项目主命名空间保持一致,或者用using引。 - 编译,工具箱出现后拖到窗体上。
注意WaterTextBox.cs里如果引用了Properties.Resources里的图片或字符串,移植时要一并把资源项带过去,否则编译报Resources不存在。资源包里Resources.resx和Resources.Designer.cs是配套的,只拷.cs不拷.resx就会翻车。我一般移植时先把WaterTextBox.cs里所有Properties.开头的引用搜一遍,确认依赖再动手。
3.3 多行 TextBox 与 PasswordChar 的兼容处理
原生TextBox设了Multiline = true之后,水印的垂直居中就不合适了,应该顶对齐。资源里的OnPaint用的是VerticalCenter,如果你要支持多行,得根据Multiline属性动态切换TextFormatFlags:
TextFormatFlags flags = TextFormatFlags.Left; flags |= this.Multiline ? TextFormatFlags.Top // 多行:水印顶对齐 : TextFormatFlags.VerticalCenter; // 单行:垂直居中另外PasswordChar场景要特别小心。密码框空着时显示「请输入密码」没问题,但一旦用户输入,Text属性返回的是真实字符,OnPaint里判断string.IsNullOrEmpty(Text)依然正确,水印会消失。但如果你在OnTextChanged里做了别的逻辑,比如统计字符数,密码框下拿到的也是明文,这点跟原生行为一致,不算坑,但要知道。
4. 避坑与常见问题排查
4.1 水印文字和输入文字字体不一致
现象:空框时提示字看起来偏细偏虚,输入后文字变实,像换了字体。 原因:用了Graphics.DrawString,走 GDI+ 渲染,跟TextBox内部的 GDI 渲染不统一。 解决:改用TextRenderer.DrawText,并确保传入的Font就是控件自身的this.Font,不要另new Font。
4.2 输入后水印残留不消失
现象:敲了字,水印还叠在文字下面。 原因:OnTextChanged里没调Invalidate(),或者调了但被别的逻辑覆盖。 解决:确认OnTextChanged重写里有base.OnTextChanged(e)和Invalidate();如果窗体上还有别的定时器在刷,检查是否把Text又置空了。
4.3 窗体缩放后水印位置错乱
现象:拉大窗体,水印跑到控件外面或者偏到一边。 原因:OnPaint里用了固定的Point坐标而不是ClientRectangle。 解决:始终基于this.ClientRectangle计算绘制区域,配合Anchor或Dock让控件自身跟随缩放,水印自然跟着走。
4.4 设计器里能看到水印,运行时却没有
现象:VS 设计视图显示提示文字,F5 跑起来空白。 原因:设计器调用的是设计时绘制路径,运行时Text初始值可能被Form1.Designer.cs里的InitializeComponent设成了空以外的值,或者WatermarkText没赋值。 解决:检查Form1.Designer.cs里有没有waterTextBox1.Text = ""之类的语句,确认WatermarkText在构造函数或属性面板里设了值。
4.5 复制到新项目后工具箱不显示控件
现象:编译通过,但工具箱里找不到WaterTextBox。 原因:新项目目标框架跟控件程序集不一致,或者控件类不是public。 解决:确认WaterTextBox类是public class,且新项目引用了正确的程序集;工具箱右键「选择项」手动浏览到编译出的 DLL 或 EXE。
5. 进阶:让水印支持动态提示与状态联动
5.1 用属性封装水印文字与颜色
基础版把水印文字写死在构造函数里,实际业务里往往要根据场景切换,比如「请输入手机号」和「请输入验证码」共用一个控件。把WatermarkText和WatermarkColor做成属性,并在set里Invalidate():
private string _watermarkText = "请输入内容"; private Color _watermarkColor = Color.Gray; public string WatermarkText { get => _watermarkText; set { _watermarkText = value; Invalidate(); } } public Color WatermarkColor { get => _watermarkColor; set { _watermarkColor = value; Invalidate(); } }这样在Form1里就能按业务状态动态改:
// 根据下拉框选择切换提示语 cmbType.SelectedIndexChanged += (s, e) => { wmBox.WatermarkText = cmbType.SelectedIndex == 0 ? "请输入手机号" : "请输入邮箱地址"; };参数说明:Invalidate()放在set里保证属性一变就重绘,不用手动调Refresh。颜色建议用Color.FromArgb(160, 160, 160)这种带透明度的灰,比纯Color.Gray更柔和,跟 Winform 默认边框色调更搭。
5.2 验证水印是否真的生效:三个必测场景
写完控件别急着交付,我一般强制走这三步验证。第一,空框 + 无焦点,水印应显示;第二,空框 + 有焦点,水印应消失,光标在闪;第三,输入文字后失焦再聚焦,水印不应出现。这三个场景覆盖了OnPaint、OnGotFocus、OnLostFocus、OnTextChanged四个方法的联动。再补一个边界:输入几个字符再全删掉,失焦后水印应重新出现。如果这一步水印没回来,多半是OnTextChanged里判断条件写成了Text.Length > 0而不是string.IsNullOrEmpty,删空时没触发重绘。
5.3 一个我踩过的坑:设计时序列化
自定义控件如果属性没有加[DefaultValue]或[DesignerSerializationVisibility],在设计器里改过WatermarkText后,Form1.Designer.cs会生成一堆冗余代码,甚至把默认值也写进去,导致运行时水印跟设计时不一致。从那以后我每次给自定义控件加属性,都习惯性补上:
[DefaultValue("请输入内容")] [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public string WatermarkText { get; set; }DefaultValue告诉设计器「这个值跟默认一样就别序列化」,DesignerSerializationVisibility控制属性是否出现在属性面板并写入代码。加上之后,Form1.Designer.cs干净很多,也不会出现「设计器里改了没生效」的玄学。这个资源包里的WaterTextBox.cs是基础版,你拿过去按上面补两行特性,就能直接用在正式项目里。希望帮到你。
本文还有配套的精品资源,点击获取