▪ 前言
WPF 内置了丰富的 UI 控件,但是他们都是独立。在实际的开发中,我们经常需要将几个 UI 控件组合起来用。
比如下面 效果图 中的 “账号输入项”:背景白色,左侧 Label控件,右侧 TextBox控件。当然它还有一些变种:背景是透明的,下面有条白线。
为了和 “UI 控件” 名字区分,我们约定将几个 “UI 控件” 组合的统称为 “表单控件项”
▪ 效果图
▪ 原始实现
白色背景的 表单控件项(账号输入项)实现代码:
<DockPanel Width="360" Height="35" Background="#FFFFFFFF">
<Label Width="70" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" DockPanel.Dock="Left">账号:</Label>
<TextBox x:Name="uiUsername" VerticalContentAlignment="Center" BorderThickness="0"></TextBox>
</DockPanel>
透明背景的 表单控件项(账号输入项)实现代码:
<DockPanel Width="360" Height="35" Background="{x:Null}">
<Label Width="70" BorderBrush="White" BorderThickness="0,0,0,1" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" DockPanel.Dock="Left" Foreground="#FFFFFFFF">账号:</Label>
<TextBox x:Name="uiUsername" BorderBrush="White" BorderThickness="0,0,0,1" VerticalContentAlignment="Center" Background="{x:Null}" Foreground="#FFFFFFFF" CaretBrush="#FFFFFFFF"></TextBox>
</DockPanel>
上次代码使用了 DockPanel 容器控件,并设置其内的 Label 控件属性
DockPanel.Dock="Left"
,这样做的主要好处就是 TextBox 空间宽度可以自适应填满整个 DockPanel 的剩余宽度空间
▪ 优化实现
在 原始实现,一个 表单控件项 的实现需要4行代码(每个控件里还有大量的属性设置)。当软件里只有一两个 表单控件项 的时候还可以,但是如果有大量的这些 表单控件项,那么将非常不利于维护。OK,接下来我们将利用 Style 和 ControlTemplate
来优化上述的代码,看看优化后实现上述两个效果的代码:
白色背景的 表单控件项(账号输入项)实现代码:
<TextBox x:Name="uiUsername" Tag="账号:" Width="360" Height="35" Style="{StaticResource styleFormcItemLTB}" Background="White"></TextBox>
透明背景的 表单控件项(账号输入项)实现代码:
<TextBox x:Name="uiUsername" Tag="账号:" Text="{Binding username}" Style="{StaticResource styleFormcItemLTB}" Width="365" Height="35" BorderBrush="White" CaretBrush="White" BorderThickness="0,0,0,1" Foreground="#FFFFFFFF"></TextBox>
只用一行代码我们就实现了上述功能,相比 原始实现 简单了很多。那我们是如何做到的呢?主要功劳还是 Style="{StaticResource styleFormcItemLTB}"
这一句,下面我们最为核心的 styleFormcItemLTB
样式资源的实现代码:
<!-- 表单控件项:Label + TextBox -->
<Style x:Key="styleFormcItemLTB" TargetType="{x:Type TextBox}">
<Setter Property="BorderThickness" Value="0"></Setter>
<Setter Property="VerticalContentAlignment" Value="Center"></Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TextBox}">
<DockPanel Width="{TemplateBinding Width}" Height="{TemplateBinding Height}" Background="{TemplateBinding Background}">
<Label Width="70" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{x:Null}" Foreground="{TemplateBinding Foreground}" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" Content="{TemplateBinding Tag}" DockPanel.Dock="Left"></Label>
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{x:Null}">
<ScrollViewer x:Name="PART_ContentHost" Foreground="{TemplateBinding Foreground}"></ScrollViewer>
</Border>
</DockPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
其实原理很简单,就是把 原始实现 的代码稍作一些修改然后移植到 Style 的 ControlTemplate 中,然后通过 {TemplateBinding ...}
继承 TextBox 的一些属性。
以样式
styleFormcItemLTB
作为基础,通过继承 TextBox 控件里的自定义样式属性值自动设置 表单控件项 内各个控件属性,快速的构建出 表单控件项
这里有个知识点需要注意,代码:
<ScrollViewer x:Name="PART_ContentHost" Foreground="{TemplateBinding Foreground}"></ScrollViewer>
在 ControlTemplate 中我们使用了上述代码,这段代码初学者这可能一下子理解不了。但是如果你把它移除了,你会发现运行软件是输入框没有了,那说明这段代码负责呈现一个输入框的,那能不能直接把它替换为:
<TextBox Foreground="{TemplateBinding Foreground}"><TextBox>
尝试了一下,替换以后可以正常显示输入框,但是在 cs 文件中我们发现无法通过 uiUsername.Text
获取到输入框中的值;很明显,这个代码只实现了展示效果,没有实现一个控件的逻辑,<TextBox Foreground="{TemplateBinding Foreground}"><TextBox>
生成了一个新的 TextBox 控件,没有和 <TextBox x:Name="uiUsername" ...
关联。
通过上面的尝试,我们基本可以确定 <ScrollViewer x:Name="PART_ContentHost" ...
是生成了一个和 <TextBox x:Name="uiUsername"
关联 TextBox,因为你可以在 cs 文件中通过 uiUsername.Text
设置和获取控件的值。
▪ 原理说明
为什么<ScrollViewer x:Name="PART_ContentHost" ...
是生成了一个和 <TextBox x:Name="uiUsername"
关联呢?这其中最为核心的就是 x:Name="PART_ContentHost"
这句话。
我们知道WPF控件是不用有固定形状的,我们可以通过 Style 来任意改变它的具体表现。
但是,控件本身具有特定的逻辑和作用:比如一个按钮,应该是可以点击的;一个输入框控件,应该是可以输入的。
这里就存在了一个矛盾:如果可以任意改变控件的具体表现,那么如何保证它特有的逻辑和作用呢?
答案就是 WPF 控件的 “部件” 概念。简单的说,就是你可以任意改变我,但是要提供我期待的部件,否则我的逻辑和作用就不能得到保证。如果阅读 TextBoxBase 的 MSDN 参考(TextBox 继承于 TextBoxBase),你会看到如下的特性:
[TemplatePartAttribute(Name = "PART_ContentHost", Type = typeof(FrameworkElement))]
[LocalizabilityAttribute(LocalizationCategory.Text)]
public abstract class TextBoxBase : Control
上述代码引用自 https://msdn.microsoft.com/zh-cn/library/system.windows.controls.primitives.textboxbase
该特性表明 TextBoxBase 控件期待你提供一个名字叫 PART_ContentHost
的部件,该部件必须是 FrameworkElement。而 TextBoxBase 将会把具体的 TextView 和 TextEditor 放到 PART_ContentHost 里面。
而能设置 x:Name="PART_ContentHost"
的只有 Decorator 或 ScrollViewer 元素,所以下面的代码就会出错:
// 提示错误:只有 Decorator 或 ScrollViewer 元素可以用作 PART_ContentHost
<Label x:Name="PART_ContentHost" Foreground="{TemplateBinding Foreground}"></Label>
不使用 因为这个该元素不支持
Foreground
属性值
经过测试 也支持
x:Name="PART_ContentHost"
,不过其不支持Foreground
属性值
▪ 潜在问题
上述的代码设计中,我们采用了 TextBox 关联 styleFormcLTB
样式,然后在 TextBox 里设置样式属性值,styleFormcLTB
通过集成这些属性值快速构建出 表单控件项。
后来在使用过程中这种方式会导致 TextBox 禁用状态下时样式无法改变(默认变灰),就算在 styleFormcLTB
设置了 Trigger IsEnabled
也无法改变样式。就感觉 TextBox 控件里的样式属性值优先级高于 styleFormcLTB
里的属性值,优先级低的无法改变优先高级的属性值,没有办法只能基于 styleFormcLTB
样式类在按需做出其他样式类了,重构的代码如下:
白色背景的 表单控件项(账号输入项)实现代码:
<TextBox x:Name="uiUsername" Tag="账号:" Width="360" Height="35" Style="{StaticResource styleFormcItemLTB10}"></TextBox>
透明背景的 表单控件项(账号输入项)实现代码:
<TextBox x:Name="uiUsername" Tag="账号:" Text="{Binding username}" Style="{StaticResource styleFormcItemLTB01}"></TextBox>
<Style x:Key="styleFormcItemLTB01" BasedOn="{StaticResource styleFormcItemLTB}" TargetType="{x:Type TextBox}">
<Setter Property="Background" Value="{x:Null}"></Setter>
<Setter Property="CaretBrush" Value="White"></Setter>
<Setter Property="Foreground" Value="White"></Setter>
<Setter Property="BorderBrush" Value="White"></Setter>
<Setter Property="BorderThickness" Value="0,0,0,1"></Setter>
</Style>
<Style x:Key="styleFormcItemLTB10" BasedOn="{StaticResource styleFormcItemLTB}" TargetType="{x:Type TextBox}">
<Setter Property="Background" Value="White"></Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="#FFF0F0F0"></Setter>
<Setter Property="Foreground" Value="#FF666666"></Setter>
</Trigger>
</Style.Triggers>
</Style>
styleFormcItemLTB01
中的首位 0 表示没有背景,末尾 1 表示没有背景情况下一种样式(下划线),当然也可以自定义 00、02、03、04 …
styleFormcItemLTB10
中的首位 1 表示白色背景,末尾 0 表示白色背景下的一种样式,当然也可以自定义 01、02、03、04 …