【WPF学习】第五十章 故事板
正如上一章介绍,WPF动画通过一组动画类(Animation类)表示。使用少数几个熟悉设置相关信息,如开始值、结束值以及持续时间。这显然使得它们非常适合于XAML。不是很清晰的时:如何为特定的事件和属性关联动画,以及如何在正确的时间触发动画。
在所有声明式动画中都会用到如下两个要素:
- 故事板。故事板是BeginAnimation()方法的XAML等价物。通过故事板将动画指定到合适的元素和属性。
- 事件触发器。事件触发器响应属性变化或事件(如按钮的Click事件),并控制故事板。例如,为了开始动画,事件触发器必须开始故事板。
一、故事板
故事板是增强的事件线,可用来分组多个动画,而且具有控制动画播放的能力——暂停、停止以及改变播放位置。然而,Storyboard类提供的最基本功能是,能够使用TargetProperty和TargetName属性指向某个特定属性和特定元素。换句话说,故事板在动画和希望应用动画的属性之间架起了一座桥梁。
下面的标记演示了如何定义用于管理DoubleAnimation的故事板:
<Storyboard TargetName="cmdGrow" TargetProperty="Width"> <DoubleAnimation From="160" To="300" Duration="0:0:5"></DoubleAnimation> </Storyboard>
TargetName和TargetProperty都是附加属性。这意味着可以直接将他们应用于动画,如下所示:
<Storyboard > <DoubleAnimation Storyboard.TargetName="cmdGrow" Storyboard.TargetProperty="Width" From="160" To="300" Duration="0:0:5"> </DoubleAnimation> </Storyboard>
上面的语法更常用,因为通过这种语法可在同一个故事板中放置几个动画,并且每个动画可用于不同的元素和属性。
定义故事板是创建动画的第一步。为让故事板实际运行起来,还需要有事件触发器。
二、事件触发器
在“【WPF学习】第三十七章 触发器 ”时第一次提到事件触发器。样式提供了一种将事件触发器关联到元素的方法。然而,可在如下4个位置定义事件触发器:
- 在样式中(Styles.Triggers集合)
- 在数据目标中(DataTemplate.Triggers集合)
- 在控件模板中(ControlTemplate.Triggers集合)
- 直接在元素中定义事件触发器(FrameworkElement.Triggers集合)
当创建事件触发器时,需要制定开始出发其的路由事件和由触发器执行的一个或多个动作。对于动画,最常用的动作是BeginStoryboard,该动作相当于调用BeginAnimation()方法。
下面的示例使用按钮的Triggers集合为Click事件关联某个动画。当单击按钮时,该动画增长按钮:
<Button Margin="10" Name="cmdGrow" Height="40" Width="160" HorizontalAlignment="Center" VerticalAlignment="Center"> <Button.Triggers> <EventTrigger RoutedEvent="Button.Click"> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Width" To="300" Duration="0:0:5"> </DoubleAnimation> </Storyboard> </BeginStoryboard> </EventTrigger> </Button.Triggers> <Button.Content> Click and Make Me Grow </Button.Content> </Button>
Storyboard.TargetProperty属性指定了希望改变的属性(在这个示例中是Width属性)。如果没有提供类的名称,故事板使用其父元素,在此使用的是希望扩展的按钮。如果希望设置附加属性(如Canvas.Left或Canvas.Top),需要在括号中封装整个属性,如下所示:
<DoubleAnimation Storyboard.TargetName="(Canvas.Top)" .../>
在这个示例中需不需要使用Storyboard.TargetName属性。当忽略该属性时,故事板使用父元素,在此是按钮。
在这个示例中使用的声明式方法和前面演示的只使用代码的方法存在如下区别:To值被硬编码为300个单位,而不是相对于包含按钮的窗口的尺寸设置。如果希望使用窗口宽度,需要使用数据绑定表达式,如下所示:
<DoubleAnimation Storyboard.TargetProperty="Width" To="{Binding ElementName=cmdGrow, Path=Width}" Duration="0:0:5"> </DoubleAnimation>
这仍不能准确地得到所希望的结果。在此,按钮从当前尺寸增大到窗口的完整宽度。只使用代码的方法使用一种简单的计算,将按钮扩大到比整个窗口宽度小30个单位的值。但XAML不支持内联计算。一种解决方法是构建能够自动完成工作的IValueConverter接口。如下所示的示例:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows.Data; namespace Animation { public class ArithmeticConverter : IValueConverter { private const string ArithmeticParseExpression = "([+\\-*/]{1,1})\\s{0,}(\\-?[\\d\\.]+)"; private Regex arithmeticRegex = new Regex(ArithmeticParseExpression); public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { if (value is double && parameter != null) { string param = parameter.ToString(); if (param.Length > 0) { Match match = arithmeticRegex.Match(param); if (match != null && match.Groups.Count == 3) { string operation = match.Groups[1].Value.Trim(); string numericValue = match.Groups[2].Value; double number = 0; if (double.TryParse(numericValue, out number)) // this should always succeed or our regex is broken { double valueAsDouble = (double)value; double returnValue = 0; switch (operation) { case "+": returnValue = valueAsDouble + number; break; case "-": returnValue = valueAsDouble - number; break; case "*": returnValue = valueAsDouble * number; break; case "/": returnValue = valueAsDouble / number; break; } return returnValue; } } } } return null; } public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { throw new NotImplementedException(); } } }
<Window x:Class="Animation.XamlAnimation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:Animation" Title="XamlAnimation" Height="300" Width="300"> <Window.Resources> <local:ArithmeticConverter x:Key="converter"></local:ArithmeticConverter> </Window.Resources> <Button Padding="10" Name="cmdGrow" Height="40" Width="160" HorizontalAlignment="Center" VerticalAlignment="Center"> <Button.Triggers> <EventTrigger RoutedEvent="Button.Click"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Width" To="{Binding ElementName=window,Path=Width,Converter={StaticResource converter},ConverterParameter=-30}" Duration="0:0:5"></DoubleAnimation> <DoubleAnimation Storyboard.TargetProperty="Height" To="{Binding ElementName=window,Path=Height,Converter={StaticResource converter},ConverterParameter=-50}" Duration="0:0:5"></DoubleAnimation> </Storyboard> </BeginStoryboard> </EventTrigger.Actions> </EventTrigger> </Button.Triggers> <Button.Content> Click and Make Me Grow </Button.Content> </Button> </Window>
使用样式关联触发器
FrameworkElement.Triggers集合有点奇怪,它仅支持事件触发器。其他触发器集合(Style.Triggers、DataTemplate.Triggers与ControlTemplate.Triggers)的功能更强大,他们支持三种基本类型的WPF触发器:属性触发器、数据触发器以及事件触发器。
使用事件触发器是关联动画的最常用方式,但并不是唯一的选择。如果使用位于样式、数据模板或控件模板中的Triggers集合,还可创建当属性值发生变化时进行响应的属性触发器。例如,下面的样式复制了前面显示的示例。当IsPressed属性为true时,该样式触发一个故事板:
<Window.Resources> <Style x:Key="GrowButtonStyle"> <Style.Triggers> <Trigger Property="Button.IsPressed" Value="True"> <Trigger.EnterActions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Width" To="250" Duration="0:0:5"></DoubleAnimation> </Storyboard> </BeginStoryboard> </Trigger.EnterActions> </Trigger> </Style.Triggers> </Style> </Window.Resources>
可使用两种方式为属性触发器关联动作。可使用Trigger.EnterActions设置当属性改变到指定的数值时希望执行的动作(在上面的示例中,当IsPressed属性值变为true时),也可以使用Trigger.ExitActions设置当属性改变回原来的数值时执行的动作(当IsPressed属性的值变回false时)。这是一种封装一堆互补动画的简便方法。
下面的按钮使用上面显示的样式:
<Button Padding="10" Name="cmdGrow" Height="40" Width="160" Style="{StaticResource GrowButtonStyle}" HorizontalAlignment="Center" VerticalAlignment="Center"> Click and Make Me Grow </Button>
请记住,不见得在样式中使用属性触发器。也可使用事件触发器,就像在前面介绍的那样。最后,不见得以与使用样式的按钮相分离的方式定义样式(也可使用内联样式设置Button.Style属性)。但是这种两部分相分离的方法更常用,并且提供了为多个元素应用相同的灵活性。
三、重叠动画
故事板提供了改变处理重叠动画方式的能力——换句话说,决定第二个动画何时被应用到已经具有一个正在运行的动画的属性上。可使用BeginStoryboard.HandoffBehavior属性改变处理重叠动画的方式。
通常,当两个动画相互重叠时,第二个动画会立即覆盖第一个动画。这种行为就是所谓的“快照并替换”(由HandoffBehavior枚举中的SnapshotAndReplace值表示)。当第二个动画开始时,第二个动画获取属性当前值(基于第一个动画)的快照,停止动画,并用新动画替换第一个动画。
另一个HandoffBehavior选项是Compose,这种方式将第二个动画融合到第一个动画的时间线中。例如,分析ListBox示例的修改版本,当缩小按钮时使用HandoffBehavior.Compose:
<EventTrigger RoutedEvent="ListBoxItem.MouseLeave"> <EventTrigger.Actions> <BeginStoryboard HandoffBehavior="Compose"> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="FontSize" BeginTime="0:0:0.5" Duration="0:0:0.2"></DoubleAnimation> </Storyboard> </BeginStoryboard> </EventTrigger.Actions> </EventTrigger>
现在,如果将鼠标移到ListBoxItem对象上,然后在移开,将看到不同的行为。当鼠标移开项时,项会继续扩张,这种行为非常明显,知道第二个动画到达其0.5秒得开始时间延迟,然后,第二个动画会缩小按钮。如果不使用Compose行为,在第二个动画开始之前的0.5秒得时间间隔内,按钮会处于等待状态,并固定为当前尺寸。
使用组合的HandoffBehavior行为需要更大开销。这是因为当第二个动画开始时,用于运行原来动画的时钟不能被释放。相反,这个时钟会继续保持存活,知道ListBoxItem对象被垃圾回收或为相同的属性应用新的动画为止。
四、同步的动画
Storyboard类间接地继承自TimelineGroup类,所以Storyboard类能包含多个动画,最令人高兴的是,这些动画可以作为一组进行管理——这意味着他们在同一时间开始。
为查看这个一个示例,分析下面的故事板。它开始两个动画,一个动画用于按钮的Width属性,而另一个动画用于按钮的Height属性。因为动画被分组到故事板中,它们共同增加按钮的尺寸,所以可得到比在代码中通过简单地多次调用BeginAnimation()方法得到的效果更趋向同步的效果。
<EventTrigger RoutedEvent="Button.Click"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Width" To="300" Duration="0:0:5"></DoubleAnimation> <DoubleAnimation Storyboard.TargetProperty="Height" To="300" Duration="0:0:5"></DoubleAnimation> </Storyboard> </BeginStoryboard> </EventTrigger.Actions> </EventTrigger>
在这个示例中,两个动画具有相同的持续时间,但这并不是必须的,对于在不同时间结束的动画,唯一需要考虑的是它们的FillBehavior行为。如果一个动画的FillBehavior属性被设置为HoldEnd,它会保持值直到故事板中所有的动画都结束。如果故事板的FillBehavior属性是HoldEnd,最后那个动画的值将被永久保存(直到使用新的动画替换这个动画或手动删除了这个动画)。
上一章列出的Timeline类的属性开始变得特别有用。例如,可通过SpeedRatio属性使故事板中的某个动画比其他动画更快,也可以使用BeginTime属性相对于一个动画来编译另一个动画的开始时间,使该动画在特定的时间点开始。
五、控制播放
到目前位置,已在事件触发器中使用了一个动作——加载动画的BeginStoryboard动作。然而,一旦创建故事板,就可以用在其他动作控制故事板。这些工作类都继承自ControllableStoryboardAction类,下表列出了这些类。
表 控制故事板的动作类
帮助文档中没有记载会妨碍使用这些动作的内容。为成功地执行这些动作,必须在同一个Triggers集合中定义所有触发器。如果将BeginStoryboard动作的触发器和PauseStoryboard动作的触发器放置到不同集合中,PauseStoryboard动作就无法工作。为查看需要使用的设计,分析示例是有帮助的。
例如,分析下图中显示的窗口。该窗口使用一个网格在完全相同的位置精确地重叠了两个Image元素。最初,只有最顶部的图像可见。但当动画运行是,该图像从1到0逐渐地增加透明度,最终使夜间的场景完全盖过白天场景。效果就像是图像从白天变换到黑夜,就像连续的随时间流逝的照片。
下面的标记定义了包含两个图像的Grid控件:
<Grid> <Image Source="night.jpg"></Image> <Image Source="day.jpg" Name="imgDay"></Image> </Grid>
下面是从一幅图像淡入到另一幅图像的动画:
<DoubleAnimation Storyboard.TargetName="imgDay" Storyboard.TargetProperty="Opacity" From="1" To="0" Duration="0:0:10"></DoubleAnimation>
为增加这个示例的趣味性,还在底部提供了几个用于控制动画播放的按钮。使用这些按钮,可执行典型的媒体播放器动作,如暂停、恢复播放以及停止(可添加其他按钮来改变速度系数以及挑选特定的时间)。
下面的标记定义了这些按钮:
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Center"> <Button Name="cmdStart">Start</Button> <Button Name="cmdPause">Pause</Button> <Button Name="cmdResume">Resume</Button> <Button Name="cmdStop">Stop</Button> <Button Name="cmdMiddle">Move To Middle</Button> </StackPanel>
通常,可选择在每个按钮的Triggers集合中放置事件触发器。然而,在前面已解释过,对于动画这种方法不能工作。最简单的解决方法是在一个地方定义所有事件触发器,例如,在包含元素的Triggers集合中,使用EventTrigger.SourceName属性关联这些事件触发器。只要SourceName属性和为按钮设置的Name属性相匹配,触发器就会应用到恰当的按钮上。
这个示例中,可使用包含这些按钮的StackPanel面板的Triggers集合。然而,使用顶级元素(在这个示例中是窗口)的Triggers集合通常最简单。这样,就可在用户界面中将按钮移到不同的位置,而不会禁用他们的功能。
<Window.Triggers> <EventTrigger SourceName="cmdStart" RoutedEvent="Button.Click"> <BeginStoryboard Name="fadeStoryboardBegin"> <Storyboard> <DoubleAnimation Storyboard.TargetName="imgDay" Storyboard.TargetProperty="Opacity" From="1" To="0" Duration="0:0:10"></DoubleAnimation> </Storyboard> </BeginStoryboard> </EventTrigger> <EventTrigger SourceName="cmdPause" RoutedEvent="Button.Click"> <PauseStoryboard BeginStoryboardName="fadeStoryboardBegin"> </PauseStoryboard> </EventTrigger> <EventTrigger SourceName="cmdResume" RoutedEvent="Button.Click"> <ResumeStoryboard BeginStoryboardName="fadeStoryboardBegin"></ResumeStoryboard> </EventTrigger> <EventTrigger SourceName="cmdStop" RoutedEvent="Button.Click"> <StopStoryboard BeginStoryboardName="fadeStoryboardBegin"></StopStoryboard> </EventTrigger> <EventTrigger SourceName="cmdMiddle" RoutedEvent="Button.Click"> <SeekStoryboard BeginStoryboardName="fadeStoryboardBegin" Offset="0:0:5"></SeekStoryboard> </EventTrigger> </Window.Triggers>
注意,必须为BeginStoryboard动作指定名称(在这个示例中,名称是fadeStoryboardBegin)。其他触发器通过为BeginStoryboardName属性指定这个名称,连接到相同的故事板。
当使用故事板动作时将遇到限制。他们提供的属性(如SeekStoryboard.Offset和SetStoryboardSpeedRatio.SpeedRatio属性)不是依赖性项属性,这会限制使用数据绑定表达式。例如,不能自动读取Slider.Value属性值并将其应用到SetStoryboardSpeedRatio.SpeedRatio动作,因为SpeedRatio属性不接受数据绑定表达式。可能认为通过使用Storyboard对象的SpeedRatio属性来解决这个问题。但这是行不同的,当动画开始时,读取SpeedRatio值并创建一个动画时钟。此后,即使改变了SpeedRatio属性的值,动画也仍会保持正常的速度。
如果希望动态调整速度或位置,唯一的解决方法是使用代码。Storyboard类中的方法提供了与故事板触发器相同的功能,包括Begin()、Pause()、Resume()、Seek()、Stop()、SkipToFill()、SetSpeedRatio()以及Remove()方法。
要访问Storyboard对象,必须在标记中设置其Name属性:
<Storyboard Name="fadeStoryboard">
现在只需要编写恰当的事件处理程序,并使用Storyboard对象的方法(请记住,简单地改变故事板的属性(比如SpeedRatio)是没有任何效果的,它们仅配置当动画开始时将要使用的设置)。
当拖动Slider控件上的滑块时,下面的事件处理程序会进行响应。该事件处理程序获取滑动条的值(范围是0~3),并使用该数值应用新的速率:
private void sldSpeed_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { fadeStoryboard.SetSpeedRatio(this, sldSpeed.Value); }
注意,SetSpeedRatio()方法需要两个参数。第一个参数是顶级动画容器(在这个示例中,是指当前窗口)。所有故事板方法都需要这个引用。第二个参数是新的速率。
六、监视动画进度
上一节显示的动画播放器仍缺少一个在大多数媒体播放器中都具有的功能——确定当前位置的能力。为使这个动画播放器更加精致,可添加一些文本来显示时间的流逝,并添加进度条来指示动画只需的速度。下图显示了使用这两个细节的动画播放器的修改版。
添加这些细节相当简单。首先需要使用TextBlock元素显示时间,而后需要使用ProgressBar控件显示图形进度条,可能认为,可使用数据绑定表达式设置TextBlock值和ProgressBar内容,但这是行不同的。因为从故事板中检索当前动画时钟相关的唯一方式是使用方法,如GetCurrentTime()和GetCurrentProgress()。无法从属性中获取相同的信息。
最简单的解决方法是响应下表中列出的某个故事板事件。
表 故事板事件
名 称 | 说 明 |
Completed | 动画已经到达终点 |
CurrentGlobalSpeedInvalidated | 速度发生了变化,或者动画被暂停、重新开始、停止或移到某个新的位置。当动画时钟反转时(在可反转动画的终点),以及当动画加速和减速时,也会引发该事件 |
CurrentStateInvalidated | 动画已经开始或结束 |
CurrentTimeInvalidated | 动画时钟已经向前移动了一个步长,正在更改动画。当动画开始、停止或结束时也会引发该事件 |
RemoveRequested | 动画正在被移除。使用动画的属性随后会返回为原来的值 |
这个示例需要使用CurrentTimeInvalidated事件,每次向前移动动画时钟都会引发该事件(通常,每秒移动60此,但如果执行的代码需要更长时间,可能会丢失时钟刻度)。
当引发CurrentTimeInvalidated事件时,发送者是Clock对象(Clock类位于System.Windows.Media.Animation名称空间)。可以通过Clock对象检索当前时间,当前时间使用TimeSpan对象表示;并且可检索当前进度,当前进度使用0~1之间的数值表示。
下面的代码更新标签和进度条:
private void storyboard_CurrentTimeInvalidated(object sender, EventArgs e) { // Sender is the clock that was created for this storyboard. Clock storyboardClock = (Clock)sender; if (storyboardClock.CurrentProgress == null) { lblTime.Text = "[[ stopped ]]"; progressBar.Value = 0; } else { lblTime.Text = storyboardClock.CurrentTime.ToString(); progressBar.Value = (double)storyboardClock.CurrentProgress; } }