使用通用附加属性来减少 WPF 元素自定义样式的多余代码
本文将以 WPFUI(https://gitee.com/dlgcy/WPFUI)项目中的 ComboBox 样式为例,介绍如何使用附加属性来增强和简化样式代码。
一、自定义元素样式的方法
在开发 WPF 应用的过程中,我们常常需要给元素设置样式,其中一种方法是创建自定义样式,套路如下:
在设计器的元素上右键 –> 编辑模板 –> 编辑副本:
选择名称和位置后点击确定即可创建:
创建后的样式如下,还包括一些颜色画刷之类的,还有最重要的 Template 属性中设置的控件模板及其触发器。在这基础上我们就可以大展拳脚,尽情改造了。
二、使用样式继承减少重复代码
先来看看原始代码的情况:
可以看到除了一些公用的代码外,主要给 ComboBox 提供了五个样式,五个样式之间就是颜色的差别,但是注意看前面的行号,每个样式还是都占用了大概 70 行,实际上其中很多代码是重复的,不相信的朋友可以亲自下载代码看看。
算了,还是我演示给大家看看吧,使用对比工具对比 PrimaryBox 和 SuccessBox 两个样式,可以看到除了三处颜色设置不同,其余代码都是重复的。三处颜色的不同,两处在普通属性设置区,一处在控件模板的触发器区,这个后面需要区别对待。
对于普通属性区的重复,都不需要用到附加属性,直接一个继承就能解决了。可以再建一个基础样式,我这里直接把 PrimaryBox 当作基础样式,其余四个继承它即可。以 SuccessBox 为例,继承之后如下:
可以看到,继承之后,普通属性设置区与基类样式相同的内容已经变灰了(Resharper 的功能),可以直接删除。由于模板属性(Template)中有一丁点的不同(前面说的那个颜色不同),导致整个模板设置都没有变灰,也就是暂时还不能删除。
三、通用附加属性代理类
接下来就是如何解决模板属性(Template)中的重复代码的问题了。
在继续之前,先来看看我之前为了让一个样式用于多个场景 —— 也就是让控件模板中的相关属性能在元素上进行设置 —— 是怎么做的吧。其实针对这种需求,有另一个做法:创建一个用户控件来继承这个元素,样式设置及最终使用都改为这个用户控件,然后需要新增设置的属性就在用户控件后台创建依赖属性。当时因为一是项目中不推荐为了这种情况创建用户控件,二是偷懒,三是对附加属性理解还不够没有想到用它,所以最终我是借用了元素(这里是 Button)自有的偏门的样式中暂未使用到的属性来传递需要的值的。比如为了设置圆角,我约定了使用 Button 的 TabIndex,然后控件模板中绑定给 Border 的 CornerRadius,并使用了 ObjectToIntConverter 转换器。还有其它几项也是这样:
这个方案,怎么说呢,虽然能达到功能,但是缺点是显而易见的,而且不止一个:
1、方案非常规,使用别扭,如果不看样式上方的注释根本不知道怎么使用。
2、绑定不够直接,借用的属性类型往往与最终类型不同,需要加转换器。
3、占用原有属性,因为一旦被借用了,就不能用于原来的用途了,万一其它同事在使用的地方按照原意来使用这个被借用的属性,就会闹出笑话。
4、可被借用的属性数量有限,有可能满足不了需要个性化设置的地方数量。
5、等等……
后来某一天,我突然灵光乍现,想到可以创建一个通用的附加属性代理类(或者说是辅助类),来满足这种场景。其实如果去学习一些开源控件库,应该早就能发现这种用法了(后来在看 AIStudio.Wpf.Controls 的代码时验证了确实有这样用的),可惜没有如果,不过现在知道也不迟。
创建方法也很简单,随便建一个类(我这里是 WpfXamlPropProxy),让它继承 DependencyObject(实际上是不需要的),然后在里面创建你需要的类型的附加属性即可。我这里建了圆角(CornerRadius)、边框粗细(BorderThickness)、鼠标移上的背景色(MouseOverBackground)三个附加属性,名称也是通用的:
如果需要意义更明确,可以选择针对某个元素建立专用的代理类(比如 MahApps 的 TextBoxHelper.Watermark 这种的)
另外,附加属性的创建方法为,输入 propa 然后按两下 Tab 键插入代码片段:
创建好了附加属性代理类,那么怎么使用呢?
首先,需要引入命名空间:
xmlns:attached="clr-namespace:WPFTemplateLib.Attached;assembly=WPFTemplateLib"
然后像前面那种借用元素自身属性的方案那样,只不过将那些属性替换为这个代理类中的属性即可,其实道理是一样的,附加属性也是依赖属性,只不过可以附加给别人罢了。这里有一个设置圆角的例子:
这里样式中绑定了 WpfXamlPropProxy.CornerRadius,默认值为 5,在元素或者子样式中就可以对其更换为其它的值:
四、使用附加属性让控件模板可共用
上一节介绍的使用通用的附加属性只是能够丰富可配置的内容,并没有减少样式代码,因为样式中的普通属性设置区,通过样式继承已经能够减少冗余了(见第二节),现在的关键是,如何去除样式中模板设置区的重复代码。答案还是使用附加属性,只不过不能直接使用,需要采用一种迂回的方法,接下来就介绍给大家,当然,如果大家有更好的方法,欢迎讨论。
在发现这个方法的过程中也走了些弯路,先来看看遇到的问题吧。
4.1、问题:给触发器中要设定的值绑定附加属性没效果
现象:在元素样式的控件模板的 Triggers 中,在某个 Trigger 的某个 Setter 的 Value 中想绑定样式中设置的某个附加属性,结果提示找不到该属性(已解决,见 6.2):
其它错误示范:如果在 Trigger(的 Setter)中直接使用 TemplateBinding,则直接会报错(不是有效值):
网上的讨论:
关于 wpf:具有附加属性的模板绑定 | 码农家园 (codenong.com)
附加属性上的 WPF 触发器不起作用 – IT 工具网 (coder.work)
4.2、方法:使用代理元素在触发器中绑定附加属性
[2024 年 11 月 3 日] 此方法已不需要,见 6.2 节。
解决方法:在控件模板中添加一个隐藏的 “代理元素”,让它的某个合适的属性来绑定那个附加属性,然后在 Trigger 中再绑定这个代理元素的那个属性:
本次这个 ComboBox 的也是同样的操作:
示例代码地址:https://gitee.com/dlgcy/WPFTemplateLib/blob/master/Styles/DictionaryComboBox.xaml
五、效果展示
搞定了 Template 中的附加属性绑定问题后,子样式中的整个 Template 部分和主样式也就相同了,也就可以删除了。
所以最终的效果是很显著的,除了主样式的代码行数和之前差不多外,其余四个样式都只剩下区区几行了:
效果如下:
Demo 源码地址:https://gitee.com/dlgcy/DLGCY_WPFPractice/tree/Blog20221107
全文完,感谢阅读。
六、更新
6.1、2024年8月15日
可以通过安装 NuGet 包 “WPFTemplateLib” 来使用:
6.2、2024 年 11 月 3 日
在控件模板或者触发器中,以 {Binding (attached:WpfXamlPropProxy.ContentPartMargin), RelativeSource={RelativeSource TemplatedParent}}
的形式(省略了)绑定附加属性,一般情况下是可以的,但有的时候不行,会有绑定错误,此时可尝试加上 Path=
可能就可以了(我看 4.1 中第一张图中我也是这样绑定的,不知道为什么当时不行):
所以可以去掉多余的绑定代理元素了:
发表评论