[转载]WPF故事版
本页内容
背景
“Longhorn”表示子系统(代号“Avalon”)添加到用户界面工具箱的一个重要功能就是动画。您可能会问,“为什么需要在我的用户界面中添加动画”?实际上,有很多理由。首先,动画是将用户的注意力吸引到界面特定部分的一种好方法。例如,想象一下,向导中的 Next 按钮弹出以告诉您现在可以进入过程的下一步。其次,当界面在状态之间转换时,动画可以帮助用户维护上下文。当 Office 菜单从最近选择的命令扩展到整个菜单时,您就可以找到这样的示例。动画还可以使您在用户界面中更好地节省屏幕空间。例如,看一下 Windows 资源管理器中的展开、折叠任务分类。
那么,为什么我们没有在用户界面中看到更多动画呢?要回答这个问题,我们先回顾一下在过去是怎么做的。举个例子,当用户按下标有“Start!”的按钮时,运行动画。下图是 Windows 窗体示例的外观:
要实现动画效果,我需要在动画应该更新时获得回调。由于动画将在我单击按钮之后启动,因此我可以将以下代码放到按钮的事件处理程序中:
private void startButton_Click(object sender, System.EventArgs e) { if( this.animationTimer == null ) { this.animationTimer = new Timer(); this.animationTimer.Interval = 33; // 30 fps this.animationTimer.Tick += new EventHandler( this.OnTimer ); // set the global start time by converting ticks to seconds this.startTime = System.DateTime.Now.Ticks/10000000.0; this.animationTimer.Start(); } }
在上述示例中,当“Start”按钮被按下后,我使用计时器按给定的间隔时间获得回调。但是,使用计时器的问题在于,您以该频率获得回调的分辨率很低,因此在某些情况下,如果计算机很忙,动画看起来就会有许多马赛克。使用这个方法的另一个问题是,它可能会使用过多的 CPU 资源。我们假定所有这些问题都已解决,然后继续讨论实际的动画。
给定一个时间源后,我可以使用两个方法来实现一个动画值。第一,我可以在每次获得计数器回调时按特定数量来递增值。
private void OnTimer( object target, System.EventArgs args ) { if( this.animatedButton.Top < 116) this.animatedButton.Top +=10; }
这个方法的缺点是,值的更改频率将根据系统的忙碌程度而加快或减慢。我使用的第二种方法是,在给定当前时间的情况下,使用公式来计算属性的新值。这样,我的代码经过一段特定的时间就会更改值。如果 CPU 非常忙,那么动画可能只更新几次;如果 CPU 有许多空闲时间,那么我将看到流畅的动画。这个方法还可以确保当时间超过动画的终点时,值将位于动画的终点。如下所示:
private void OnTimer( object target, System.EventArgs args ) { double timeSinceStart = DateTime.Now.Ticks/10000000.0 - this.startTime; this.animatedButton.Top = (int) this.CalculateCurrentValue( timeSinceStart, 1.0, 16, 116); if( timeSinceStart > duration ) { this.animationTimer.Stop(); this.animationTimer = null; } } private double CalculateCurrentValue( double currentTime, double duration, double startValue, double endValue) { double progress; if( currentTime <= duration ) progress = currentTime/duration; else progress = 1.0; return startValue + (endValue - startValue) * progress; }
下面是动画在执行时的一些屏幕快照。
如果我要使按钮弹起,则必须编写更多的代码以了解何时在适当的时刻反转方向。为了让我的动画看起来像真正的弹起,可能还需要更改值来加快和减慢时间进度,这可以通过更多的代码实现(就目前而言)。在这里,我们将不进行演示。相反,我们来看一下如何使用 Avalon 来完成这个动画。
Avalon 中的动画
那么,Avalon 如何帮助我解决这些问题呢?在 Avalon 中,已经为我编写好了上述大部分代码。XAML 允许我使用标记来描述界面的画面,然后使用称为故事板 的机制将这些画面转变为动画。让我们从界面的画面开始。
<Window x:Class="AnimationSample.Window1" xmlns="http://schemas.microsoft.com/2003/xaml" xmlns:x="Definition" Text="AnimationSample" > <Grid> <Canvas> <Button ID="AnimatedButton" Canvas.Left="16" Canvas.Top="16" > Animated </Button> <Button ID="StartButton" Canvas.Left="16" Canvas.Top="140" Click="StartButtonClick" > Animate! </Button> </Canvas> </Grid> </Window>
以下是我们在 Avalon 中所处位置的屏幕快照:
这里,我有一个启动动画的按钮和一个生成动画的按钮,就如 Windows 窗体示例中一样。现在,我们来添加动画部分。
<Window x:Class="AnimationSample.Window1" xmlns="http://schemas.microsoft.com/2003/xaml" xmlns:x="Definition" Text="AnimationSample" > <Window.Storyboards> <Timeline BeginTime="*null" ID="MoveDown" > <SetterTimeline TargetID="AnimatedButton" Path="(Canvas.Top)" > <LengthAnimation To="116" Duration="1" /> </SetterTimeline>> </Timeline> </Window.Storyboards> <Grid> <Canvas> <Button ID="AnimatedButton" Canvas.Left="16" Canvas.Top="16" > Animated Button </Button> <Button ID="StartButton" Canvas.Left="16" Canvas.Top="140" Click="StartButtonClick" > Animate! </Button> </Canvas> </Grid> </Window>
在上面,我在一个连接到窗口且名为故事板的容器中编写了动画的 XAML 描述。如果您曾经使用过视频编辑系统,那么这个名称会使您觉得更有意义。大多数系统中都有一个称为故事板视图的视频视图,它显示最终产品中包含的每个剪辑的第一帧。这里的概念是类似的。上述故事板中的每条时间线都代表一个应用于窗口内容的动画剪辑。与视频编辑视图的不同之处在于,这些剪辑不需要像在电影中那样按顺序播放,而是能够由用户单独触发。这就是故事板及其动画为什么与窗口的呈现内容分离开的原因;这样,多个动画就可以在相同的呈现内容上运行,而无需复制该内容。Storyboard 属性可保存特定容器(例如,一个窗口)的所有动画。故事板中的动画可以启用该故事板所连接的容器中任何元素的大部分属性。在本例中,该故事板所连接的元素是 Window 元素 (AnimationSample.Window1),并且该故事板可以启用该窗口中元素的任何属性(一个 Grid、一个 Canvas 和两个 Buttons)。
Storyboard 标记中是一个 Timeline 标记。该标记作为一个分组元素,将同时运行的几个动画一起放到一个标记中。您可以将 Timeline 视为时间的 Canvas。另一个 Timeline 标记中的所有 Timeline 都由其父 Timeline 约束。在上述示例中,请注意顶层时间线的开始时间(ID 为 "MoveDown" 的时间线)是 "*null",这意味着,只有当代码告诉它时才会启动。该时间线中的 SetterTimeline 和 LengthAnimation 的默认 BeginTime 为 0。这意味着,它们将在与父时间线(“MoveDown”时间线)启动时间的偏移量为 0 秒时启动。
Timeline 标记中是一个 SetterTimeline。SetterTimeline 将其内部动画的输出“设置”为一个特定元素上的特定属性值。该元素由 SetterTimeline 上的 TargetID 属性设置,要启用的属性通过 PropertyPath 在 Path 属性中描述。在上述示例中,SetterTimeline 将其内部的 LengthAnimation 的输出映射到 ID 为“AnimatedButton”的元素的 Canvas.Top 属性。
PropertyPath 属性的值具有某些含义。在本例中,我启用了“AnimatedButton”的 Canvas.Top 属性。该属性是一个附加属性,用于告诉“AnimatedButton”的 Canvas 父应该将该按钮置于何处。它包含在括号中,以便启用更多复杂的值。例如,我还可以启用按钮的 Foreground 属性,据我所知是 SolidColorBrush。SolidColorBrush 只有一个属性,即,画笔本身的颜色。我可以使用以下属性路径来启用该属性:"(Button.Foreground). (SolidColorBrush.Color)"。该属性路径会转到按钮的 Button.Foreground 属性,然后在该值上尝试查找 SolidColorBrush.Color 属性。您可以在 WinFX SDK 中找到有关 PropertyPath 的更多信息。
最后,我们在 SetterTimeline 标记中探究描述动画的标记。我所使用的动画是 LengthAnimation,因为我所启用的属性 (Canvas.Top) 的值的类型是 Length。Avalon 中的动画依赖于它们启用的类型,因此系统可以验证在动画的起点、终点和中间点设置的值。LengthAnimation 还设置了一个 Duration。动画就是允许我在动画上设置任何 Timeline 属性,以便控制(例如)它的开始时间和播放时间的时间线。在上述 Windows 窗体代码中,我需要知道,在动画真正有意义之前从一个值到另一个值所需花费的时间。因此,我设置了 LengthAnimation 的 Duration。
触发动画
既然我们已经描述了动画,下面就该让它在按下按钮时播放了。请注意,在上述标记中,我在“Start”按钮上设置了 Click 处理程序以调用 StartButtonClick 方法,因此我需要做的全部事情就是为该事件实现处理程序。
private void StartButtonClick(object sender, RoutedEventArgs e) { // Find the timeline that we want to start Timeline moveDownTimeline = this.Storyboards[0]; // Find the Clock TimelineClock timelineClock = this.FindStoryboardClock(moveDownTimeline); if (timelineClock == null) return; // Start it! timelineClock.InteractiveController.Begin(); }
在 2004 年 11 月发布的 Avalon 社区技术预览版中,我必须找到符合要播放的时间线的时钟。标记中显示的时间线是对可应用于任何元素树的动画的描述。它知道时间线相对于其父启动的时间、时间线的持续时间以及许多其他数据。该时间线可以应用于一组以上的画面。如果时间线用于多个位置,那么它就再也无法知道任一实例的当前时间。此外,在这种情况下启动时间线没有太大意义,除非我还指定了要启动时间线的哪个实例。因此,当时间线应用于一组特定的画面时,会创建一个时钟以符合该时间线的特定实例。时钟可记录当前时间以及特定于该时间线实例的其他数据,并会公开允许您操纵该实例如何播放的方法。
一旦我使用 FindStoryboardClock 方法找到时钟,就必须让时钟启动。这是通过从时钟获得 InteractiveController 并使用它启动时钟来完成的。这将启动在用于查找时钟的时间线下定义的动画的所有时钟。
更多动画
现在,我们已经有了启动时间线的代码,下面我们来试着添加另一个动画。
<Timeline BeginTime="*null" ID="MoveDown" AutoReverse="True" > <SetterTimeline TargetID="AnimatedButton" Path="(Canvas.Top)" > <LengthAnimation To="116" Duration="1"/> </SetterTimeline> <SetterTimeline TargetID="AnimatedButton" Path="(Button.Width)" > <LengthAnimation From="75" To="100" BeginTime="0:0:0.5" Duration="0.5" /> </SetterTimeline> </Timeline>
我添加了一个新的设置时间线和动画,它使用按钮的宽度。请注意,我设置了动画的 BeginTime 属性,以使其在位置动画启动半秒后启动。BeginTime 属性采用一种特殊的格式,以便 Avalon 将其值识别为时间值,并对应于您可能在跑表上看到的内容(小时:分钟:秒钟)。该动画还有一个 From 属性,该属性可以显式设置动画启动的值。当这个新动画启动时,无论 Width 属性的值是什么,该值都会启用从 From 值到 To 值的设置。
弹回
现在,如果您尝试执行上述示例,就会发现按钮在动画结尾已经弹回其初始位置。这不是我所需要的。您是否还记得,实际上我想让按钮看起来好像被弹起。这可以通过在动画上设置 AutoReverse 属性来完成。
<Window.Storyboards> <Timeline BeginTime="*null" ID="MoveDown" AutoReverse="True"> <SetterTimeline TargetID="AnimatedButton" Path="(Canvas.Top)" > <LengthAnimation To="116" Duration="1"/> </SetterTimeline> <SetterTimeline TargetID="AnimatedButton" Path="(Button.Width)" > <LengthAnimation From="75" To="100" BeginTime="0:0:0.5" Duration="0.5" /> </SetterTimeline> </Timeline> </Window.Storyboards>
看,这很简单。现在,不需要更多代码,我的动画就可以在到达终点时倒退,并返回到起始位置。如果将 AutoReverse 设为真,则时间线的运行时间将是原来的两倍,以防止更改动画的速度。请注意,我在包含有我的动画的时间线上设置了 AutoReverse 属性,并且倒退会影响该动画。这是父时间线如何影响其包含的时间线和动画的另一个示例。
两个时间线优于一个
此时,您可能会问自己,“为什么制作动画都涉及这个结构”。当然,它十分灵活,您可以轻松地倒退动画并可以进行插补,但是我为什么一定要使用这个故事板呢?
答案就是,如果使用一个以上的时间线,交互动画就会变得非常有趣。例如,我可能希望在单击一个按钮时播放弹起动画,而在单击另一个按钮时播放另一个动画。为此,首先我需要另一个按钮:
<Window x:Class="AnimationSample.Window1" xmlns="http://schemas.microsoft.com/2003/xaml" xmlns:x="Definition" Text="AnimationSample" > <Window.Storyboards> … </Window.Storyboards> <Grid> <Canvas> <Button ID="AnimatedButton" Canvas.Left="16" Canvas.Top="16" Width="75"> Animated </Button> <Button ID="StartButton" Canvas.Left="16" Canvas.Top="160" Click="StartButtonClick" > Animate! </Button> <Button ID="Start2Button" Canvas.Left="16" Canvas.Top="190" Click="Start2ButtonClick"> Animate 2! </Button> </Canvas> </Grid> </Window>
现在,我将添加要由该按钮触发的新时间线。在本例中,它将启用动画按钮的高度:
<Window.Storyboards> <Timeline BeginTime="*null" ID="MoveDown" AutoReverse="True" > … </Timeline> <Timeline BeginTime="*null" ID="GetTaller" AutoReverse="True"> <SetterTimeline TargetID="AnimatedButton" Path="(Button.Height)" > <LengthAnimation From="25" To="50" Duration="0.4"/> </SetterTimeline> </Timeline> </Window.Storyboards>
这非常类似于我以前设置的时间线。
我还需要更新用于触发时间线的代码,以使其更加健壮。
private void Start2ButtonClick(object sender, RoutedEventArgs e) { Debug.Assert( BeginClockForTimeline("GetTaller") ); } private void StartButtonClick(object sender, RoutedEventArgs e) { Debug.Assert( BeginClockForTimeline("MoveDown") ); } // Returns false if it fails to find the timeline or the clock. private bool BeginClockForTimeline(string name) { Timeline timeline = FindTimeline(name); if (timeline == null) return false; TimelineClock timelineClock = this.FindStoryboardClock(timeline); if (timelineClock == null) return false; // Start it! timelineClock.InteractiveController.Begin(); return true; } private Timeline FindTimeline(string name) { Timeline foundTimeline = null; foreach (Timeline curTimeline in this.Storyboards) { if (curTimeline.ID == name) { foundTimeline = curTimeline; break; } } return foundTimeline; }
以上是两个新方法的代码。第一个方法可以启动给定时间线 ID 的时钟。它执行“Start”按钮事件处理程序在前面代码中所执行的大部分任务,除了要使用第二个方法以外。第二个方法可以查找给定字符串 ID 的时间线。它从窗口获得 Storyboard 集合,并对其进行迭代以查找具有匹配 ID 的时间线,并在找到后返回该时间线。这两个方法可以使您在给定时间线字符串 ID 的情况下,轻松启动窗口 Storyboard 集合中的任何时间线。
现在,有了新方法,按钮的事件处理程序就显得无足轻重了,它们只负责调用 BeginClockForTimeline 方法(如果无法找到时钟,则进行声明)。
下面是最终动画的几个屏幕快照。请注意在顶层动画播放时动画播放的高度。
如果您运行包含这些更改的示例,就会发现“Start!”按钮启动的是弹起时间线,而“Start2!”按钮启动的是新的“GetTaller”时间线。下面是有趣的部分。时间线将在按下相应按钮的任何 时间播放,即使其他时间线已经播放。这对于支持交互动画至关重要。设计动画的人员无法预测动画何时开始,因为这些时间是由用户交互或其他一些不可预知的事件决定的。但是,设计者可以定义响应这些事件的动画,并且 Avalon 将同时运行这些动画,并在尝试同时启用同一属性时解决冲突。
在我们首次遇到 Storyboard 标记时,我就提到过,可以将故事板视为一个可应用于一组元素的动画“剪辑”集合。这里,我们可以看到实例。“GetTaller”时间线是一个可独立于“MoveDown”剪辑触发的动画剪辑。您可以为该应用程序创建任意多的独立动画剪辑,并且它们都可以只通过 ID 链接到应用程序窗口的内容,因此我只需更改 ID 或将该故事板指定给另一个窗口,即可更改所启用的元素。