专题二我们学习了数据绑定和 MVVM 模式,掌握了 WPF 的核心架构。但 WPF 内置的控件不能满足所有需求,有时我们需要创建自定义控件或者对现有控件进行深度定制。这一专题我们将学习 WPF 的高级控件和自定义控件开发,让你能够创建任何你想要的界面。
3.1 控件模板(ControlTemplate)
3.1.1 为什么需要 ControlTemplate?
ControlTemplate 可以完全改变控件的外观,而不改变其行为
csharp
// 默认 Button 是一个矩形按钮
// 通过 ControlTemplate,可以变成圆形、椭圆形、或者任何形状
// 默认外观是硬编码的
<Button Content="普通按钮"/>
// 自定义模板后可以是任何样子
<Button Content="圆形按钮">
<Button.Template>
<ControlTemplate TargetType="Button">
<Ellipse Fill="LightBlue">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Ellipse>
</ControlTemplate>
</Button.Template>
</Button>
3.1.2 ControlTemplate 基础
xml
<!-- 一个完整的按钮模板 -->
<Window.Resources>
<Style TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<!-- 模板根元素 -->
<Grid>
<!-- 背景 -->
<Border x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="5">
<!-- 内容 -->
<ContentPresenter x:Name="contentPresenter"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{TemplateBinding Content}"/>
</Border>
<!-- 鼠标悬停时的发光效果 -->
<Border x:Name="glowBorder"
Background="Transparent"
BorderBrush="Gold"
BorderThickness="2"
CornerRadius="5"
Opacity="0"/>
</Grid>
<!-- 模板触发器 -->
<ControlTemplate.Triggers>
<!-- 鼠标悬停 -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="LightBlue"/>
<Setter TargetName="glowBorder" Property="Opacity" Value="1"/>
</Trigger>
<!-- 按钮按下 -->
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleX="0.95" ScaleY="0.95"/>
</Setter.Value>
</Setter>
</Trigger>
<!-- 禁用状态 -->
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
3.1.3 TemplateBinding 和 ContentPresenter
xml
<!-- TemplateBinding:绑定到目标控件的属性 -->
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<!-- ContentPresenter:显示内容的地方 -->
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Border>
</ControlTemplate>
3.1.4 自定义按钮模板示例
xml
<!-- 玻璃风格按钮 -->
<Style x:Key="GlassButtonStyle" TargetType="Button">
<Setter Property="Width" Value="120"/>
<Setter Property="Height" Value="40"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<!-- 按钮背景 -->
<Border x:Name="border"
Background="#2196F3"
CornerRadius="20">
<Border.Effect>
<DropShadowEffect BlurRadius="8" ShadowDepth="2" Opacity="0.3"/>
</Border.Effect>
</Border>
<!-- 玻璃高光效果 -->
<Border x:Name="glass"
Background="White"
Opacity="0.2"
CornerRadius="20"
Height="20"
VerticalAlignment="Top"/>
<!-- 内容 -->
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<!-- 点击动画 -->
<Border x:Name="ripple"
Background="White"
Opacity="0"
CornerRadius="20">
<Border.RenderTransform>
<ScaleTransform ScaleX="0" ScaleY="0"/>
</Border.RenderTransform>
</Border>
</Grid>
<ControlTemplate.Triggers>
<!-- 鼠标悬停 -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="#1976D2"/>
<Setter TargetName="glass" Property="Height" Value="25"/>
</Trigger>
<!-- 按钮按下 -->
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleX="0.97" ScaleY="0.97"/>
</Setter.Value>
</Setter>
<Setter TargetName="ripple" Property="Opacity" Value="0.3"/>
<Setter TargetName="ripple" Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleX="1" ScaleY="1"/>
</Setter.Value>
</Setter>
</Trigger>
<!-- 禁用状态 -->
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Background" Value="#B0BEC5"/>
<Setter TargetName="glass" Property="Opacity" Value="0"/>
<Setter Property="Foreground" Value="#ECEFF1"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
3.2 数据模板(DataTemplate)
3.2.1 DataTemplate 基础
DataTemplate 定义了数据对象如何显示在 UI 上
xml
<!-- 基本 DataTemplate -->
<ListBox ItemsSource="{Binding Users}">
<ListBox.ItemTemplate>
<DataTemplate>
<Border BorderBrush="Gray" BorderThickness="1" Margin="5" Padding="10" CornerRadius="5">
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="Bold" FontSize="14"/>
<TextBlock Text="{Binding Email}" Foreground="Gray"/>
<TextBlock Text="{Binding Age, StringFormat=年龄:{0}岁}"/>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
3.2.2 复杂的 DataTemplate
xml
<Window.Resources>
<!-- 根据数据状态改变显示样式 -->
<DataTemplate x:Key="UserTemplate" DataType="local:User">
<Border x:Name="rootBorder" Margin="5" Padding="10" CornerRadius="5">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="LightGray"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Background" Value="White"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsActive}" Value="False">
<Setter Property="Background" Value="#F5F5F5"/>
<Setter Property="Opacity" Value="0.6"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="BorderBrush" Value="#2196F3"/>
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="Background" Value="#E3F2FD"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 头像 -->
<Border Grid.Column="0" Width="40" Height="40" CornerRadius="20"
Background="{Binding AvatarColor}">
<TextBlock Text="{Binding Initials}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="White"
FontWeight="Bold"/>
</Border>
<!-- 用户信息 -->
<StackPanel Grid.Column="1" Margin="10,0">
<TextBlock Text="{Binding Name}" FontWeight="Bold" FontSize="14"/>
<TextBlock Text="{Binding Email}" Foreground="Gray" FontSize="11"/>
<TextBlock Text="{Binding Department}" FontSize="11"/>
</StackPanel>
<!-- 状态指示器 -->
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="{Binding StatusColor}" Margin="0,0,0,5"/>
<TextBlock Text="{Binding Status}" FontSize="10" Foreground="Gray"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</Window.Resources>
3.2.3 DataTemplateSelector(模板选择器)
csharp
// 根据数据选择不同的模板
public class MessageTemplateSelector : DataTemplateSelector
{
public DataTemplate IncomingTemplate { get; set; }
public DataTemplate OutgoingTemplate { get; set; }
public override DataTemplate SelectTemplate(object item, DependencyObject container)
{
if (item is Message message)
{
return message.IsIncoming ? IncomingTemplate : OutgoingTemplate;
}
return base.SelectTemplate(item, container);
}
}
xml
<Window.Resources>
<!-- 接收消息模板(左侧)-->
<DataTemplate x:Key="IncomingTemplate">
<Border Background="LightGray" CornerRadius="10" Margin="5"
HorizontalAlignment="Left" MaxWidth="300">
<TextBlock Text="{Binding Content}" Margin="10"/>
</Border>
</DataTemplate>
<!-- 发送消息模板(右侧)-->
<DataTemplate x:Key="OutgoingTemplate">
<Border Background="LightBlue" CornerRadius="10" Margin="5"
HorizontalAlignment="Right" MaxWidth="300">
<TextBlock Text="{Binding Content}" Margin="10"/>
</Border>
</DataTemplate>
<!-- 模板选择器 -->
<local:MessageTemplateSelector x:Key="MessageTemplateSelector"
IncomingTemplate="{StaticResource IncomingTemplate}"
OutgoingTemplate="{StaticResource OutgoingTemplate}"/>
</Window.Resources>
<!-- 使用模板选择器 -->
<ListBox ItemsSource="{Binding Messages}"
ItemTemplateSelector="{StaticResource MessageTemplateSelector}"/>
3.3 自定义控件开发
3.3.1 自定义控件类型
| 控件类型 | 说明 | 适用场景 |
|---|---|---|
| UserControl | 组合现有控件 | 创建复合控件,如登录面板 |
| CustomControl | 继承自 Control | 需要完全自定义外观和行为 |
| TemplatedControl | 模板化控件 | 支持样式和模板重定义 |
| Decorator | 装饰器 | 包装单个子元素 |
3.3.2 UserControl 示例:评分控件
csharp
// Controls/RatingControl.xaml.cs
public partial class RatingControl : UserControl
{
// 依赖属性:值
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(int), typeof(RatingControl),
new FrameworkPropertyMetadata(0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged));
// 依赖属性:最大值
public static readonly DependencyProperty MaxValueProperty =
DependencyProperty.Register(nameof(MaxValue), typeof(int), typeof(RatingControl),
new PropertyMetadata(5));
// 依赖属性:只读模式
public static readonly DependencyProperty IsReadOnlyProperty =
DependencyProperty.Register(nameof(IsReadOnly), typeof(bool), typeof(RatingControl),
new PropertyMetadata(false));
// 路由事件:值改变
public static readonly RoutedEvent ValueChangedEvent =
EventManager.RegisterRoutedEvent(nameof(ValueChanged), RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<int>), typeof(RatingControl));
public int Value
{
get => (int)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public int MaxValue
{
get => (int)GetValue(MaxValueProperty);
set => SetValue(MaxValueProperty, value);
}
public bool IsReadOnly
{
get => (bool)GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
public event RoutedPropertyChangedEventHandler<int> ValueChanged
{
add => AddHandler(ValueChangedEvent, value);
remove => RemoveHandler(ValueChangedEvent, value);
}
public RatingControl()
{
InitializeComponent();
}
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (RatingControl)d;
control.UpdateStars();
var args = new RoutedPropertyChangedEventArgs<int>(
(int)e.OldValue, (int)e.NewValue, ValueChangedEvent);
control.RaiseEvent(args);
}
private void UpdateStars()
{
// 更新星星显示逻辑
for (int i = 1; i <= MaxValue; i++)
{
var star = FindName($"Star{i}") as Button;
if (star != null)
{
star.Content = i <= Value ? "★" : "☆";
star.Foreground = i <= Value ?
new SolidColorBrush(Colors.Gold) :
new SolidColorBrush(Colors.Gray);
}
}
}
private void Star_Click(object sender, RoutedEventArgs e)
{
if (IsReadOnly) return;
var button = sender as Button;
var value = int.Parse(button.Tag.ToString());
Value = value;
}
private void Star_MouseEnter(object sender, MouseEventArgs e)
{
if (IsReadOnly) return;
var button = sender as Button;
var value = int.Parse(button.Tag.ToString());
for (int i = 1; i <= MaxValue; i++)
{
var star = FindName($"Star{i}") as Button;
if (star != null)
{
star.Content = i <= value ? "★" : "☆";
star.Foreground = i <= value ?
new SolidColorBrush(Colors.Gold) :
new SolidColorBrush(Colors.LightGray);
}
}
}
private void Star_MouseLeave(object sender, MouseEventArgs e)
{
if (IsReadOnly) return;
UpdateStars();
}
}
xml
<!-- Controls/RatingControl.xaml -->
<UserControl x:Class="WpfControls.Controls.RatingControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel x:Name="StarsPanel" Orientation="Horizontal">
<Button x:Name="Star1" Tag="1" Content="☆" FontSize="24"
Click="Star_Click" MouseEnter="Star_MouseEnter" MouseLeave="Star_MouseLeave"
Background="Transparent" BorderThickness="0" Cursor="Hand"/>
<Button x:Name="Star2" Tag="2" Content="☆" FontSize="24"
Click="Star_Click" MouseEnter="Star_MouseEnter" MouseLeave="Star_MouseLeave"
Background="Transparent" BorderThickness="0" Cursor="Hand"/>
<Button x:Name="Star3" Tag="3" Content="☆" FontSize="24"
Click="Star_Click" MouseEnter="Star_MouseEnter" MouseLeave="Star_MouseLeave"
Background="Transparent" BorderThickness="0" Cursor="Hand"/>
<Button x:Name="Star4" Tag="4" Content="☆" FontSize="24"
Click="Star_Click" MouseEnter="Star_MouseEnter" MouseLeave="Star_MouseLeave"
Background="Transparent" BorderThickness="0" Cursor="Hand"/>
<Button x:Name="Star5" Tag="5" Content="☆" FontSize="24"
Click="Star_Click" MouseEnter="Star_MouseEnter" MouseLeave="Star_MouseLeave"
Background="Transparent" BorderThickness="0" Cursor="Hand"/>
</StackPanel>
</UserControl>
3.3.3 CustomControl 示例:数字输入框
csharp
// Controls/NumericUpDown.cs
public class NumericUpDown : Control
{
// 依赖属性:值
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(double), typeof(NumericUpDown),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged, CoerceValue));
// 依赖属性:最小值
public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register(nameof(Minimum), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(double.MinValue, OnMinimumChanged));
// 依赖属性:最大值
public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register(nameof(Maximum), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(double.MaxValue, OnMaximumChanged));
// 依赖属性:步长
public static readonly DependencyProperty IncrementProperty =
DependencyProperty.Register(nameof(Increment), typeof(double), typeof(NumericUpDown),
new PropertyMetadata(1.0));
// 依赖属性:小数位数
public static readonly DependencyProperty DecimalPlacesProperty =
DependencyProperty.Register(nameof(DecimalPlaces), typeof(int), typeof(NumericUpDown),
new PropertyMetadata(0, OnDecimalPlacesChanged));
// 依赖属性:只读模式
public static readonly DependencyProperty IsReadOnlyProperty =
DependencyProperty.Register(nameof(IsReadOnly), typeof(bool), typeof(NumericUpDown),
new PropertyMetadata(false));
// 路由事件:值改变
public static readonly RoutedEvent ValueChangedEvent =
EventManager.RegisterRoutedEvent(nameof(ValueChanged), RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<double>), typeof(NumericUpDown));
static NumericUpDown()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(NumericUpDown),
new FrameworkPropertyMetadata(typeof(NumericUpDown)));
}
public double Value
{
get => (double)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public double Minimum
{
get => (double)GetValue(MinimumProperty);
set => SetValue(MinimumProperty, value);
}
public double Maximum
{
get => (double)GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
public double Increment
{
get => (double)GetValue(IncrementProperty);
set => SetValue(IncrementProperty, value);
}
public int DecimalPlaces
{
get => (int)GetValue(DecimalPlacesProperty);
set => SetValue(DecimalPlacesProperty, value);
}
public bool IsReadOnly
{
get => (bool)GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
public event RoutedPropertyChangedEventHandler<double> ValueChanged
{
add => AddHandler(ValueChangedEvent, value);
remove => RemoveHandler(ValueChangedEvent, value);
}
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (NumericUpDown)d;
var args = new RoutedPropertyChangedEventArgs<double>(
(double)e.OldValue, (double)e.NewValue, ValueChangedEvent);
control.RaiseEvent(args);
}
private static object CoerceValue(DependencyObject d, object baseValue)
{
var control = (NumericUpDown)d;
var value = (double)baseValue;
if (value < control.Minimum) return control.Minimum;
if (value > control.Maximum) return control.Maximum;
// 限制小数位数
return Math.Round(value, control.DecimalPlaces);
}
private static void OnMinimumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (NumericUpDown)d;
control.CoerceValue(ValueProperty);
}
private static void OnMaximumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (NumericUpDown)d;
control.CoerceValue(ValueProperty);
}
private static void OnDecimalPlacesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (NumericUpDown)d;
control.CoerceValue(ValueProperty);
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
var increaseButton = GetTemplateChild("PART_IncreaseButton") as Button;
var decreaseButton = GetTemplateChild("PART_DecreaseButton") as Button;
var textBox = GetTemplateChild("PART_TextBox") as TextBox;
if (increaseButton != null)
increaseButton.Click += IncreaseButton_Click;
if (decreaseButton != null)
decreaseButton.Click += DecreaseButton_Click;
if (textBox != null)
{
textBox.TextChanged += TextBox_TextChanged;
textBox.LostFocus += TextBox_LostFocus;
textBox.KeyDown += TextBox_KeyDown;
// 更新格式化显示
UpdateTextBox(textBox);
}
}
private void IncreaseButton_Click(object sender, RoutedEventArgs e)
{
if (IsReadOnly) return;
Value = Math.Min(Value + Increment, Maximum);
}
private void DecreaseButton_Click(object sender, RoutedEventArgs e)
{
if (IsReadOnly) return;
Value = Math.Max(Value - Increment, Minimum);
}
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
var textBox = sender as TextBox;
if (textBox == null || textBox.IsFocused) return;
UpdateFromTextBox(textBox);
}
private void TextBox_LostFocus(object sender, RoutedEventArgs e)
{
var textBox = sender as TextBox;
if (textBox == null) return;
UpdateFromTextBox(textBox);
UpdateTextBox(textBox);
}
private void TextBox_KeyDown(object sender, KeyEventArgs e)
{
var textBox = sender as TextBox;
if (textBox == null) return;
if (e.Key == Key.Enter)
{
UpdateFromTextBox(textBox);
UpdateTextBox(textBox);
textBox.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
}
else if (e.Key == Key.Up)
{
IncreaseButton_Click(sender, e);
}
else if (e.Key == Key.Down)
{
DecreaseButton_Click(sender, e);
}
}
private void UpdateFromTextBox(TextBox textBox)
{
if (double.TryParse(textBox.Text, out double newValue))
{
Value = newValue;
}
}
private void UpdateTextBox(TextBox textBox)
{
textBox.Text = Value.ToString($"F{DecimalPlaces}");
}
}
xml
<!-- Themes/Generic.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfControls.Controls">
<Style TargetType="local:NumericUpDown">
<Setter Property="Height" Value="30"/>
<Setter Property="Width" Value="150"/>
<Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
<Setter Property="Background" Value="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"/>
<Setter Property="BorderBrush" Value="{DynamicResource {x:Static SystemColors.ControlDarkBrushKey}}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:NumericUpDown">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 输入框 -->
<TextBox x:Name="PART_TextBox"
Grid.Column="0"
Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent},
StringFormat=F{0}, UpdateSourceTrigger=PropertyChanged}"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="{TemplateBinding BorderBrush}"
Background="{TemplateBinding Background}"
Foreground="{TemplateBinding Foreground}"
IsReadOnly="{TemplateBinding IsReadOnly}"
VerticalContentAlignment="Center"
Padding="5,0"/>
<!-- 增减按钮区域 -->
<StackPanel Grid.Column="1" Orientation="Horizontal">
<RepeatButton x:Name="PART_IncreaseButton"
Content="▲"
Width="25"
FontSize="10"
IsEnabled="{TemplateBinding IsReadOnly, Converter={StaticResource NotConverter}}"/>
<RepeatButton x:Name="PART_DecreaseButton"
Content="▼"
Width="25"
FontSize="10"
IsEnabled="{TemplateBinding IsReadOnly, Converter={StaticResource NotConverter}}"/>
</StackPanel>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="PART_TextBox" Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
3.4 附加属性
3.4.1 什么是附加属性?
附加属性可以让父容器控制子元素的布局行为
csharp
// Grid.Row、Grid.Column 是典型的附加属性
<Grid>
<Button Grid.Row="0" Grid.Column="1"/> <!-- 附加属性 -->
</Grid>
3.4.2 自定义附加属性
csharp
// 示例:让任何控件都能显示水印
public static class WatermarkService
{
// 附加属性:水印文本
public static readonly DependencyProperty WatermarkProperty =
DependencyProperty.RegisterAttached("Watermark", typeof(string), typeof(WatermarkService),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.Inherits));
// 附加属性:水印颜色
public static readonly DependencyProperty WatermarkColorProperty =
DependencyProperty.RegisterAttached("WatermarkColor", typeof(Brush), typeof(WatermarkService),
new PropertyMetadata(Brushes.Gray));
// 附加属性:是否显示水印
public static readonly DependencyProperty IsWatermarkVisibleProperty =
DependencyProperty.RegisterAttached("IsWatermarkVisible", typeof(bool), typeof(WatermarkService),
new PropertyMetadata(false));
public static void SetWatermark(DependencyObject obj, string value)
{
obj.SetValue(WatermarkProperty, value);
}
public static string GetWatermark(DependencyObject obj)
{
return (string)obj.GetValue(WatermarkProperty);
}
public static void SetWatermarkColor(DependencyObject obj, Brush value)
{
obj.SetValue(WatermarkColorProperty, value);
}
public static Brush GetWatermarkColor(DependencyObject obj)
{
return (Brush)obj.GetValue(WatermarkColorProperty);
}
public static void SetIsWatermarkVisible(DependencyObject obj, bool value)
{
obj.SetValue(IsWatermarkVisibleProperty, value);
}
public static bool GetIsWatermarkVisible(DependencyObject obj)
{
return (bool)obj.GetValue(IsWatermarkVisibleProperty);
}
}
xml
<!-- 样式中使用附加属性 -->
<Style TargetType="TextBox">
<Setter Property="local:WatermarkService.Watermark" Value="请输入..."/>
<Setter Property="local:WatermarkService.WatermarkColor" Value="LightGray"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Grid>
<ScrollViewer x:Name="PART_ContentHost" />
<TextBlock x:Name="Watermark"
Text="{Binding Path=(local:WatermarkService.Watermark), RelativeSource={RelativeSource TemplatedParent}}"
Foreground="{Binding Path=(local:WatermarkService.WatermarkColor), RelativeSource={RelativeSource TemplatedParent}}"
Opacity="0.5"
IsHitTestVisible="False"
Margin="5,0,0,0"
VerticalAlignment="Center"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Text" Value="">
<Setter TargetName="Watermark" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="Text" Value="{x:Null}">
<Setter TargetName="Watermark" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="Watermark" Property="Visibility" Value="Collapsed"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
3.5 行为(Behaviors)
3.5.1 什么是行为?
行为允许在 XAML 中给控件附加交互功能,而不需要创建子类
bash
# 安装 Behaviors 包
dotnet add package Microsoft.Xaml.Behaviors.Wpf
3.5.2 自定义行为示例
csharp
// Behaviors/DragInCanvasBehavior.cs
public class DragInCanvasBehavior : Behavior<UIElement>
{
private Point _startPoint;
private bool _isDragging;
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.MouseLeftButtonDown += OnMouseLeftButtonDown;
AssociatedObject.MouseMove += OnMouseMove;
AssociatedObject.MouseLeftButtonUp += OnMouseLeftButtonUp;
}
protected override void OnDetaching()
{
AssociatedObject.MouseLeftButtonDown -= OnMouseLeftButtonDown;
AssociatedObject.MouseMove -= OnMouseMove;
AssociatedObject.MouseLeftButtonUp -= OnMouseLeftButtonUp;
base.OnDetaching();
}
private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var element = sender as FrameworkElement;
if (element != null)
{
_startPoint = e.GetPosition(element.Parent as UIElement);
_isDragging = true;
element.CaptureMouse();
element.Cursor = Cursors.Hand;
}
}
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (!_isDragging) return;
var element = sender as FrameworkElement;
if (element != null && element.Parent is Canvas canvas)
{
var currentPoint = e.GetPosition(canvas);
var offset = currentPoint - _startPoint;
double left = Canvas.GetLeft(element);
double top = Canvas.GetTop(element);
if (double.IsNaN(left)) left = 0;
if (double.IsNaN(top)) top = 0;
Canvas.SetLeft(element, left + offset.X);
Canvas.SetTop(element, top + offset.Y);
_startPoint = currentPoint;
}
}
private void OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
var element = sender as FrameworkElement;
if (element != null)
{
_isDragging = false;
element.ReleaseMouseCapture();
element.Cursor = null;
}
}
}
// Behaviors/TextBoxFilterBehavior.cs
public class TextBoxFilterBehavior : Behavior<TextBox>
{
public static readonly DependencyProperty FilterTypeProperty =
DependencyProperty.Register(nameof(FilterType), typeof(TextBoxFilter), typeof(TextBoxFilterBehavior),
new PropertyMetadata(TextBoxFilter.None));
public TextBoxFilter FilterType
{
get => (TextBoxFilter)GetValue(FilterTypeProperty);
set => SetValue(FilterTypeProperty, value);
}
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.PreviewTextInput += OnPreviewTextInput;
AssociatedObject.PreviewKeyDown += OnPreviewKeyDown;
DataObject.AddPastingHandler(AssociatedObject, OnPaste);
}
protected override void OnDetaching()
{
AssociatedObject.PreviewTextInput -= OnPreviewTextInput;
AssociatedObject.PreviewKeyDown -= OnPreviewKeyDown;
DataObject.RemovePastingHandler(AssociatedObject, OnPaste);
base.OnDetaching();
}
private void OnPreviewTextInput(object sender, TextCompositionEventArgs e)
{
foreach (char c in e.Text)
{
if (!IsValidChar(c))
{
e.Handled = true;
return;
}
}
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Space && !IsValidChar(' '))
{
e.Handled = true;
}
}
private void OnPaste(object sender, DataObjectPastingEventArgs e)
{
if (e.DataObject.GetDataPresent(DataFormats.Text))
{
string text = e.DataObject.GetData(DataFormats.Text) as string;
if (text != null && !IsValidText(text))
{
e.CancelCommand();
}
}
}
private bool IsValidChar(char c)
{
return FilterType switch
{
TextBoxFilter.Numeric => char.IsDigit(c),
TextBoxFilter.Integer => char.IsDigit(c) || c == '-',
TextBoxFilter.Decimal => char.IsDigit(c) || c == '.' || c == '-',
TextBoxFilter.Alphabetic => char.IsLetter(c) || c == ' ',
TextBoxFilter.Alphanumeric => char.IsLetterOrDigit(c) || c == ' ',
_ => true
};
}
private bool IsValidText(string text)
{
return text.All(IsValidChar);
}
}
public enum TextBoxFilter
{
None,
Numeric,
Integer,
Decimal,
Alphabetic,
Alphanumeric
}
xml
<!-- 使用行为 -->
<Window xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:WpfControls.Behaviors">
<Canvas Background="LightGray" Width="400" Height="300">
<!-- 可拖拽的矩形 -->
<Rectangle Width="50" Height="50" Fill="Red" Canvas.Left="100" Canvas.Top="100">
<i:Interaction.Behaviors>
<local:DragInCanvasBehavior/>
</i:Interaction.Behaviors>
</Rectangle>
</Canvas>
<!-- 只允许输入数字的文本框 -->
<TextBox>
<i:Interaction.Behaviors>
<local:TextBoxFilterBehavior FilterType="Numeric"/>
</i:Interaction.Behaviors>
</TextBox>
</Window>
3.6 资源字典
3.6.1 创建资源字典
xml
<!-- Styles/Brushes.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 颜色资源 -->
<SolidColorBrush x:Key="PrimaryBrush" Color="#2196F3"/>
<SolidColorBrush x:Key="PrimaryDarkBrush" Color="#1976D2"/>
<SolidColorBrush x:Key="PrimaryLightBrush" Color="#BBDEFB"/>
<SolidColorBrush x:Key="AccentBrush" Color="#FF4081"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#4CAF50"/>
<SolidColorBrush x:Key="WarningBrush" Color="#FF9800"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#F44336"/>
<!-- 渐变画刷 -->
<LinearGradientBrush x:Key="PrimaryGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#2196F3" Offset="0"/>
<GradientStop Color="#1976D2" Offset="1"/>
</LinearGradientBrush>
</ResourceDictionary>
xml
<!-- Styles/ButtonStyles.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Brushes.xaml"/>
</ResourceDictionary.MergedDictionaries>
<!-- 主要按钮样式 -->
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{StaticResource PrimaryBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="15,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="4">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource PrimaryDarkBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleX="0.98" ScaleY="0.98"/>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.6"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- 次要按钮样式 -->
<Style x:Key="SecondaryButtonStyle" TargetType="Button" BasedOn="{StaticResource PrimaryButtonStyle}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource PrimaryBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
</ResourceDictionary>
3.6.2 合并资源字典
xml
<!-- App.xaml -->
<Application x:Class="WpfControls.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- 全局样式 -->
<ResourceDictionary Source="Styles/Brushes.xaml"/>
<ResourceDictionary Source="Styles/ButtonStyles.xaml"/>
<ResourceDictionary Source="Styles/TextBoxStyles.xaml"/>
<ResourceDictionary Source="Styles/DataGridStyles.xaml"/>
<!-- 主题(可在运行时切换)-->
<ResourceDictionary x:Name="ThemeDictionary" Source="Themes/LightTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
<!-- 隐式样式:自动应用于所有 Button -->
<Style TargetType="Button" BasedOn="{StaticResource PrimaryButtonStyle}"/>
<!-- 全局资源 -->
<SolidColorBrush x:Key="GlobalForeground" Color="#333333"/>
</ResourceDictionary>
</Application.Resources>
</Application>
3.6.3 动态切换主题
csharp
// ThemeManager.cs
public static class ThemeManager
{
public static event EventHandler<string> ThemeChanged;
private static string _currentTheme = "Light";
public static string CurrentTheme
{
get => _currentTheme;
set
{
if (_currentTheme != value)
{
_currentTheme = value;
ApplyTheme(value);
ThemeChanged?.Invoke(null, value);
}
}
}
private static void ApplyTheme(string themeName)
{
var newTheme = new ResourceDictionary
{
Source = new Uri($"Themes/{themeName}Theme.xaml", UriKind.Relative)
};
var app = Application.Current;
var oldTheme = app.Resources.MergedDictionaries
.FirstOrDefault(rd => rd.Source?.OriginalString?.Contains("Theme") == true);
if (oldTheme != null)
{
var index = app.Resources.MergedDictionaries.IndexOf(oldTheme);
app.Resources.MergedDictionaries[index] = newTheme;
}
else
{
app.Resources.MergedDictionaries.Add(newTheme);
}
}
}
3.7 动画
3.7.1 基础动画
xml
<!-- 简单动画 -->
<Button Content="淡入动画">
<Button.Triggers>
<EventTrigger RoutedEvent="Button.Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="0" To="1" Duration="0:0:1"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Button.Triggers>
</Button>
3.7.2 多种动画类型
xml
<Window.Resources>
<!-- 颜色动画 -->
<Storyboard x:Key="ColorAnimation">
<ColorAnimation Storyboard.TargetProperty="(Button.Background).(SolidColorBrush.Color)"
From="LightBlue" To="DarkBlue" Duration="0:0:0.5"
AutoReverse="True" RepeatBehavior="Forever"/>
</Storyboard>
<!-- 缩放动画 -->
<Storyboard x:Key="ScaleAnimation">
<DoubleAnimation Storyboard.TargetProperty="(Button.RenderTransform).(ScaleTransform.ScaleX)"
From="1" To="1.2" Duration="0:0:0.3"
AutoReverse="True"/>
<DoubleAnimation Storyboard.TargetProperty="(Button.RenderTransform).(ScaleTransform.ScaleY)"
From="1" To="1.2" Duration="0:0:0.3"
AutoReverse="True"/>
</Storyboard>
<!-- 旋转动画 -->
<Storyboard x:Key="RotateAnimation">
<DoubleAnimation Storyboard.TargetProperty="(Button.RenderTransform).(RotateTransform.Angle)"
From="0" To="360" Duration="0:0:2"
RepeatBehavior="Forever"/>
</Storyboard>
<!-- 位移动画 -->
<Storyboard x:Key="MoveAnimation">
<DoubleAnimation Storyboard.TargetProperty="(Button.RenderTransform).(TranslateTransform.X)"
From="0" To="100" Duration="0:0:1"
AutoReverse="True" RepeatBehavior="Forever"/>
</Storyboard>
</Window.Resources>
<StackPanel>
<Button Content="颜色动画" Width="120" Height="40" Margin="5">
<Button.RenderTransform>
<RotateTransform/>
</Button.RenderTransform>
<Button.Triggers>
<EventTrigger RoutedEvent="Button.MouseEnter">
<BeginStoryboard Storyboard="{StaticResource ColorAnimation}"/>
</EventTrigger>
</Button.Triggers>
</Button>
</StackPanel>
3.7.3 关键帧动画
xml
<Storyboard x:Key="BounceAnimation">
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(Button.RenderTransform).(TranslateTransform.Y)"
RepeatBehavior="Forever">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="0"/>
<EasingDoubleKeyFrame KeyTime="0:0:0.2" Value="-30">
<EasingDoubleKeyFrame.EasingFunction>
<QuadraticEase EasingMode="EaseOut"/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame KeyTime="0:0:0.4" Value="0">
<EasingDoubleKeyFrame.EasingFunction>
<BounceEase Bounces="2" Bounciness="2"/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
3.8 综合示例:自定义控件库
csharp
// Controls/ToggleSwitch.cs
[TemplatePart(Name = "PART_Track", Type = typeof(Border))]
[TemplatePart(Name = "PART_Thumb", Type = typeof(Thumb))]
public class ToggleSwitch : Control
{
public static readonly DependencyProperty IsCheckedProperty =
DependencyProperty.Register(nameof(IsChecked), typeof(bool), typeof(ToggleSwitch),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnIsCheckedChanged));
public static readonly DependencyProperty CheckedContentProperty =
DependencyProperty.Register(nameof(CheckedContent), typeof(object), typeof(ToggleSwitch),
new PropertyMetadata("ON"));
public static readonly DependencyProperty UncheckedContentProperty =
DependencyProperty.Register(nameof(UncheckedContent), typeof(object), typeof(ToggleSwitch),
new PropertyMetadata("OFF"));
public static readonly DependencyProperty CheckedBrushProperty =
DependencyProperty.Register(nameof(CheckedBrush), typeof(Brush), typeof(ToggleSwitch),
new PropertyMetadata(Brushes.Green));
public static readonly DependencyProperty UncheckedBrushProperty =
DependencyProperty.Register(nameof(UncheckedBrush), typeof(Brush), typeof(ToggleSwitch),
new PropertyMetadata(Brushes.Gray));
static ToggleSwitch()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(ToggleSwitch),
new FrameworkPropertyMetadata(typeof(ToggleSwitch)));
}
public bool IsChecked
{
get => (bool)GetValue(IsCheckedProperty);
set => SetValue(IsCheckedProperty, value);
}
public object CheckedContent
{
get => GetValue(CheckedContentProperty);
set => SetValue(CheckedContentProperty, value);
}
public object UncheckedContent
{
get => GetValue(UncheckedContentProperty);
set => SetValue(UncheckedContentProperty, value);
}
public Brush CheckedBrush
{
get => (Brush)GetValue(CheckedBrushProperty);
set => SetValue(CheckedBrushProperty, value);
}
public Brush UncheckedBrush
{
get => (Brush)GetValue(UncheckedBrushProperty);
set => SetValue(UncheckedBrushProperty, value);
}
private Border _track;
private Thumb _thumb;
private static void OnIsCheckedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (ToggleSwitch)d;
control.UpdateVisualState((bool)e.NewValue);
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_track = GetTemplateChild("PART_Track") as Border;
_thumb = GetTemplateChild("PART_Thumb") as Thumb;
if (_thumb != null)
{
_thumb.DragCompleted += Thumb_DragCompleted;
}
UpdateVisualState(IsChecked);
}
private void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)
{
IsChecked = !IsChecked;
}
private void UpdateVisualState(bool isChecked)
{
if (_track != null)
{
_track.Background = isChecked ? CheckedBrush : UncheckedBrush;
}
if (_thumb != null)
{
var margin = _thumb.Margin;
if (isChecked)
{
var trackWidth = _track?.ActualWidth ?? 100;
var thumbWidth = _thumb.ActualWidth;
margin.Left = trackWidth - thumbWidth - margin.Right;
}
else
{
margin.Left = 0;
}
_thumb.Margin = margin;
}
}
}
xml
<!-- Themes/Generic.xaml(添加到现有文件)-->
<Style TargetType="local:ToggleSwitch">
<Setter Property="Height" Value="30"/>
<Setter Property="Width" Value="80"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:ToggleSwitch">
<Border x:Name="PART_Track"
Background="{TemplateBinding UncheckedBrush}"
CornerRadius="15">
<Grid>
<!-- 状态文本 -->
<TextBlock x:Name="StatusText"
Text="{TemplateBinding UncheckedContent}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="White"
FontWeight="Bold"/>
<!-- 滑块 -->
<Thumb x:Name="PART_Thumb"
Width="25" Height="25"
Cursor="Hand">
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="White" CornerRadius="12.5">
<Border.Effect>
<DropShadowEffect BlurRadius="3" ShadowDepth="1"/>
</Border.Effect>
</Border>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="StatusText" Property="Text"
Value="{Binding CheckedContent, RelativeSource={RelativeSource TemplatedParent}}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
3.9 常见错误与陷阱
错误1:ControlTemplate 中忘记设置 TemplateBinding
xml
<!-- ❌ 错误:硬编码值,样式无法覆盖 -->
<ControlTemplate TargetType="Button">
<Border Background="LightBlue" /> <!-- 固定颜色 -->
</ControlTemplate>
<!-- ✅ 正确:使用 TemplateBinding -->
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" />
</ControlTemplate>
错误2:DataTemplate 中绑定路径错误
xml
<!-- ❌ 错误:没有指定数据上下文 -->
<DataTemplate>
<TextBlock Text="{Binding Name}"/> <!-- 绑定的是 DataContext,不是数据项 -->
</DataTemplate>
<!-- ✅ 正确:在 ItemsControl 的 ItemTemplate 中 -->
<ListBox ItemsSource="{Binding Users}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"/> <!-- 绑定 User 对象的 Name -->
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
错误3:自定义控件中忘记注册 DefaultStyleKey
csharp
// ❌ 错误
static NumericUpDown()
{
// 没有注册,控件会使用默认样式
}
// ✅ 正确
static NumericUpDown()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(NumericUpDown),
new FrameworkPropertyMetadata(typeof(NumericUpDown)));
}