动画 Expander 控件
一个自定义内容控件,可以随着动画展开/折叠。
引言
在 Whitebox Security 开发我们的软件时,我们遇到了一个需求,需要构建一个可以展开和折叠的控件,同时包含不同的内容。
对该控件的要求是
- 最小化时占用最小屏幕空间。
- 最大化时,用户可以自定义控件的大小。
- 如果用户折叠和展开控件,它将扩展到用户设置的最后一次展开的长度(高度或宽度)。
- 当控件的大小改变时,它会调整屏幕上其他控件的大小。
- 展开和折叠操作将是动画化的。
- 控件可以轻松配置为向不同方向展开。
要求
该项目使用 Visual Studio 2008 编写,基于 .NET 3.5 SP1 构建。
先决知识
要使用该控件,您应该具备基本的 WPF 知识。
如果您想了解控件是如何编写的,您应该熟悉
- 自定义控件,请参阅 MSDN 控件创作概述。
- 动画,请参阅 MSDN 动画概述。
- 触发器、样式和模板,请参阅 MSDN 上的 样式和模板。
演示应用程序
演示应用程序由一个使用 Grid
分成四部分的 Window
组成。在屏幕的每个四分之一处,都放置了一个 AnimatingExpanderControl
,其中包含一些内容。
这四个控件中的每一个都展示了一种略有不同的配置。一些不同的地方包括
- 展开方向。
- 初始状态为折叠或展开。
- 使用动画进行展开和折叠。
- 限制内容的宽度/高度的最大值。
使用控件
这是四个控件之一的 XAML
<!--Expand Left-->
<Border
BorderBrush="Black"
BorderThickness="2"
Grid.Row="1"
Grid.Column="1"
Padding="3">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Viewbox
Stretch="Uniform"
Grid.Column="0">
<Image
Source="..\Images\cat_rope.gif">
</Image>
</Viewbox>
<local:AnimatingExpanderControl
ExpandDirection="Left"
Style="{StaticResource AnimatingExpanderControlStyle}"
Grid.Column="1"
Title="Expand Content Left"
>
<Viewbox
Stretch="Uniform">
<StackPanel
Orientation="Vertical">
<Button>
Content
</Button>
<TextBlock>
Some more content
</TextBlock>
<StackPanel
Orientation="Vertical">
<RadioButton Content="First Choice"/>
<RadioButton Content="Second Choice"/>
<RadioButton Content="Third Choice"/>
</StackPanel>
</StackPanel>
</Viewbox>
</local:AnimatingExpanderControl>
</Grid>
</Border>
正如您所见,该控件定义了一个 ViewBox
作为其内容。这是为了让内容根据其所占用的空间进行缩放。该控件的“邻近”内容也放置在 ViewBox
中,原因相同。
控件上可以设置的自定义依赖属性有
ExpandDirection
- 确定展开的方向。值可以是Up
、Down
、Left
和Right
。IsCollapsed
- 一个标志,显示控件初始化时是折叠还是未折叠。InitialExpandedLength
- 用户可以设置此项来控制展开器展开的初始值(宽度或高度)。AnimationEnabled
- 一个标志,用于启用或禁用控件上的展开和折叠动画。Title
- 此属性设置控件的标题,该标题显示在展开/折叠按钮旁边。
在屏幕上动态调整不同组件大小的一种方法是,将内容放置在一个 Grid
中,其中 AnimatingExpanderControl
位于 Width
设置为“Auto
”的 Column
(或 Row
)中,而屏幕的其余内容位于 Width
为“*”的 Column
中。此外,还使用了 ViewBox
来容纳内容。
要限制控件可以展开的最大尺寸,请在控件的 **Content** 上设置 MaxWidth
(或 MaxHeight
)。在演示中,向右展开的控件上可以看到这一点。
控件的结构
<Border BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Background="{TemplateBinding Background}"
CornerRadius="3"
SnapsToDevicePixels="true">
<DockPanel
Name="TemplateDockPanel"
DataContext="{Binding RelativeSource={RelativeSource TemplatedParent}}">
<Thumb
DockPanel.Dock="Top"
Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}"
BorderBrush="White"
x:Name="PART_Thumb"
Cursor="SizeNS"
DragDelta="GridSplitter_DragDelta"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Margin="5,0,5,5"
IsEnabled="{Binding ElementName=PART_CheckBox, Path=IsChecked, Mode=OneWay}"
Style="{StaticResource GridSplitterStyle}"/>
<ContentPresenter
DockPanel.Dock="Bottom"
Margin="{TemplateBinding Padding}"
x:Name="PART_Content"/>
<CheckBox
x:Name="PART_CheckBox"
Checked="BottomCB_Checked"
Unchecked="BottomCB_UnChecked"
Style="{StaticResource CheckBox_Expander}"
Margin="1"
Content="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Title}"
Foreground="{TemplateBinding Foreground}"
Padding="3"
FontFamily="{TemplateBinding FontFamily}"
FontSize="{TemplateBinding FontSize}"
FontStyle="{TemplateBinding FontStyle}"
FontStretch="{TemplateBinding FontStretch}"
FontWeight="{TemplateBinding FontWeight}">
</CheckBox>
</DockPanel>
</Border>
该控件由三个组件组成,放置在 DockPanel
中。这些组件在控件的 ControlTemplate
中设置
- 一个
Thumb
,模拟一个GridSplitter
。GridSplitter
允许通过拖动来调整控件的大小。如果控件折叠,GridSplitter
将被禁用。 - 控件的内容,通过
ContentPresenter
显示。 - 一个
CheckBox
,模拟一个带箭头的按钮。按下时,控件将展开/折叠,箭头方向会改变。控件的Title
依赖属性显示在CheckBox
旁边。
一些基本样式已应用于 Thumb
和 Checkbox
,这里不详述。
此外,在 ControlTemplate
中还设置了一些触发器
<ControlTemplate.Triggers>
<!--Trigger for up direction-->
<Trigger
Property="ExpandDirection"
Value="Up">
<Setter Property="DockPanel.Dock"
Value="Top"
TargetName="PART_Thumb"/>
<Setter Property="DockPanel.Dock"
Value="Bottom"
TargetName="PART_Content"/>
<Setter Property="HorizontalAlignment"
Value="Stretch"
TargetName="PART_Thumb"/>
<Setter Property="VerticalAlignment"
Value="Bottom"
TargetName="PART_Thumb"/>
<Setter Property="Height"
Value="5"
TargetName="PART_Thumb"/>
<Setter Property="HorizontalAlignment"
Value="Center"
TargetName="PART_CheckBox"/>
</Trigger>
<!--Trigger for down direction-->
<Trigger
Property="ExpandDirection"
Value="Down">
<Setter Property="DockPanel.Dock"
Value="Bottom"
TargetName="PART_Thumb"/>
<Setter Property="DockPanel.Dock"
Value="Top"
TargetName="PART_Content"/>
<Setter Property="HorizontalAlignment"
Value="Stretch"
TargetName="PART_Thumb"/>
<Setter Property="VerticalAlignment"
Value="Bottom"
TargetName="PART_Thumb"/>
<Setter Property="Height"
Value="5"
TargetName="PART_Thumb"/>
<Setter Property="HorizontalAlignment"
Value="Center"
TargetName="PART_CheckBox"/>
<Setter Property="Style"
TargetName="PART_CheckBox"
Value="{StaticResource CheckBoxUpSideDown_Expander}"
/>
</Trigger>
<!--Trigger for left direction-->
<Trigger
Property="ExpandDirection"
Value="Left">
<Setter Property="DockPanel.Dock"
Value="Left"
TargetName="PART_Thumb"/>
<Setter Property="DockPanel.Dock"
Value="Right"
TargetName="PART_Content"/>
<Setter Property="HorizontalAlignment"
Value="Center"
TargetName="PART_Thumb"/>
<Setter Property="VerticalAlignment"
Value="Stretch"
TargetName="PART_Thumb"/>
<Setter Property="Cursor"
Value="SizeWE"
TargetName="PART_Thumb"/>
<Setter Property="Width"
Value="5"
TargetName="PART_Thumb"/>
<Setter Property="LayoutTransform"
TargetName="PART_CheckBox"
>
<Setter.Value>
<RotateTransform Angle="-90"/>
</Setter.Value>
</Setter>
<Setter Property="VerticalAlignment"
Value="Center"
TargetName="PART_CheckBox"/>
</Trigger>
<!--Trigger for right direction-->
<Trigger
Property="ExpandDirection"
Value="Right">
<Setter Property="DockPanel.Dock"
Value="Right"
TargetName="PART_Thumb"/>
<Setter Property="DockPanel.Dock"
Value="Left"
TargetName="PART_Content"/>
<Setter Property="HorizontalAlignment"
Value="Center"
TargetName="PART_Thumb"/>
<Setter Property="VerticalAlignment"
Value="Stretch"
TargetName="PART_Thumb"/>
<Setter Property="Cursor"
Value="SizeWE"
TargetName="PART_Thumb"/>
<Setter Property="Width"
Value="5"
TargetName="PART_Thumb"/>
<Setter Property="LayoutTransform"
TargetName="PART_CheckBox"
>
<Setter.Value>
<RotateTransform Angle="90"/>
</Setter.Value>
</Setter>
<Setter Property="VerticalAlignment"
Value="Center"
TargetName="PART_CheckBox"/>
</Trigger>
<Trigger Property="IsEnabled"
Value="false">
<Setter Property="Foreground"
Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}"/>
</Trigger>
</ControlTemplate.Triggers>
这些触发器基本上会根据控件的 ExpandDirection
依赖属性设置不同的属性。
代码隐藏部分
这是处理用户在控件折叠时单击展开/折叠按钮(实际上是 CheckBox
)的代码
private void BottomCB_Checked(object sender, RoutedEventArgs e) {
//determine the DP that needs to be changed
DependencyProperty actualContentLengthDP;
this.DetermineLengthDPToChange(out actualContentLengthDP);
//change the determined DP with animation
if (AnimationEnabled) {
this.ExpandWithAnimation(actualContentLengthDP);
}
//change the determined DP without animation
else {
this.ActualContent.SetValue(actualContentLengthDP, this.ExpandedValue);
}
this.IsCollapsed = false;
}
如果禁用动画,实际内容的高度(或宽度)将设置为 ExpandedValue
。如果这是控件第一次展开,ExpandedValue
将从 InitialExpandedLength
依赖属性获取。否则,使用的值是控件通过“GridSplitter
”调整到的最后一次高度(宽度)。
下面的代码处理使用动画时的展开
private void ExpandWithAnimation(
DependencyProperty actualContentLengthDP
) {
Storyboard sb = new Storyboard();
DoubleAnimation expandAnimation = new DoubleAnimation();
expandAnimation.From = 0;
//Content is collapsed. Expand to last saved this.ExpandedValue.
expandAnimation.To = this.ExpandedValue;
expandAnimation.Duration = new TimeSpan(0, 0, 0, 0, _animationDuration);
sb.Children.Add(expandAnimation);
sb.FillBehavior = FillBehavior.Stop;
this.BeginChangeSizeAnimation(
sb,
expandAnimation,
actualContentLengthDP
);
}
private void BeginChangeSizeAnimation(
Storyboard sb,
DoubleAnimation lengthAnimation,
DependencyProperty actualContentLengthDP
) {
//execute Animation
Storyboard.SetTargetProperty(lengthAnimation,
new PropertyPath(actualContentLengthDP.Name));
if (null != this.ActualContent) {
sb.Completed += delegate {
this.ActualContent.SetValue(actualContentLengthDP,
lengthAnimation.To.Value);
};
sb.Begin(this.ActualContent);
}
}
这一次,通过使用简单的 DoubleAnimation
将内容的高度/宽度设置为展开值。动画完成后,一个委托会将实际内容宽度/高度的值设置为 .To
值。
一旦控件展开,用户就可以调整其大小。这是通过一个 Thumb
来实现的,它模拟了一个 GridSplitter
。为了确保 Thumb
在控件折叠时无法拖动,请回顾“控件结构”部分中的以下一行
IsEnabled="{Binding ElementName=PART_CheckBox, Path=IsChecked, Mode=OneWay}"
Thumb
的 IsEnabled
属性绑定到 CheckBox
的 IsChecked
依赖属性。这意味着只有当 CheckBox
IsChecked
(即控件已展开)时,Thumb
才 IsEnabled
。
这是 thumb 拖动事件的处理程序
private void GridSplitter_DragDelta(object sender, DragDeltaEventArgs e) {
if (null != this.ActualContent) {
//set this.ExpandedValue to new dragged value, then resize the content.
//if new value is smaller then zero, or bigger the Content's Max Length,
//reset length accordingly
switch (this.ExpandDirection) {
case ExpandDirection.Down:
this.ExpandedValue = this.ActualContent.Height + e.VerticalChange;
this.CheckExpandedValue(this.ActualContent.MaxHeight);
this.ActualContent.Height = this.ExpandedValue;
break;
case ExpandDirection.Up:
this.ExpandedValue = this.ActualContent.Height - e.VerticalChange;
this.CheckExpandedValue(this.ActualContent.MaxHeight);
this.ActualContent.Height = this.ExpandedValue;
break;
case ExpandDirection.Left:
this.ExpandedValue = this.ActualContent.Width - e.HorizontalChange;
this.CheckExpandedValue(this.ActualContent.MaxWidth);
this.ActualContent.Width = this.ExpandedValue;
break;
case ExpandDirection.Right:
this.ExpandedValue = this.ActualContent.Width + e.HorizontalChange;
this.CheckExpandedValue(this.ActualContent.MaxWidth);
this.ActualContent.Width = this.ExpandedValue;
break;
}
}
}
该方法首先确定垂直或水平的变化,并相应地设置 ExpandedValue
。然后它验证新的 ExpandedValue
是否合法。接下来,它将控件内容的 Height
或 Width
属性设置为新的 ExpandedValue
。下次控件折叠然后再次展开时,它将展开到保存的 ExpandedValue
。
这是检查 ExpandedValue
是否合法并重置其合法性的代码
private void CheckExpandedValue(double MaxLength) {
if (this.ExpandedValue < 0) {
this.ExpandedValue = 0;
}
if (this.ExpandedValue > MaxLength) {
this.ExpandedValue = MaxLength;
}
}
您有什么看法?
这是我为 CodeProject 撰写的第一个文章。如果您觉得这篇文章有帮助,请告诉我您的想法。
致谢
在编写 ExpandDirection
的触发器时,我受到了一些微软原始 WPF 控件模板的启发。