理解逻辑树和可视化树
下图显示了一个非常简单的窗口,该窗口包含两个按钮。为创建该窗口,在窗口中嵌套了一个StackPanel控件。在StackPaenl控件中,放置了两个Button控件,并且在每个按钮中可以添加所选择的内容
1 2 3 4 <StackPanel Margin ="5" > <Button Margin ="5" Padding ="5" > First Button</Button > <Button Margin ="5" Padding ="5" > Second Button</Button > </StackPanel >
添加的元素分类成为逻辑树,下图显示了逻辑树。WPF编程人员需要耗费大部分时间构建逻辑树,然后使用事件处理代码支持它们。实际上,到目前为止介绍的所有WPF特性(如属性值继承、事件路由以及样式)都是通过逻辑树进行工作的。
简单窗口的逻辑树
然而,如果希望自定义元素,逻辑树起不到多大帮助作用。显然,可使用另一个元素替换整个元素(例如,可使用自定义的 FancyButton 类替换当前的 Buton 类),但这需要做更多工作,并且可能扰乱应用程序的用户界面或代码。因此,WPF通过可视化树进入更深层次 。
可视化树是逻辑树的扩展版本。它将元素分成更小的部分。换句话说,它并不查看被精心封装到一起的黑色方框,如按钮,而是查看按钮的可视化元素–使按钮具有阴影背景特性的边框(由 ButtonChrome 类表示)、内部的容器(ContentPresenter 对象)以及存储按钮文本的块(由大家熟悉的 TextBlock 表示)。下图显示了上例的可视化树。
简单窗口的可视化树
所有这些细节本身都是元素——换句话说,控件(如按钮)中的每个单独的细节都是由FrameworkElement类的派生类表示的。
可采用多种方法将一棵逻辑树扩展成一棵可视化树,认识到这一点是很重要的。使用的样式和设置的属性等细节都可能影响可视化树的构成。
通过可视化树可以完成以下两项非常有用的工作:
可使用样式改变可视化树中的元素。可使用Style.TargetType 属性选择希望修改的特定元素。甚至当控件属性发生变化时,可使用触发器自动完成更改。不过,某些特定的细节很难甚至无法修改。
可为控件创建新模板。对于这种情况,控件模板将被用于按期望的方式构建可视化树。
WPF提供了用于浏览逻辑树和可视化树的两个类:System.Windows.LogicalTreeHelper
和System.Windows.Media.VisualTreeHelper
LogicalTreeHelper 类,该类允许通过动态加载的XAML文档在WPF应用程序中关联事件处理程序,LogicTreeHelper
类提供了较少的方法,下表列出了这些方法,尽管这些方法偶尔很有用,但大多数情况下会改用特定的FrameworkElement类中的方法。
LogicalTreeHelper类的方法
名称
说明
FindLogicalNode()
根据名称查找特定元素,从指定的元素开始并向下查找逻辑树
BringIntoView()
如果元素在可滚动的容器中,并且当前不可见,就将元素滚动到视图中。FrameworkElement.BegIntoView()
方法执行相同的工作
GetParent()
获取指定元素的父元素
GetChildren()
获取指定元素的子元素。不同元素支持不同的内容型。例如,面板支持多个子元素,而内容控件只支持一个子元素。然而,GetChildren()方法抽象了这一区别,并且可使用任何类型的元素进行工作
VisualTreeHelper
类提供的方法于LogicalTreeHelper
类提供的方法类似,也提供了GetChildrenCount()
、GetChild()
以及GetParent()
方法
VisualTreeHelper 类还提供了一种研究应用程序中可视化树的有趣方法。使用 GetChild()方法,可以遍历任意窗口的可视化树,并且为了进行分析可以将它们显示出来。这是一个非常好的学习工具,只需要使用一些递归的代码就可以实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public partial class VisualTreeDisplay : Window { public VisualTreeDisplay () { InitializeComponent(); } public void ShowVisualTree (DependencyObject element ) { treeElements.Items.Clear(); ProcessElement(element, null ); } private void ProcessElement (DependencyObject element, TreeViewItem previousItem ) { TreeViewItem item = new TreeViewItem(); item.Header = element.GetType().Name; item.IsExpanded = true ; if (previousItem == null ) { treeElements.Items.Add(item); } else { previousItem.Items.Add(item); } for (int i = 0 ; i < VisualTreeHelper.GetChildrenCount(element); i++) { ProcessElement(VisualTreeHelper.GetChild(element, i), item); } } }
一旦为项目添加这棵树,就可以使用其他任何窗口的代码显示其可视化树:
1 2 3 VisualTreeDisplay treeDisplay = new VisualTreeDisplay(); treeDisplay.ShowVisualTree(this ); treeDisplay.Show();
理解模板
控件如何从逻辑树表示扩展成可视化树表示?
每隔控件都有一个内置的方法,用于确定如何渲染控件(作为一组更基础的元素)。该方法称为控件模板(Control Template
),是用XAML标记块定义的。
下面是普通Button
类的模板的简化版本。该版本省略了XML名车行空间声明、为嵌套的元素设置属性的特性,以及当按钮被禁用、取得焦点或单击时确定按钮行为的触发器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 <Window xmlns:themes ="clr-namespace:Microsoft.Windows.Themes;assembly=PresentationFramework.Aero" > <Button Content ="Button" > <Button.Template > <ControlTemplate TargetType ="{x:Type Button}" > <themes:ButtonChrome x:Name ="Chrome" > <ContentPresenter HorizontalAlignment ="Center" VerticalAlignment ="Center" Content ="{TemplateBinding ContentControl.Content}" /> </themes:ButtonChrome > <ControlTemplate.Triggers > <Trigger Property ="UIElement.IsKeyboardFocused" Value ="True" > <Setter TargetName ="Chrome" Property ="themes:ButtonChrome.RenderDefaulted" Value ="True" /> </Trigger > <Trigger Property ="ToggleButton.IsChecked" Value ="True" > <Setter TargetName ="Chrome" Property ="themes:ButtonChrome.RenderPressed" Value ="True" /> </Trigger > <Trigger Property ="UIElement.IsEnabled" Value ="False" > <Setter Property ="TextElement.Foreground" Value ="#FFADADAD" /> </Trigger > </ControlTemplate.Triggers > </ControlTemplate > </Button.Template > </Button > </Window >
尽管尚未研究ButtonChrome
和ContentPresenter
类,但很容易就能联想到:控件模板提供了在可视化树中看到的扩展内容。ButtonChrome
类定义按钮的标准可视化外观,而ContentPresenter
类存储了提供的所有内容。如果希望构建全新按钮,只需要创建新的控件模板。除ButtonChrome
类之外,还使用了其他一些内容——可能是自定义元素,也可能某个绘制形状的元素。
ButtonChrome
类继承自Decorator
类(于Border
类非常类似)。这意味着这些类是为了在其他元素周围添加图形装饰而设计的——用于在按钮内容的周围添加图形装饰
当构建自己的控件模板时将看到同样的职责分离。如果足够幸运,可直接使用触发器完成所有工作,可能不需要创建自定义类并添加代码。另一方面,如果需要提供更复杂的可视化设计,可能需要继承自定义的修饰类。ButtonChrome类本身不提供定制功能–该类专门用于渲染标准按钮的特定主题外观。
所有的模板都继承自FrameworkTeamplate
基类,模板类型除了控件模板(ControlTeamplate
)之外,还有数据模板(DataTeamplate
和HierarchicalDataTeamplate
),以及很特殊的用于ItemsControl
控件的面板模板(由ItemsPanTeamplate
类表示)
修饰类
ButtonChrome
类是在Microsoft.Windows.Themes 名称空间中定义的,该名称空间中包含了一些较少的彼此相似的类,这些类用于渲染基本的Windows细节。除ButtonChrome
外,这些类还包括BulletChrome
(用于复选框和单选按钮)、ScrollChrome
(用于滚动条)、ListBoxChrome
以及SystemDropShadowChrome
。这是最低级别的公有控件API。在稍高级别上,您会发现System.Windows.Controls.Primitives
名称空间中包含大量可以独立使用的基本元素,但它们通常被封装到更有用的控件中。这些元素包括ScrollBar
、ResizeGrip
(用于改变窗口的尺寸)、Thumb
(滚动条上的拖动按钮)、TickBar
(滑动条上可选的刻度设置)等。在本质上,System.Windows.Controls.Primitives
名称空间提供了可用在各种控件中的基本要素,本身的作用不大,而Microsoft.Windows.Themes
名称空间包含了用于渲染这些细节的基本绘图逻辑。
还有一点区别。与大多数 WPF 类型一样,System,Windows.Controls.Primitives 名称空间中的类型都是在 PresentationFramework.dll 程序集中定义的。然而,Microsof, Windows.Themes 名称空间中的类型是在三个不同的程序集中定义的:PresentationFramework.Aero.dll、PresentationFramework,Luna,dl! 和 PresentationFramework.Royale.dl。每个程序集都包含自己的ButtonChrome 类(以及其他修饰类)版本,这些版本的渲染逻辑稍有不同。WPF 使用哪个程序集取决于操作系统和主题设置。
尽管控件模板经常使用修饰类进行绘图,但并非总需要这样。例如,ResizeGrip 元素(该元素用于在可以改变尺寸的窗口的右下角创建点网格)非常简单,它的模板可使用之前学习的绘图类,如 Path、DrawingBrush 以及 LinearGradientBrush。下面是其使用的标记(有些复杂):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 <ResizeGrip > <ResizeGrip.Template > <ControlTemplate TargetType ="{x:Type ResizeGrip}" > <Grid Background ="{TemplateBinding Panel.Background}" SnapsToDevicePixels ="True" > <Path Margin ="0,0,2,2" HorizontalAlignment ="Right" VerticalAlignment ="Bottom" Data ="M9,0L11,0 11,11 0,11 0,9 3,9 3,6 6,6 6,3 9,3z" > <Path.Fill > <DrawingBrush TileMode ="Tile" Viewbox ="0 0 3 3" ViewboxUnits ="Absolute" Viewport ="0 0 3 3" ViewportUnits ="Absolute" > <DrawingBrush.Drawing > <DrawingGroup > <DrawingGroup.Children > <GeometryDrawing Geometry ="M0,0L2,0 2,2 0,2z" > <GeometryDrawing.Brush > <LinearGradientBrush StartPoint ="0,0.25" EndPoint ="1,0.75" > <GradientStop Offset ="0.3" Color ="#FFFFFFFF" /> <GradientStop Offset ="0.75" Color ="#FFBBC5D7" /> <GradientStop Offset ="1" Color ="#FF6D83A9" /> </LinearGradientBrush > </GeometryDrawing.Brush > </GeometryDrawing > </DrawingGroup.Children > </DrawingGroup > </DrawingBrush.Drawing > </DrawingBrush > </Path.Fill > </Path > </Grid > </ControlTemplate > </ResizeGrip.Template > </ResizeGrip >
剖析控件
当创建控件模板时,新建的模板完全代替了原来的的模板。这样可以得到更大的灵活性,但更复杂。大多数情况下,在创建满足自己需求的模板之前,需要查看控件使用的标准模板。在某些情况下,自定义的控件模板可镜像标准模板,并只进行很少的修改。
WPF文档没有列出标准控件模板的XAML。然而,可通过编程获取所需的信息。基本思想是从Template
属性(该属性在Control
类中定义)获取控件的模板,然后使用XamlWriter
类,将该模板串行化到XAML文件中。
MainWindow.xaml MainWindow.xaml.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <Grid Name ="grid" Margin ="10" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="*" /> <ColumnDefinition Width ="3*" /> </Grid.ColumnDefinitions > <ListBox Name ="lstType" DisplayMemberPath ="Name" SelectionChanged ="lstTypes_SelectionChanged" /> <TextBox Name ="txtTemplate" Grid.Column ="1" FontFamily ="Consolas" TextWrapping ="Wrap" VerticalScrollBarVisibility ="Visible" /> </Grid >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 public partial class MainWindow : Window { public MainWindow () { InitializeComponent(); this .Loaded += Window_Loaded; } private void Window_Loaded (object sender, RoutedEventArgs e ) { Type controlType = typeof (Control); List<Type> derivedTypes = new List<Type>(); Assembly assembly = Assembly.GetAssembly(typeof (Control)); foreach (Type type in assembly.GetTypes()) { if (type.IsSubclassOf(controlType) && !type.IsAbstract && type.IsPublic) { derivedTypes.Add(type); } } derivedTypes.Sort(new TypeComparer()); lstType.ItemsSource = derivedTypes; } private void lstTypes_SelectionChanged (object sender, SelectionChangedEventArgs e ) { try { Type type = (Type)lstType.SelectedItem; ConstructorInfo info = type.GetConstructor(System.Type.EmptyTypes); Control control = (Control)info.Invoke(null ); Window win = control as Window; if (win != null ) { win.WindowState = System.Windows.WindowState.Minimized; win.ShowInTaskbar = false ; win.Show(); } else { control.Visibility = Visibility.Collapsed; grid.Children.Add(control); } ControlTemplate template = control.Template; XmlWriterSettings settings = new XmlWriterSettings(); settings.Indent = true ; StringBuilder sb = new StringBuilder(); XmlWriter writer = XmlWriter.Create(sb, settings); System.Windows.Markup.XamlWriter.Save(template, writer); txtTemplate.Text = sb.ToString(); if (win != null ) { win.Close(); } else { grid.Children.Remove(control); } } catch (Exception err) { txtTemplate.Text = "<< Error generating template:" + err.Message + ">>" ; ; } } } public class TypeComparer : IComparer <Type >{ public int Compare (Type x, Type y ) { return x.Name.CompareTo(y.Name); } }
构建该应用程序的诀窍是使用反射(reflection),反射是用于检查类型的.NETAPI。当第一次加载应用程序的主窗口时,扫描PresentationFramework.dll 核心程序集(在该程序集中定义了控件类)中的所有类型。然后将这些类型添加到一个集合中,根据类型名称进行排序,此后将该集合绑定到一个列表。
无论何时从列表中选择控件,相应的控件模板都会显示在右边的文本框中。完成这一步需要做更多的工作。第一个挑战是,在窗口中实际显示控件之前,控件的模板为空。通过使用反射,代码试图创建控件的一个实例,并将它添加到当前窗口中(但可将 Visibility设置为Collapse,使控件不可见) 。**第二个挑战是,将现存的 ControlTemplete 对象转换为大家熟悉的 XAML 标记。XamlWriter.Save()静态方法负责完成该任务,但代码使用 XamlWriter 和 XamlWriterSettings 对象以确保 XAML缩进合理,便于阅读。**所有这些代码都被封装在异常处理块中,异常处理块监视不能被创建或不能添加到 Grid 网格(如另一个 Window 或 Page)中的控件产生的问题:
扩展该应用程序,从而在文本框中编辑模板,使用XamIReader将模板转换回ControlTemplate对象,然后指定给某个控件并观察效果,这并不是很困难。然而,通过将模板放置到真实窗口中进行实际操作,测试和改进它们会更加容易
创建控件模板
基本 Button 控件使用 ButtonChrome 类绘制其特殊的背景和边框。Button类使用 ButtonChrome 类而不使用 WPF 绘图图元的一个原因是,标准按钮的外观依赖于几个明显的特征(是否被禁用、是否具有焦点以及是否正在被单击)和其他一些更微妙的因素(如当前Windows 主题)。只使用触发器实现这类逻辑是笨拙的。
然而,当构建自定义控件时,可以不用担心标准化和主题集成(实际上,WPF不像以前的用户界面技术那样强调用户界面标准化
)。反而更需要关注如何创建富有吸引力的新颖控件,并将它们混合到用户界面的其他部分。因此,可能不需要创建诸如ButtonChrome的类,而可使用已经学过的元素(可使用绘图元素和动画技巧),设计自给自足的不使用代码的控件模板。
简单按钮
为应用自定义控件模板,只需要设置控件的Template
属性。尽管可定义内联模板(通过在控件标签内部嵌入控件模板标签),但这种方法基本没有意义。这是因为几乎总是希望未同一控件的多个皮肤实例重用模板。为适应这种设计,需要将控件模板定义为资源,并使用StaticResource
引用该资源,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <Window.Resources > <ControlTemplate x:Key ="ButtonTemplate" TargetType ="{x:Type Button}" > <Border Background ="#E3D4F5" BorderBrush ="Red" BorderThickness ="1" CornerRadius ="5" > <ContentPresenter HorizontalAlignment ="Center" VerticalAlignment ="Center" RecognizesAccessKey ="True" /> </Border > </ControlTemplate > </Window.Resources > <StackPanel > <Button Height ="50" Click ="Button_Click" Template ="{StaticResource ButtonTemplate}" > Click_Me </Button > </StackPanel >
通过这种方法,不仅可以较容易地创建许多自定义按钮,在以后还可以很灵活地修改控件模板,而不会扰乱应用程序用户界面的其余部分。
该 ContentPresenter 元素将 RecognizesAccessKey 属性设置为 true。尽管这不是必需的,但可确保按钮支持访问键——具有下划线的字母,可以使用该字母快速触发按钮。对于这种情况,如果按钮具有文本 Click_Me,那么当用户按下 Alt+M 组合键时会触发按钮(在标准的 Windows设置中,下划线是隐藏的,并且只要按下了 Alt 键,访问键(在此是 M 键)就会具有下划线)。如果未将RecognizesAccessKey属性设置为true,就会忽略该细节,并且任何下划线都将被视为普通的下划线,并作为按钮内容的一部分进行显示。
如果控件继承自 ContentControl类,其模板将包含一个 ContentPresenter 元素,指示将在何处放置内容。 **如果控件继承自 ItemsControl类,其模板将包含一个 ItemsPresenter 元素,指示在何处放置包含列表项的面板。**在极少数情况下,控件可能使用这些类的派生版本–例如,ScrollViewer 的控件模板使用继承自ContentPresenter 类的 ScrollContentPresenter 类
模板绑定
通过使用它模板绑定,模板可从应用模板的控件中提取一个值。在本例中,可使用模板绑定检索Padding
属性的值,并使用该属性值在ContentPresenter
元素周围创建外边距。
这样就会得到所期望的效果,在边框和内容之间添加一些空白
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <Window.Resources > <ControlTemplate x:Key ="ButtonTemplate" TargetType ="{x:Type Button}" > <Border Margin ="{TemplateBinding Padding}" Background ="#E3D4F5" BorderBrush ="Red" BorderThickness ="1" CornerRadius ="5" > <ContentPresenter HorizontalAlignment ="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment ="{TemplateBinding VerticalContentAlignment}" RecognizesAccessKey ="True" /> </Border > </ControlTemplate > </Window.Resources > <StackPanel > <Button Height ="50" Padding ="10" Click ="Button_Click" Template ="{StaticResource ButtonTemplate}" > Click_Me </Button > </StackPanel >
模板绑定和普通的数据绑定类似,但它们的量级更轻,因为它们是专门只支持单向数据绑定(换句话说,它们可从控件想模板传递信息,但不能从模板向控件传递信息) ,并且不能用于从Freezable
类的派生类的属性中提取信息。如果遇到模板绑定不生效的情形,可改用具有完整功能的数据绑定
模板绑定支持 WPF 的变化监测基础结构,所有依赖项属性都包含该基础结构。这意味着如果修改了控件的属性,模板会自动考虑该变化。当使用在一小段时间内重复改变属性值的动画时,该细节尤其有用。
从技术角度该,ContentPresenter
元素之所以能够工作,是因为它有一个模板绑定——用于将ContentPresenter.Content
属性设置为Button.Content
属性。然而该绑定是隐式的,所以不必自行添加
可能选择避免模板绑定的另一个原因是–您的控件不能很好地支持它们。例如,如果为按钮设置了Background 属性,可能注意到当按钮被按下时不会连贯地处理该背景色(实际上,这时该背景色消失了,并且被按下按钮的默认外观替换了)。该例中的自定义模板与此类似,尽管还没有任何鼠标悬停和鼠标单击行为,但一旦添加这些细节,就会希望完全控制按钮的颜色以及在不同状态下它们的变化方式。
改变属性的触发器
如果测试在上一节中创建的按钮,就会发现它令人十分失望。本质上,它不过是一个红色的圆角矩形–当在它上面移动鼠标或单击鼠标时,其外观没有任何反应。按钮只是无动于衷,呆在那儿不动。因此可通过为控件模板添加触发器来方便地解决这个问题。
通过Trigger直接改变颜色需要手动设置多个颜色
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 <Window.Resources > <ControlTemplate x:Key ="ButtonTemplate" TargetType ="{x:Type Button}" > <Border x:Name ="border" Margin ="{TemplateBinding Padding}" Background ="#E3D4F5" BorderBrush ="Red" BorderThickness ="1" CornerRadius ="5" > <ContentPresenter HorizontalAlignment ="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment ="{TemplateBinding VerticalContentAlignment}" RecognizesAccessKey ="True" /> </Border > <ControlTemplate.Triggers > <Trigger Property ="IsMouseOver" Value ="True" > <Setter Property ="TextBlock.Foreground" Value ="White" /> <Setter TargetName ="border" Property ="Background" Value ="DarkRed" /> </Trigger > <Trigger Property ="IsPressed" Value ="True" > <Setter Property ="TextBlock.Foreground" Value ="White" /> <Setter TargetName ="border" Property ="Background" Value ="IndianRed" /> <Setter TargetName ="border" Property ="BorderBrush" Value ="DarkKhaki" /> </Trigger > </ControlTemplate.Triggers > </ControlTemplate > </Window.Resources > <StackPanel > <Button Height ="50" Padding ="10" Click ="Button_Click" Template ="{StaticResource ButtonTemplate}" > Click_Me </Button > </StackPanel >
通过改变Opacity
只需要设置单个颜色即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 <Window.Resources > <ControlTemplate x:Key ="ButtonTemplate" TargetType ="{x:Type Button}" > <Border x:Name ="border" > <Border Margin ="{TemplateBinding Padding}" Background ="#E3D4F5" BorderBrush ="Red" BorderThickness ="1" CornerRadius ="5" > <ContentPresenter HorizontalAlignment ="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment ="{TemplateBinding VerticalContentAlignment}" RecognizesAccessKey ="True" /> </Border > </Border > <ControlTemplate.Triggers > <Trigger Property ="IsMouseOver" Value ="True" > <Setter TargetName ="border" Property ="Opacity" Value ="0.7" /> </Trigger > <Trigger Property ="IsPressed" Value ="True" > <Setter TargetName ="border" Property ="Opacity" Value ="0.5" /> </Trigger > </ControlTemplate.Triggers > </ControlTemplate > </Window.Resources > <StackPanel > <Button Height ="50" Padding ="10" Click ="Button_Click" Template ="{StaticResource ButtonTemplate}" > Click_Me </Button > </StackPanel >
为使该模板能够工作,还要进行另一项修改。已为 Border 元素指定一个名称,并且该名称被用于设置每个设置器的 TargetName 属性。通过这种方法,设置器能更新在模板中指定的Border 元素的 Background 和 BorderBrush属性。使用名称是确保更新模板特定部分的最容易方法。可创建一条元素类型规则来影响所有 Border 元素(原因是已经知道在按钮模板中只有一个边框),但如果在以后改变模板,这种方法更清晰,也更灵活。
在所有按钮(以及其他大部分控件)中还需要另一个元素——焦点指示器 。虽然无法改变现有的边框以添加焦点效果,但是可以很容易地添加另一个元素以显示是否具有焦点,并且可以简单地使用触发器根据 Button.IsKeyboardFocused
属性显示或隐藏该元素。尽管可使用许多方法创建焦点效果,但下面的示例只添加了一个具有虚线边框的透明的 Rectangle 元素。Rectangle 元素不能包含子内容,从而需要确保 Rectangle 元素和其余内容相互重叠。完成该操作最容易的方法是使用只有一个单元格的 Grid 控件来封装 Rectangle 元素和 ContentPresenter 元素,这两个元素位于同一个单元格中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 <Window.Resources > <ControlTemplate x:Key ="ButtonTemplate" TargetType ="{x:Type Button}" > <Border x:Name ="border" > <Border Margin ="{TemplateBinding Padding}" Background ="#E3D4F5" BorderBrush ="Red" BorderThickness ="1" CornerRadius ="5" > <Grid > <Rectangle x:Name ="focusCue" SnapsToDevicePixels ="True" Stroke ="Black" StrokeDashArray ="1 2" StrokeThickness ="1" Visibility ="Hidden" /> <ContentPresenter HorizontalAlignment ="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment ="{TemplateBinding VerticalContentAlignment}" RecognizesAccessKey ="True" /> </Grid > </Border > </Border > <ControlTemplate.Triggers > <Trigger Property ="IsMouseOver" Value ="True" > <Setter TargetName ="border" Property ="Opacity" Value ="0.7" /> </Trigger > <Trigger Property ="IsPressed" Value ="True" > <Setter TargetName ="border" Property ="Opacity" Value ="0.5" /> </Trigger > <Trigger Property ="IsKeyboardFocused" Value ="True" > <Setter TargetName ="focusCue" Property ="Visibility" Value ="Visible" /> </Trigger > <Trigger Property ="IsEnabled" Value ="False" > <Setter TargetName ="border" Property ="Background" Value ="Gray" /> </Trigger > </ControlTemplate.Triggers > </ControlTemplate > </Window.Resources > <StackPanel > <Button Height ="50" Padding ="10" Click ="Button_Click" Template ="{StaticResource ButtonTemplate}" > Click_Me </Button > </StackPanel >
**为确保该规则优先于其他相冲突的触发器设置,应当在触发器列表的末尾定义它。**这样,不管 IsMouseOver 属性是否为 true,IsEnabled 属性触发器都具有优先权,并且按钮保持未激活状态的外观。
模板和样式有类似之处。通常,在整个应用程序中,这两个特性都可以改变元素的外观。然而,样式被限制在一个小得多的范围之内。它们可调整控件的属性,但不能使用全新的由不同元素组成的可视化树替代控件原来的外观。
使用动画的触发器
触发器并非局限于设置属性。当特定属性发生变化时,还可以使用时间触发器运行动画。时间触发器允许控件更流畅地一点点从一个状态改变到另一个状态,从而进一步润色其外观。
下面是重新设计的按钮模板,当鼠标悬停在按钮上时,该模板使用触发器实现按钮颜色脉冲效果(在红色和蓝色之间不断切换)。当鼠标离开时,使用一个单独的持续1秒的动画,将按钮背景返回到其正常颜色:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 <Window.Resources > <ControlTemplate x:Key ="ButtonTemplate" TargetType ="{x:Type Button}" > <Border x:Name ="border" Margin ="{TemplateBinding Padding}" Background ="#E3D4F5" BorderBrush ="Red" BorderThickness ="1" CornerRadius ="5" > <Grid > <Rectangle x:Name ="focusCue" SnapsToDevicePixels ="True" Stroke ="Black" StrokeDashArray ="1 2" StrokeThickness ="1" Visibility ="Hidden" /> <ContentPresenter HorizontalAlignment ="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment ="{TemplateBinding VerticalContentAlignment}" RecognizesAccessKey ="True" /> </Grid > </Border > <ControlTemplate.Triggers > <EventTrigger RoutedEvent ="MouseEnter" > <BeginStoryboard > <Storyboard > <ColorAnimation AutoReverse ="True" RepeatBehavior ="Forever" Storyboard.TargetName ="border" Storyboard.TargetProperty ="Background.Color" To ="Blue" Duration ="0:0:0.1" /> </Storyboard > </BeginStoryboard > </EventTrigger > <EventTrigger RoutedEvent ="MouseLeave" > <BeginStoryboard > <Storyboard > <ColorAnimation Storyboard.TargetName ="border" Storyboard.TargetProperty ="Background.Color" Duration ="0:0:0.5" /> </Storyboard > </BeginStoryboard > </EventTrigger > </ControlTemplate.Triggers > </ControlTemplate > </Window.Resources > <StackPanel > <Button Height ="50" Padding ="10" Click ="Button_Click" Template ="{StaticResource ButtonTemplate}" > Click_Me </Button > </StackPanel >
可使用两种等价的方法添加鼠标悬停动画–创建响应MouseEnter和 MouseLeave 事件的事件触发器(正如在此所演示的),或创建当IsMouseOver属性发生变化时添加进入和退出动作的属性触发器。
组织模板资源
当使用控件模板时,需要决定如何更广泛地共享模板,以及是否希望自动地明确地应用模板。大多数情况下,控件模板应用于多个窗口,甚至可能应用于整个应用程序,为避免多次定义模板可在Application
类的Resources
集合中定义模板资源
然而,为此需要考虑另一个事项。通常,控件模板在多个应用程序之间共享。单个应用程序很有可能使用单独开发的模板。然而,一个应用程序只能有一个App.xaml 文件和一个Application.Resources集合。**因此,在单独资源字典中定义资源是一个更好的主意。这样,可灵活地在特定窗口或在整个应用程序中使用资源。而且还可以结合使用样式,因为任何应用程序都可以包含多个资源字典。**为在 Visual Studio 中添加资源字典,在 Solution Explorer 窗口中右击项目,选择Add|NewItem菜单项,然后选择Resource Dictionary(WPF)模板。
虽然可将所有模板组合到单个资源字典文件中,但富有经验的开发人员更愿意为每个控件模板创建单独的资源字典。这是因为控件模板可能会很快变得过于复杂,并可能需要使用其他相关资源。将它们保存在一个单独的地方,并与其他控件相隔离,是一种很好的组织方式
为使用资源字典,只需要将它们添加到特定窗口或应用程序(这种情况更常见)的Resources
集合中。可使用MergedDictionaries
集合完成该工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <Application x:Class ="WpfApp.App" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local ="clr-namespace:WpfApp" StartupUri ="MainWindow.xaml" > <Application.Resources > <ResourceDictionary > <ResourceDictionary.MergedDictionaries > <ResourceDictionary Source ="ButtonStyles.xaml" /> </ResourceDictionary.MergedDictionaries > </ResourceDictionary > </Application.Resources > </Application >
分解按钮控件模板
当完善或扩展控件模板时,可发现其中封装了大量的不同细节,包括特定的形状、几何图形和画刷。从控件模板中提取这些细节并将它们定义为单独的资源是一个好主意 。一个原因是通过该步骤,可以更方便地在一组相关的控件中重用这些画刷。例如,您可能会决定创建使用相同颜色的自定义 Buton、CheckBox 和 RadioButton控件。为使该工作更加容易,可为画刷(名为Brushes.xaml)创建一个单独的资源字典,并将该资源字典合并到每个控件(如Button,xaml、CheckBox.xaml和 RadioButton.xaml)的资源字典中。
1 2 3 4 5 6 7 8 9 10 11 12 13 <ResourceDictionary xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local ="clr-namespace:ResourceLibrary" > <ResourceDictionary.MergedDictionaries > <ResourceDictionary Source ="/Resources/Brushes.xaml" /> <ResourceDictionary Source ="/Resources/Buttons.xaml" /> <ResourceDictionary Source ="/Resources/CheckBoxs.xaml" /> </ResourceDictionary.MergedDictionaries > </ResourceDictionary >
通过样式应用模板
控件模板本质上硬编码了一些细节,如颜色方案等等。如果希望提供不同的颜色方案,就必须创建引用不同画刷资源的新模板副本。可使用如下方式不使用硬编码的颜色,而需要使用模板绑定从控件属性中提取出信息。下面的示例为前面看到的特殊按钮定义了一个精简的模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <ControlTemplate x:Key ="ButtonTemplate" TargetType ="{x:Type Button}" > <Border x:Name ="border" Background ="{TemplateBinding Background}" BorderBrush ="{TemplateBinding BorderBrush}" BorderThickness ="2" CornerRadius ="2" > <Grid > <Rectangle Name ="focusCue" SnapsToDevicePixels ="True" Stroke ="Black" StrokeDashArray ="1 2" StrokeThickness ="1" Visibility ="Hidden" /> <ContentPresenter Margin ="{TemplateBinding Padding}" HorizontalAlignment ="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment ="{TemplateBinding VerticalContentAlignment}" RecognizesAccessKey ="True" /> </Grid > </Border > <ControlTemplate.Triggers > <Trigger Property ="IsKeyboardFocused" Value ="True" > <Setter TargetName ="focusCue" Property ="Visibility" Value ="Visible" /> </Trigger > </ControlTemplate.Triggers > </ControlTemplate >
关联的样式应用这个控件模板,设置边框和背景颜色,并添加触发器以便根据按钮的状态改变背景色:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <Style x:Key ="DefaultButtonStyle" TargetType ="{x:Type Button}" > <Setter Property ="Template" Value ="{StaticResource ButtonTemplate}" /> <Setter Property ="BorderBrush" Value ="{StaticResource DefaultBorderBrush}" /> <Setter Property ="Background" Value ="{StaticResource DefaultBackground}" /> <Setter Property ="TextBlock.Foreground" Value ="White" /> <Style.Triggers > <Trigger Property ="IsMouseOver" Value ="True" > <Setter Property ="Background" Value ="{StaticResource HighlightBackground}" /> </Trigger > <Trigger Property ="IsPressed" Value ="True" > <Setter Property ="Background" Value ="{StaticResource PressedBackground}" /> </Trigger > <Trigger Property ="IsEnabled" Value ="False" > <Setter Property ="Background" Value ="{StaticResource DisableBackground}" /> </Trigger > </Style.Triggers > </Style >
理想情况下,应能在控件模板中保留所有触发器,因为它们代表控件的行为,并使用样式简单设置基本属性。但在此如果希望样式能够设置颜色方案,是不可能实现的。
如果在控件模板和样式中都设置了触发器,那么样式触发器具有优先权
自动应用模板
在当前示例中,每个按钮负责使用Template
或Style
属性将自身关联到适当模板。如果使用控件模板,在应用程序中的特定位置创建特殊效果,这是合理的。当如果希望在具有自定义外观的整个应用程序中改变每隔按钮的皮肤,这就不是很方便了。对于这种情况,可能会更希望应用程序中的所有按钮自动请求新的模板。为实现该功能,需要通过样式应用控件模板。
**技巧是使用类型样式,这种样式会自动影响响应的元素类型并设置Teamplate
属性。下面是一个样式示例,应将该样式放到资源字典集合中,从而为按钮提供新的外观:
1 2 3 <Style TargetType ="{x:Type Button}" > <Setter Property ="Control.Template" Value ="{StaticResource ButtonTemplate}" /> </Style >
上面的代码可以工作,原因是样式没有指定键名,这意味着改用元素类型(Button)。请记住,仍可通过创建一个按钮并将其Style属性明确设置为null值,退出该样式:
1 <Button Style ="{x:Null}" ... > </Button >
如果遵循正确的设计原则,并在单独的资源字典中定义按钮,这种技术的效果更好。对于这种情况,直到添加将您的资源导入到整个应用程序或特定窗口中的 ResourceDictionary 标签时,样式才会生效。
包含基于类型的样式的组合的资源字典通常(非正式地)被称为主题(Theme) 。主题能够实现非凡的效果。通过主题可为已有应用程序的所有控件重新应用皮肤,而根本不需要公开用户界面标记。需要做的全部工作就是为项目添加资源字典,并将其合并到App.xaml
文件的Application.Resources
集合中。
为使用主题,为项目添加包含资源字典的xaml文件。例如,WPF Futures提供了一个名为ExpressionDark.xaml的主题文件,然后,需要在应用程序中激活样式。可逐个窗口地完成该工作,但更快捷的方法是通过添加如下所示的标记在应用程序级别导入它们:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <Application x:Class ="WpfApp.App" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local ="clr-namespace:WpfApp" StartupUri ="MainWindow.xaml" > <Application.Resources > <ResourceDictionary > <ResourceDictionary.MergedDictionaries > <ResourceDictionary Source ="pack://application:,,,/ResourceLibrary;component/Resources/ExpressionDark.xaml" /> </ResourceDictionary.MergedDictionaries > </ResourceDictionary > </Application.Resources > </Application >
由用户选择的皮肤
在一些应用程序中,可能希望动态改变模板,通常是根据用户的个人爱好加以改变,基本思路是在运行时加载新的资源字典,并使用新加载的资源字典代替当前的资源字典 (不需要替换所有资源,只需要替换那些用于皮肤的资源)。
ExpressionDark
和ExpressionLight
主题资源可见Themes 所示
引用主题资源 MainWindow.xaml 切换主题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <Application x:Class ="WpfApp.App" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local ="clr-namespace:WpfApp" StartupUri ="MainWindow.xaml" > <Application.Resources > <ResourceDictionary > <ResourceDictionary.MergedDictionaries > <ResourceDictionary Source ="pack://application:,,,/ResourceLibrary;component/Resources/ExpressionDark.xaml" /> </ResourceDictionary.MergedDictionaries > </ResourceDictionary > </Application.Resources > </Application >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 <Window x:Class ="WpfApp.MainWindow" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d ="http://schemas.microsoft.com/expression/blend/2008" xmlns:local ="clr-namespace:WpfApp" xmlns:r ="clr-namespace:ResourceLibrary;assembly=ResourceLibrary" Title ="MainWindow" Width ="350" Height ="520" Background ="{DynamicResource WindowBackgroundBrush}" > <StackPanel > <StackPanel Margin ="20" Orientation ="Horizontal" > <TextBlock Margin ="10,0" Text ="Theme:" /> <RadioButton x:Name ="rb_dark" Click ="ChangeThemeClick" Content ="Dark" IsChecked ="True" /> <RadioButton x:Name ="rb_light" Click ="ChangeThemeClick" Content ="Light" /> </StackPanel > <Button Width ="100" Height ="30" Margin ="20" > Button </Button > </StackPanel > </Window >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 private void ChangeThemeClick (object sender, RoutedEventArgs e ){ var rd = new ResourceDictionary(); if (rb_dark.IsChecked == true ) { rd.Source = new Uri( "pack://application:,,,/ResourceLibrary;component/Resources/ExpressionDark.xaml" , UriKind.RelativeOrAbsolute ); } else if (rb_light.IsChecked == true ) { rd.Source = new Uri( "pack://application:,,,/ResourceLibrary;component/Resources/ExpressionLight.xaml" , UriKind.RelativeOrAbsolute ); } if (rd.Source != null ) Application.Current.Resources.MergedDictionaries[0 ] = rd; }
当加载新的资源字典时,会自动使用新模板更新所有按钮。如果当修改控件时不需要完全改变皮肤,还可以为皮肤提供基本样式。该例假定ExpressionDark
和ExpressionLight
资源使用元素类型样式自动改变按钮。还有一种方法——可通过手动设置Button对象的Template
属性或Style
属性来选用新的模板。如果使用这种方法,务必使用DynamicResource
应用,而不能使用StaticResource
,如果使用StaticResource
,当切换皮肤时不会更新按钮模板。
当使用 DynamicResource 引用时,首先要保证需要的资源位于资源层次结构中。如果资源并不位于资源层次结构中,就会忽略资源。而且按钮会恢复为它们的标准外观,而不会生成错误,
还有一种通过编写代码加载资源字典的方法。可使用与为窗口创建代码隐藏类几乎相同的方法,为资源字典创建代码隐藏类。然后就可以直接实例化这个类
,而不是使用ResourceDictionary.Source
属性。这种方法有一个有点,它是前类型的(没有机会为Source属性输入无效的URI),并且可为资源类添加属性、方法以及其他功能。
尽管为资源字典创建代码隐藏类很容易,但Visual Studio不能自动完成该工作。需要为继承自ResourceDictionary
的部分类添加代码文件,并在构造函数中调用 InitializeComponent()
方法:
这里使用的类名为ExpressionDark
,而且改为存储在ExpressionDark.xaml.cs
文件中。包含资源的XAML文件被命名为ExpressionDark.xaml
。不是必须使用一致的名称,当这是一个好主意,并且在创建窗口以及创建页面时也遵循了Visual Studio使用的这一约定。
1 2 3 4 5 6 7 8 9 public partial class ExpressionDark : ResourceDictionary { public ExpressionDark () { InitializeComponent(); } }
1 2 3 4 5 <ResourceDictionary x:Class ="ResourceLibrary.Resources.ExpressionDark" ... > ... </ResourceDictionary >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private void ChangeThemeClick (object sender, RoutedEventArgs e ){ ResourceDictionary? rd = null ; if (rb_dark.IsChecked == true ) { rd = new ExpressionDark(); } else if (rb_light.IsChecked == true ) { rd = new ExpressionLight(); } if (rd != null ) Application.Current.Resources.MergedDictionaries[0 ] = rd; }
在Solution Explorer中,如果希望ExpressionDark.cs
文件嵌套在ExpressionDark.xaml
文件的下面,需要在文本编辑器中修改.csproj项目文件。在<ItemGroup>
部分,找到代码隐藏文件,并将下面的代码:
1 <Compile Include ="Resources\ExpressionDark.xaml.cs" />
修改为:
1 2 3 <Compile Include ="Resources\ExpressionDark.xaml.cs" > <DependentUpon > Resources\ExpressionDark.xaml</DependentUpon > </Compile >
我使用的.NET8版本的WPF,似乎不需要进行此处的配置
构建更复杂的模板
为控件模板和为其提供支持的代码之间有一个隐含约定。如果使用自定义控件模板替代控件的标准模板,就需要确保新模板能够满足控件的实现代码的所有需要。
在简单控件中,这个过程比较容易,因为对模板几乎没有(或完全没有)什么真正的要求。对于复杂控件问题就显得有些微妙了,因为控件的外观和实现不可能是完全相互独立的。对于这种情况,控件需要对其可视化显示做出一些假设,而不管曾经被设计得多么好。
嵌套的模板
按钮控件的模板可分解成几个较为简单的部分。然而,许多模板并非如此简单。在某些情况下,控件模板将包含每个自定义模板也需要的大量元素。而在有些情况下,改变控件的外观设计创建多个模板。
创建下面的示例是为ListBox控件设计模板,并酌情添加自动应用模板的样式
“鱼眼”效果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <Style TargetType ="{x:Type ListBox}" > <Setter Property ="Template" > <Setter.Value > <ControlTemplate TargetType ="{x:Type ListBox}" > <Border x:Name ="border" Background ="{TemplateBinding Background}" BorderBrush ="{TemplateBinding BorderBrush}" BorderThickness ="1" CornerRadius ="3" > <ScrollViewer Focusable ="False" > <ItemsPresenter Margin ="2" /> </ScrollViewer > </Border > </ControlTemplate > </Setter.Value > </Setter > </Style >
对于该模板,最值得注意之处是它未提供的功能–配置列表中各项的外观。没有该功能,被选择的元素总是使用熟悉的蓝色背景突出显示。为改变这种行为,需要为ListBoxItem 控件添加控件模板,ListBoxItem 控件是封装列表中每个单独元素内容的内容控件
与 ListBox 模板一样,可使用元素类型样式应用 ListBoxltem 模板。下面的基本模板在一个不可见的边框中封装了每个项。因为 ListBoxltem 是内容控件,所以需要使用 ContentPresenter元素在其内部放置项的内容。除这些基本内容外,还有当鼠标移动到项上或单击项时做出响应的触发器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 <Style TargetType ="{x:Type ListBoxItem}" > <Setter Property ="Template" > <Setter.Value > <ControlTemplate TargetType ="{x:Type ListBoxItem}" > <Border x:Name ="border" Background ="{TemplateBinding Background}" BorderBrush ="{TemplateBinding BorderBrush}" BorderThickness ="{TemplateBinding BorderThickness}" > <ContentPresenter /> </Border > <ControlTemplate.Triggers > <EventTrigger RoutedEvent ="ListBoxItem.MouseEnter" > <EventTrigger.Actions > <BeginStoryboard > <Storyboard > <DoubleAnimation Storyboard.TargetProperty ="FontSize" To ="20" Duration ="0:0:0.1" /> </Storyboard > </BeginStoryboard > </EventTrigger.Actions > </EventTrigger > <EventTrigger RoutedEvent ="ListBoxItem.MouseLeave" > <EventTrigger.Actions > <BeginStoryboard > <Storyboard > <DoubleAnimation BeginTime ="0:0:0.5" Storyboard.TargetProperty ="FontSize" Duration ="0:0:0.1" /> </Storyboard > </BeginStoryboard > </EventTrigger.Actions > </EventTrigger > <Trigger Property ="IsMouseOver" Value ="True" > <Setter TargetName ="border" Property ="BorderBrush" Value ="Red" /> </Trigger > <Trigger Property ="IsSelected" Value ="True" > <Setter TargetName ="border" Property ="Background" Value ="Green" /> <Setter TargetName ="border" Property ="TextBlock.Foreground" Value ="White" /> </Trigger > </ControlTemplate.Triggers > </ControlTemplate > </Setter.Value > </Setter > </Style >
修改滚动条
列表框还有一个方面没有改变:右边的滚动条。它是ScrollViewer
元素的一部分,ScrollViewer
元素时ListBox
模板的一部分。尽管该例重新定义了ListBox
模板,但没有替换ScrollBar
的ScrollViewer
为自定义该细节,可为 ListBox 控件创建一个新的 ScrollViewer 模板。然后可将 ScrolViewer模板指向自定义的 ScrollBar 模板。然而,还有更简单的选择。可创建一个改变所有 ScrollBar控件模板的特定于元素类型的样式。这样就避免了创建 ScrollViewer 模板所需的额外工作。
当然,还需要考虑这种设计会对应用程序的其他部分造成什么影响。如果创建元素类型样式 ScrollBar,并将其添加到窗口的 Resources 集合中,对于窗口中的所有控件,无论何时使用ScrollBar 控件,都会具有新样式的滚动条,这可能正是您所希望的效果。另一方面,**如果希望只改变 ListBox控件中的滚动条,就必须为ListBox控件本身的资源集合添加元素类型样式 ScrollBar。**最后,如果希望改变整个应用程序中所有滚动条的外观,可将该样式添加到 App.xaml文件的资源集合中。
解剖滚动条
滚动条的背景由 Track 类表示——实际上是一个具有阴影并且被拉伸占满整个滚动条长度的矩形。滚动条的末尾处是按钮,通过这些按钮可以向上或向下(或向左或向右)滚动一个步长这些按钮是 RepeatButton类的实例 ,该类继承自 ButtonBase类。RepeatButton 类和普通 Button类之间的重要区别在于,如果在 RepeatButton 按钮上保持鼠标为按下状态,就会反复触发 Click事件(对于滚动这是非常方便的)。
**在滚动条的中间是代表滚动内容中当前位置的 Thumb 元素。并且最有趣的是,滑块两侧的空白实际上由另外两个 RepeatButton对象构成,它们是透明的。**当单击这两个按钮中的一个时,滚动条会滚动一整页(一页是滚动内容所在的可见窗口中的内部容量)。通过单击滑块两侧的条形区域,可快速浏览滚动内容,这一功能是大家所熟悉的。
下面是用于垂直滚动条的模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 <Window.Resources > <SolidColorBrush x:Key ="StandardBrush" Color ="#F5F5F5" /> <SolidColorBrush x:Key ="StandardBorderBrush" Color ="#D4D4D4" /> <SolidColorBrush x:Key ="PressedBrush" Color ="#EEEEEE" /> <SolidColorBrush x:Key ="GlyphBrush" Color ="#333" /> <Style x:Key ="ScrollBarThumbStyle" TargetType ="{x:Type Thumb}" > <Setter Property ="IsTabStop" Value ="False" /> <Setter Property ="Focusable" Value ="False" /> <Setter Property ="Margin" Value ="1,0,1,0" /> <Setter Property ="Background" Value ="{StaticResource StandardBrush}" /> <Setter Property ="BorderBrush" Value ="{StaticResource StandardBorderBrush}" /> <Setter Property ="Template" > <Setter.Value > <ControlTemplate TargetType ="{x:Type Thumb}" > <Ellipse Fill ="{StaticResource StandardBrush}" Stroke ="{StaticResource StandardBorderBrush}" /> </ControlTemplate > </Setter.Value > </Setter > </Style > <Style x:Key ="ScrollBarLineButtonStyle" TargetType ="{x:Type RepeatButton}" > <Setter Property ="Focusable" Value ="False" /> <Setter Property ="Template" > <Setter.Value > <ControlTemplate TargetType ="{x:Type RepeatButton}" > <Grid Margin ="1" > <Ellipse x:Name ="border" Fill ="{StaticResource StandardBrush}" Stroke ="{StaticResource StandardBorderBrush}" StrokeThickness ="1" /> <ContentPresenter HorizontalAlignment ="Center" VerticalAlignment ="Center" /> </Grid > <ControlTemplate.Triggers > <Trigger Property ="IsPressed" Value ="True" > <Setter TargetName ="border" Property ="Fill" Value ="{StaticResource PressedBrush}" /> </Trigger > </ControlTemplate.Triggers > </ControlTemplate > </Setter.Value > </Setter > </Style > <Style x:Key ="ScrollBarPageButtonStyle" TargetType ="{x:Type RepeatButton}" > <Setter Property ="IsTabStop" Value ="False" /> <Setter Property ="Focusable" Value ="False" /> <Setter Property ="Template" > <Setter.Value > <ControlTemplate TargetType ="{x:Type RepeatButton}" > <Border Background ="Transparent" /> </ControlTemplate > </Setter.Value > </Setter > </Style > <ControlTemplate x:Key ="VerticalScrollBar" TargetType ="{x:Type ScrollBar}" > <Grid > <Grid.RowDefinitions > <RowDefinition MaxHeight ="18" /> <RowDefinition Height ="*" /> <RowDefinition MaxHeight ="18" /> </Grid.RowDefinitions > <RepeatButton Grid.Row ="0" Height ="18" Command ="ScrollBar.LineUpCommand" Style ="{StaticResource ScrollBarLineButtonStyle}" > <Path Data ="M 0 4 L 8 4 L 4 0 Z" Fill ="{StaticResource GlyphBrush}" /> </RepeatButton > <Track x:Name ="PART_Track" Grid.Row ="1" IsDirectionReversed ="True" ViewportSize ="0" > <Track.DecreaseRepeatButton > <RepeatButton Command ="ScrollBar.PageUpCommand" Style ="{StaticResource ScrollBarPageButtonStyle}" /> </Track.DecreaseRepeatButton > <Track.Thumb > <Thumb Style ="{StaticResource ScrollBarThumbStyle}" /> </Track.Thumb > <Track.IncreaseRepeatButton > <RepeatButton Command ="ScrollBar.PageDownCommand" Style ="{StaticResource ScrollBarPageButtonStyle}" /> </Track.IncreaseRepeatButton > </Track > <RepeatButton Grid.Row ="3" Height ="18" Command ="ScrollBar.LineDownCommand" Style ="{StaticResource ScrollBarLineButtonStyle}" > <Path Data ="M 0 0 L 4 4 L 8 0 Z" Fill ="{StaticResource GlyphBrush}" /> </RepeatButton > <RepeatButton Grid.Row ="3" Height ="18" Command ="ScrollBar.LineDownCommand" Style ="{StaticResource ScrollBarLineButtonStyle}" > <Path Data ="M 0 0 L 4 4 L 8 0 Z" Fill ="{StaticResource GlyphBrush}" /> </RepeatButton > </Grid > </ControlTemplate > <Style TargetType ="{x:Type ScrollBar}" > <Setter Property ="SnapsToDevicePixels" Value ="True" /> <Setter Property ="OverridesDefaultStyle" Value ="True" /> <Style.Triggers > <Trigger Property ="Orientation" Value ="Vertical" > <Setter Property ="Width" Value ="18" /> <Setter Property ="Height" Value ="auto" /> <Setter Property ="Template" Value ="{StaticResource VerticalScrollBar}" /> </Trigger > </Style.Triggers > </Style > <Style TargetType ="{x:Type ListBox}" > <Setter Property ="Template" > <Setter.Value > <ControlTemplate TargetType ="{x:Type ListBox}" > <Border x:Name ="border" Background ="{TemplateBinding Background}" BorderBrush ="{TemplateBinding BorderBrush}" BorderThickness ="1" CornerRadius ="3" > <ScrollViewer Focusable ="False" > <ItemsPresenter Margin ="2" /> </ScrollViewer > </Border > </ControlTemplate > </Setter.Value > </Setter > </Style > <Style TargetType ="{x:Type ListBoxItem}" > <Setter Property ="Height" Value ="30" /> <Setter Property ="Template" > <Setter.Value > <ControlTemplate TargetType ="{x:Type ListBoxItem}" > <Border x:Name ="border" Background ="{TemplateBinding Background}" BorderBrush ="{TemplateBinding BorderBrush}" BorderThickness ="{TemplateBinding BorderThickness}" > <ContentPresenter VerticalAlignment ="{TemplateBinding VerticalContentAlignment}" /> </Border > <ControlTemplate.Triggers > <EventTrigger RoutedEvent ="ListBoxItem.MouseEnter" > <EventTrigger.Actions > <BeginStoryboard > <Storyboard > <DoubleAnimation Storyboard.TargetProperty ="FontSize" To ="20" Duration ="0:0:0.1" /> </Storyboard > </BeginStoryboard > </EventTrigger.Actions > </EventTrigger > <EventTrigger RoutedEvent ="ListBoxItem.MouseLeave" > <EventTrigger.Actions > <BeginStoryboard > <Storyboard > <DoubleAnimation BeginTime ="0:0:0.5" Storyboard.TargetProperty ="FontSize" Duration ="0:0:0.1" /> </Storyboard > </BeginStoryboard > </EventTrigger.Actions > </EventTrigger > <Trigger Property ="IsMouseOver" Value ="True" > <Setter TargetName ="border" Property ="BorderBrush" Value ="Red" /> </Trigger > <Trigger Property ="IsSelected" Value ="True" > <Setter TargetName ="border" Property ="Background" Value ="Green" /> <Setter TargetName ="border" Property ="TextBlock.Foreground" Value ="White" /> </Trigger > </ControlTemplate.Triggers > </ControlTemplate > </Setter.Value > </Setter > </Style > </Window.Resources > <Grid > <ListBox > <ListBoxItem > 🍎</ListBoxItem > <ListBoxItem > 🍌</ListBoxItem > <ListBoxItem > 🍊</ListBoxItem > <ListBoxItem > 🍈</ListBoxItem > <ListBoxItem > 🍉</ListBoxItem > <ListBoxItem > 🍇</ListBoxItem > <ListBoxItem > 🍓</ListBoxItem > <ListBoxItem > 🍍</ListBoxItem > <ListBoxItem > 🌰</ListBoxItem > </ListBox > </Grid >
一旦理解滚动条的多部分结构,上面的模板就非常直观了。下面列出需要注意的即可要点:
垂直滚动条由一个包含三行的网课构成。顶hang6he底行容纳两端的按钮(并显示为剪头)。它们固定占用18可单位。中间部分容纳Track
元素,占用了剩余空间。
两段的RepeatButton
元素使用相同的样式。唯一的区别是Content
属性,该属性包含了一个用于绘制剪头的Path
对象,因为顶部的按钮具有上箭头而底部具有下箭头。为简明起见,这些剪头使用的微语言路径。其他细节(如背景填充和剪头周围显示的圆圈)是在控件模板中定义的,这些定义位于标记中的ScrollBarLineButtonStyle
部分
两个按钮都链接到 ScrollBar 类中的命令(LineUpCommand 和 LineDownCommand)。这正是其工作原理。只要提供链接到这个命令的按钮即可,不必考虑按钮的名称是什么,也不必考虑其外观像什么或使用哪个特定的类(在第9章中详细介绍了命令)。
Track 元素名为 PART_Track。为使 ScrollBar 类能够成功地关联到它的代码,必须使用这个名称
。如果査看 ScrollBar 类的默认模板(类似于上面的模板,但更长一些),也会看到该元素。
Track.ViewportSize 属性被设置为0。这是该模板特有的实现细节,可确保 Thumb 元素总有相同的尺寸(通常,滑块根据内容按比例地改变尺寸,因此如果滚动的内容在窗口中基本上能够显示,这时滑块会变得较长)。
Track 元素封装了两个 RepeatButton 对象(它们的样式单独定义)和 Thumb 元素。同样,这些按钮通过命令连接到适当的功能。
控件模板示例
为通用控件提供新模板是一件复杂的任务。这是因为控件模板的所有需求并不总是很明显。例如,典型的ScrollBar
控件需要组合两个RepeatButton
对象和一个Track
对象。其他控件模板需要具有特定PART_名称
的元素。在自定义窗口情形中,需要确保定义了装饰层,因为一些控件需要使用它。
尽管可通过分析控件的默认模板发现这些细节,但这些默认模板通常很复杂并且包含许多无关紧要的细节。以及一些无论如何都不会支持的绑定。幸运的是,在此有一个更好的起点:ControlTeamplateExample 示例项目
可视化状态
到目前为止,已经学习了最直接、最流行的编写控件模板的方法:混合使用元素、绑定表达式以及触发器 。使用元素创建控件的整个可视化结构。绑定用于从控件类的属性提取信息并将其应用于元素内部。而触发器创建交互功能,当控件的状态发生变化时允许控件改变其外观。
这种模型的优点是极其强大和灵活。可执行希望的任何操作。在按钮示例中没有立即看到该优点,因为控件模板依赖于内置的属性,如IsMouseOver
和IsPressed
。但即使不能使用这些属性,也仍可编写改变自身1️以响应鼠标移动和按钮单击的控件模板。技巧是使用应用动画的事件触发器 。例如,可添加事件触发器,通过启动改变边框背景颜色的动画来响应Border.MouseOver 事件。该动画甚至看起来不像是动画——如果将其持续时间设置为0秒,它将立即应用自身,就像正在使用的属性触发器。实际上,这正是许多专业模板示例使用的技术。
尽管它们的功能很强大,但基于触发器的模板有如下缺点:它们需要模板设计人员深入理解控件的工作方式,例如在按钮示例中,模板设计人员需要知道IsMouseOver 和IsPressed 属性的存在,并且需要知道如何使用它们。而且这还不是唯一需要掌握的细节——例如,大多数控件需要根据鼠标移动、被禁用、获得焦点以及许多其他状态的改变,修改其可视化外观。当结合使用这些状态时,很难准确判断控件应当具有什么样的外观。此外,如果使用基于触发器的模型实现过渡效果,会显得很笨拙。例如,假设希望创建当将鼠标悬停在其上时闪烁的按钮。为获得专业级别的效果,可能需要两个动画——一个动画将按钮的状态从正常状态改变为鼠标悬停状态,另一个动画在此后立即应用不停闪烁的效果。使用基于触发器的模板管理所有这些细节可能是一个挑战。
Microsoft在WPF4中添加了称为可视化状态(Visual State
)的新特性 ,该特性化解了这个挑战。使用具有特定名称的部件和可视化状态,控件能提供标准化的可视化协定。**不需要理解整个控件,模板设计人员只需要理解可视化协定的规则。**因此,设计简单的控件模板要容易得多——当以为以前从来没有用过的控件设计模板时更是如此。
控件可使用TeamplatePart
特性指示控件模板应当包含具有特定名称的元素(或部件),与此类似,可使用TemplateVisualSTate
特性指示它们支持的可视化状态 。例如,普通的按钮应当提供如下所示的一组可视化状态:
1 2 3 4 5 6 7 8 [TemplateVIsualState(Name="Normal" ,GroupName="CommonStates" ) ] [TemplateVIsualState(Name="MouseOver" ,GroupName="CommonStates" ) ] [TemplateVIsualState(Name="Pressed" ,GroupName="CommonStates" ) ] [TemplateVIsualState(Name="Disabled" ,GroupName="CommonStates" ) ] [TemplateVIsualState(Name="Unfocused" ,GroupName="FocusStates" ) ] [TemplateVIsualState(Name="Focused" ,GroupName="FocusStates" ) ] public class Button : ButtonBase {}
状态被放到各个组中。组是互相排斥的,这意味着控件具有每隔组中的一个状态 。例如,上面显示的按钮具有两个状态组:CommonStates
和FocusStates
。在任意给定时刻,按钮有一个来自CommonStates
组的状态并且有一个来自FocusStates
组的状态。
例如,如果使用 Tab键将焦点移到按钮上,按钮的状态将是 Normal(来自CommonStates 组)和 Focused(来自 FocusStates 组)。然后如果将鼠标移动到按钮上,其状态将是 MouseOver(来自CommonStates 组)和 Focused(来自 FocusStates 组)。不使用状态组,处理这种情况就会遇到麻烦要么必须使某些状态支配其他状态(这样处于 MouseOver 状态的按钮会丢失焦点指示器),要么需要创建其他更多的状态(例如FocusedNormal、UnfocusedNormal、FocusedMouseOver 和UnfocusedMouseOyer等)。
至此,您可能已经体会到可视化状态模型的魅力。从模板来看,立即就能清楚地了解到控件模板需要解决6种不同的可能状态。还知道每种状态的名称是唯一重要的细节。**不需要知道按钮类提供了哪些属性,也不需要知道控件内部的工作原理。**最令人满意的是,如果使用Expression Blend,当为支持可视化状态的控件创建控件模板时可以得到增强了的设计时支持。Blend 将为您显示控件支持的具有特定名称的部件和可视化状态(因为在定义它们时使用了TemplatePart
和 TemplateVisualState
特性),然后可以添加相应的元素和故事板。