写这标题其实是有感而发的。前阵子给一个WPF上位机项目的登录窗口做界面美化需求很简单密码输入框里要有一行灰色的提示文案请输入密码鼠标点进去、输完密码就消失。听起来跟TextBox水印一样简单结果PasswordBox这玩意默认不带水印网上搜了一堆方案不是要装控件库就是要改控件模板代码量一个比一个大。最后我自己封装了一套方案附加属性加一套样式总共也就四十来行代码业务侧只要写一行XAML整个项目所有密码框都能用。这篇就把思路和完整代码拆开讲清楚。1. 为什么PasswordBox天生没有水印位1.1 默认模板里到底有什么先看PasswordBox最原始的内部结构。WPF控件绝大多数都是无外观控件真正干活的是ControlTemplate里面的可视化树。PasswordBox的默认模板大概是这样的Style TargetType{x:Type PasswordBox} Setter PropertyTemplate Setter.Value ControlTemplate TargetType{x:Type PasswordBox} Border x:NameBd Background{TemplateBinding Background} BorderBrush{TemplateBinding BorderBrush} BorderThickness{TemplateBinding BorderThickness} ScrollViewer x:NamePART_ContentHost SnapsToDevicePixels{TemplateBinding SnapsToDevicePixels}/ /Border /ControlTemplate /Setter.Value /Setter /Style重点在这个PART_ContentHost。密码输入、光标闪烁、选中高亮全都由这个ScrollViewer承载。它是个特殊的逻辑承载器你没法往它里面塞别的东西。整个模板里就一个Border套一个ScrollViewer没有任何位置放提示文字。这就是为什么很多人想通过简单设置属性实现水印最后都无功而返。提示PART_ContentHost是WPF控件模板里的约定命名模板必须包含这个元素否则PasswordBox没法正常工作。1.2 Password不是依赖属性限制了多少想象力这是整个问题的病根也是最容易被新手忽略的点。WPF里几乎所有属性都能参与绑定、样式触发、动画因为它们都是依赖属性。但PasswordBox.Password偏偏是个普通CLR属性没走依赖属性那套机制。后果是实实在在的不能写Trigger PropertyPassword Value来判断密码框是不是空的因为样式触发器只认依赖属性。不能直接给Password做数据绑定ViewModel里没法用{Binding Password}双向拿密码。不能用作DataTrigger的绑定源。大家平时用TextBox水印可以绑定Text属性然后做触发器。到了PasswordBox这里整套思路直接断了你连密码是否为空这个状态都没法用纯XAML方式感知到。所以任何PasswordBox水印方案都必须解决同一个问题怎么在密码变化时收到通知然后去控制提示文字的显示/隐藏。既然Password不是依赖属性就只能靠PasswordChanged事件或者用行为/附加属性去监听。2. 先看常见的三种伪解法为什么不够省心2.1 装饰层TextBlock看起来简单用起来心累网上最多的一种做法是在PasswordBox外面套一层Grid下面放TextBlock上面放PasswordBox密码框背景设成Transparent事件里控制TextBlock显示隐藏Grid TextBlock x:NamePlaceholder Text请输入密码 ForegroundGray Margin6,0,0,0 VerticalAlignmentCenter IsHitTestVisibleFalse/ PasswordBox x:NamePwdBox BackgroundTransparent/ /GridPwdBox.PasswordChanged (s, e) { Placeholder.Visibility string.IsNullOrEmpty(PwdBox.Password) ? Visibility.Visible : Visibility.Collapsed; };这个方案能跑但它有个致命问题每增加一个密码框就要复制一份Grid结构、额外写一段事件处理。登录窗只有一个密码框还好一旦遇到旧密码、新密码、确认密码这种三框页面代码就到处重复。而且视觉样式没法统一维护两个密码框的水印颜色、字体、边距靠人肉保持一致后期改样式要逐个找。我说难听点这方案适合demo不适合上项目。2.2 继承自定义控件功能全却要动业务代码第二种思路是写一个PlaceholderPasswordBox : PasswordBox在控件内部处理模板和事件。这方案一劳永逸想加什么功能都能加但它的问题在于你需要把项目里所有PasswordBox标签改成local:PlaceholderPasswordBox。如果你的项目已经写了几十个密码框改动量瞬间上去了如果项目里还有样式隐式生效自定义控件的默认样式处理不好就全盘翻车。而且继承这条路把问题搞重了。我们只是想要个提示文案不需要一个新的控件类型。WPF提供的附加属性机制本来就是干这个的——不改变控件类型不侵入业务代码往现有控件上贴新能力。2.3 引入第三方控件库划算不划算一眼看清像MaterialDesignInXAML、HandyControl这些成熟库都自带水印密码框样式也漂亮。项目里已经引了控件库直接用当然省事。但如果项目只是为了一个登录窗口的水印就去引入整个第三方依赖那代价就大了主题包体积、模板覆盖冲突、样式优先级问题、升级兼容性每一项都是隐形成本。我做过一段时间WPF项目最大的体会是为了一个几十行能解决的问题去引一套框架后面还贷的利息往往超出想象。当然如果你本身就在用某个控件库那就没必要自己造轮子直接查库的文档用它的水印密码框即可。3. 最快方案一个附加属性加一套模板改最少的代码先说明这套方案的设计思路。上面分析了Watermark本质上要干两件事提供提示文字并且把它画在密码框内部合适的位置。密码变化时及时同步提示文字的可见性。第1件用重写ControlTemplate来做在模板里加一个TextBlock第2件用附加属性监听PasswordChanged事件来做。二者配合业务侧只需要挂一个附加属性样式全局生效。3.1 附加属性代码30行以内搞定新建一个静态类我一般放在Common/Behavior目录下叫PasswordBoxHelperusing System.Windows; using System.Windows.Controls; public static class PasswordBoxHelper { public static readonly DependencyProperty PlaceholderProperty DependencyProperty.RegisterAttached( Placeholder, typeof(string), typeof(PasswordBoxHelper), new PropertyMetadata(string.Empty, OnPlaceholderChanged)); public static void SetPlaceholder(DependencyObject obj, string value) obj.SetValue(PlaceholderProperty, value); public static string GetPlaceholder(DependencyObject obj) (string)obj.GetValue(PlaceholderProperty); private static void OnPlaceholderChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is PasswordBox passwordBox) { passwordBox.PasswordChanged - OnPasswordChanged; passwordBox.PasswordChanged OnPasswordChanged; UpdatePlaceholderVisibility(passwordBox); } } private static void OnPasswordChanged(object sender, RoutedEventArgs e) { if (sender is PasswordBox passwordBox) UpdatePlaceholderVisibility(passwordBox); } private static void UpdatePlaceholderVisibility(PasswordBox passwordBox) { if (passwordBox.Template null) return; if (passwordBox.Template.FindName(PART_Placeholder, passwordBox) is TextBlock placeholder) { placeholder.Visibility string.IsNullOrEmpty(passwordBox.Password) ? Visibility.Visible : Visibility.Collapsed; } } }这段代码的工作逻辑很直白Placeholder是一个附加依赖属性作用就是把提示文字这个数据挂到任意PasswordBox上。当附加属性被赋值时OnPlaceholderChanged触发做两件事挂上PasswordChanged事件监听器同时立即刷新一次可见性。这样即使PasswordBox已经有密码水印也不会错误显示。密码每次变化UpdatePlaceholderVisibility去模板里找名为PART_Placeholder的TextBlock根据Password是否为空切换可见性。为什么要用FindName去找TextBlock而不是直接把TextBlock引用存下来因为PasswordBox的模板可能被动态切换比如用户切换了主题模板重建了旧的TextBlock引用就失效了。FindName是从当前模板实时查找稳妥得多。3.2 全局Style模板把提示文案放进PasswordBox内部接下来定义一个PasswordBox的全局样式。放在App.xaml的Application.Resources里或者单独建一个ResourceDictionary然后合并进来。两个地方我都试过如果项目本身有多个皮肤建议放独立字典统一管理。Style TargetType{x:Type PasswordBox} Setter PropertyForeground Value#333333/ Setter PropertyBackground ValueWhite/ Setter PropertyBorderBrush Value#CCCCCC/ Setter PropertyBorderThickness Value1/ Setter PropertyPadding Value6,4/ Setter PropertyFontSize Value14/ Setter PropertyVerticalContentAlignment ValueCenter/ Setter PropertyTemplate Setter.Value ControlTemplate TargetType{x:Type PasswordBox} Border x:NameBd Background{TemplateBinding Background} BorderBrush{TemplateBinding BorderBrush} BorderThickness{TemplateBinding BorderThickness} CornerRadius3 SnapsToDevicePixelsTrue Grid TextBlock x:NamePART_Placeholder Text{Binding RelativeSource{RelativeSource TemplatedParent}, Path(local:PasswordBoxHelper.Placeholder)} Foreground#999999 FontSize{TemplateBinding FontSize} Margin6,0,0,0 VerticalAlignmentCenter IsHitTestVisibleFalse VisibilityCollapsed/ ScrollViewer x:NamePART_ContentHost Margin{TemplateBinding Padding} VerticalAlignmentCenter/ /Grid /Border /ControlTemplate /Setter.Value /Setter /Style有个细节需要说明我用了local:PasswordBoxHelper.Placeholder这个绑定路径而不是Tag。虽然用{TemplateBinding Tag}写起来更短但Tag在业务代码里经常被临时存数据把它强行作为水印存储位后续容易跟别的逻辑撞车。用附加属性绑定数据来源清晰谁都知道这个字段是干嘛的。注意命名空间XAML根节点要声明xmlns:localclr-namespace:你的项目名.Common3.3 业务侧用法一行XAML水印生效到了业务代码所有改动就这一句PasswordBox local:PasswordBoxHelper.Placeholder请输入密码 Width260 Height32/效果就是页面加载时显示灰色请输入密码点击输入、密码框里有字符水印立刻消失把密码删光水印又回来。不需要手动给控件命名不需要写事件加一个新的密码框也只是复制一行XAML的事。这个方案最核心的价值是一个样式管全部。全局样式的Template生效范围是项目里所有PasswordBox只要挂上附加属性就有水印不想加水印的密码框不挂附加属性即可两者不冲突。相比装饰层方案靠人肉复制Grid维护成本简直是天壤之别。4. 实测阶段踩过的坑以及对应的补丁4.1 PART_ContentHost不能丢最早我图省事把模板里的ScrollViewer换成了普通的ContentPresenter页面跑起来密码完全无法输入光标也不出现。查了半天才意识到PART_ContentHost是PasswordBox内部约定的承载机制密码录入、光标绘制、IME输入都依赖它。所以模板里必须保留这个x:Name位置要放在Grid中TextBlock的后面保证密码输入层在水印之上。4.2 Tag字段不能乱用该占位就占位用{TemplateBinding Tag}做水印文本的版本我一开始觉得挺好使结果后来业务方要求在登录页的密码框Tag里存一个埋点标志用来区分登录来源。Tag被覆盖以后水印文本当场消失。从那以后我就把所有占位文本改成附加属性绑定了。在此提醒一句凡是能被业务逻辑修改的属性都不适合用来存视觉配置。4.3 焦点、边框、视觉反馈要一起处理重写模板之后PasswordBox自带的默认焦点样式蓝色边框也跟着没了。用户体验上这是个减分项特别是登录页用户根本看不出当前焦点在哪个输入框。我后来在模板里补了触发器ControlTemplate.Triggers Trigger PropertyIsKeyboardFocused ValueTrue Setter TargetNameBd PropertyBorderBrush Value#4A90D9/ Setter TargetNameBd PropertyBorderThickness Value1.5/ /Trigger Trigger PropertyIsEnabled ValueFalse Setter TargetNameBd PropertyOpacity Value0.6/ /Trigger /ControlTemplate.Triggers这样聚焦时边框变蓝、变粗禁用时整体半透明交互反馈才算完整。4.4 程序设置Password时手动刷一次水印PasswordChanged事件只在用户输入、或者通过代码给Password赋值时触发这里有个细节通过代码设置PasswordBox.Password属性时同样会触发PasswordChanged事件所以大部分场景下不需要手动刷新。但有一种情况会漏——PasswordBox在模板尚未加载完成时比如构造函数里就给Password赋值此时Template.FindName返回null水印状态没更新。稳妥做法是在附加属性赋值时调用一次UpdatePlaceholderVisibility我在代码里已经这样做了同时在模板加载完成后也刷新一次。实测下来这个顺序能覆盖绝大多数初始化场景。4.5 模板被第三方主题覆盖时的处理如果项目里用了HandyControl或者MaterialDesign这类主题库这些库通常会给PasswordBox也定义隐式样式优先级和App.xaml里的全局样式撞车时后合并进资源字典的才能生效。排错逻辑很简单确认你的样式是在App.xaml最后合并的再不行就在具体页面的UserControl.Resources里放一份局部样式局部资源的优先级高于全局资源这样最稳妥。做上位机的同学尤其要注意很多工业视觉软件项目会同时引入第三方皮肤库水印样式死活不出来十有八九是资源字典合并顺序的问题。5. 再进一步水印的MVP化与扩展5.1 封装到独立ResourceDictionary全局复用实际项目中我习惯把PasswordBoxHelper的样式抽成独立文件比如Styles/PasswordBoxStyle.xaml然后在App.xaml里合并Application.Resources ResourceDictionary ResourceDictionary.MergedDictionaries ResourceDictionary SourceStyles/PasswordBoxStyle.xaml/ /ResourceDictionary.MergedDictionaries /ResourceDictionary /Application.Resources这样一来整个解决方案里任何窗口、任何用户控件都能直接享用同一个水印样式。哪天产品经理说水印颜色改浅一点你只需要动一个文件。这个维护成本比起几十个Grid到处拷贝差距是数量级的。5.2 对MVVM友好PasswordBoxHelper还有更多玩法Watermark只是PasswordBoxHelper的起点。既然附加属性已经能挂事件在同一个Helper里再扩展别的能力非常顺手。我后来在这个类里又加了一个IsBoundToViewModel相关的辅助方法用PasswordChanged事件同步密码到ViewModel绕开Password不能绑定的老问题public static readonly DependencyProperty BoundPasswordProperty DependencyProperty.RegisterAttached( BoundPassword, typeof(string), typeof(PasswordBoxHelper), new PropertyMetadata(string.Empty, OnBoundPasswordChanged));这个演进路径大家参考注意别把事件重复挂载每次挂载前先解绑一次。5.3 支持显示密码小眼睛的模板思路很多登录场景需要小眼睛按钮切换密码可见性。PasswordBox本身不支持显示明文需要模板里做处理。常规做法是密码框的模板里放两个控件一个真正的PasswordBox接收输入一个TextBox显示明文切换时用Password属性的值去填充TextBox的Text。这个涉及双向同步代码量会上去不少但整体架构还是基于附加属性和模板这套思路并没有突破到新领域。我个人的体会是水印功能别为了炫技去做得太复杂。WPF里很多看似简单的问题背后都有框架机制方面的原因比如PasswordBox的Password不是依赖属性这个设定表面看是限制实际上是在提醒你密码这种安全敏感数据尽量不要暴露在绑定的世界里。所以附加属性监听事件模板留占位这种带一点古老味道的方案反而在这个场景下最合适。改最少代码、达到最好效果就是我在这个项目里最大的收获。