创建动画 ContentControl
展示了如何构建一个 ContentControl,该控件能够为内容之间的过渡添加动画效果。
引言
WPF 拥有非常强大的动画功能,但在某些情况下,将它们与数据驱动的内容结合使用会非常困难。一个例子是当 ContentControl
根据 ViewModel 中绑定的对象动态渲染视图时。
本文展示了一种解决方案,该方案通过增强标准的 ContentControl
来为内容之间的过渡添加动画,同时仍保持其熟悉的功能和行为。
带动画的 ContentControl
示例中提供的控件是一个独立的控件,继承自 ContentControl
,当检测到其内容发生更改时,它将应用从右到左的飞出动画。无论内容是来自数据绑定、代码隐藏还是 XAML,都没有关系。事实上,除了动画之外,它的行为与普通的 ContentControl
完全相同。
该控件被创建为“自定义控件”,这与用户控件不同,因为它
- 可以继承自任何 WPF 控件,而不是仅仅继承自
UserControl
- 没有后备的 .xaml 文件,因此无法对其视觉树做出硬性假设(尽管它有一个默认样式,这正是我们在本文中将要使用的)
讨论这两种方法之间的区别超出了本文的范围,但由于我们想创建一个 ContentControl
,我们必须选择“自定义控件”方法。
为了处理动画,AnimatedContentControl
需要一个临时绘制旧内容的区域。在控件的默认样式中,我们为此目的添加了一个矩形。该矩形的大小与内容相同,并且占据布局中的相同空间。当然,我们稍后将从代码控制其定位和可见性。
这是 AnimatedContentControl
的默认样式
<Style TargetType="{x:Type local:AnimatedContentControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:AnimatedContentControl}">
<Grid>
<ContentPresenter
Content="{TemplateBinding Content}"
x:Name="PART_MainContent" />
<Rectangle x:Name="PART_PaintArea" />
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
请注意用于我们代码所需控件的命名。这遵循自定义控件的命名约定,并为我们提供了一种在 C# 代码中获取这些控件的方法。这在 这个 CodeProject 文章 中有更详细的讨论,但简而言之,我们可以在模板应用后使用 GetName
方法来获取对控件的引用。
/// <summary>
/// This gets called when the template has been applied and we have our visual tree
/// </summary>
public override void OnApplyTemplate()
{
m_paintArea = Template.FindName("PART_PaintArea", this) as Shape;
m_mainContent = Template.FindName("PART_MainContent", this) as ContentPresenter;
base.OnApplyTemplate();
}
响应内容更改
基类提供了一个名为 OnContentChanged
的重载,我们可以使用它在内容更改后执行一些操作。需要注意的是,调用此方法时,实际属性已经更改(因此如果我们查看 this.Content
,它将等于 newContent
参数),但*视觉外观尚未更新*。我们通过捕获当前视觉外观并将其绘制到我们的临时矩形上来利用这一点。
/// <summary>
/// This gets called when the content we're displaying has changed
/// </summary>
/// <param name="oldContent">The content that was previously displayed</param>
/// <param name="newContent">The new content that is displayed</param>
protected override void OnContentChanged(object oldContent, object newContent)
{
if (m_paintArea != null && m_mainContent != null)
{
m_paintArea.Fill = CreateBrushFromVisual(m_mainContent);
BeginAnimateContentReplacement();
}
base.OnContentChanged(oldContent, newContent);
}
CreateBrushFromVisual
方法简单地拍摄当前外观的快照并将其存储在 ImageBrush
中
/// <summary>
/// Creates a brush based on the current appearance of a visual element.
/// The brush is an ImageBrush and once created, won't update its look
/// </summary>
/// <param name="v">The visual element to take a snapshot of</param>
private Brush CreateBrushFromVisual(Visual v)
{
if (v == null)
throw new ArgumentNullException("v");
var target = new RenderTargetBitmap((int)this.ActualWidth, (int)this.ActualHeight,
96, 96, PixelFormats.Pbgra32);
target.Render(v);
var brush = new ImageBrush(target);
brush.Freeze();
return brush;
}
现在矩形具有旧内容的视觉效果,是时候开始动画了
/// <summary>
/// Starts the animation for the new content
/// </summary>
private void BeginAnimateContentReplacement()
{
var newContentTransform = new TranslateTransform();
var oldContentTransform = new TranslateTransform();
m_paintArea.RenderTransform = oldContentTransform;
m_mainContent.RenderTransform = newContentTransform;
m_paintArea.Visibility = Visibility.Visible;
newContentTransform.BeginAnimation(TranslateTransform.XProperty,
CreateAnimation(this.ActualWidth, 0));
oldContentTransform.BeginAnimation(TranslateTransform.XProperty,
CreateAnimation(0, -this.ActualWidth,
(s,e) => m_paintArea.Visibility = Visibility.Hidden));
}
在上面的方法中,我们创建了两个新的 TranslateTransform
。它们负责将内容向左移动,并排显示
- 我们临时矩形的动画从显示原始内容的位置开始,并将向左移动,直到它完全超出控件的可见区域。
- 我们应用于
ContentPresenter
(在动画开始时持有新视觉外观的那个)的新内容的动画将从屏幕外右侧开始,并移动进来,直到占据其最终位置。
为了让过渡感觉更生动,我们使用了具有 BackEase 算法的缓动函数。这将使动画内容稍微超出目标位置,然后弹回其原始位置。用于创建动画的代码封装在一个简单的方法中
/// <summary>
/// Creates the animation that moves content in or out of view.
/// </summary>
/// <param name="from">The starting value of the animation.</param>
/// <param name="to">The end value of the animation.</param>
/// <param name="whenDone">(optional)
/// A callback that will be called when the animation has completed.</param>
private AnimationTimeline CreateAnimation(double from, double to,
EventHandler whenDone = null)
{
IEasingFunction ease = new BackEase
{ Amplitude = 0.5, EasingMode = EasingMode.EaseOut };
var duration = new Duration(TimeSpan.FromSeconds(0.5));
var anim = new DoubleAnimation(from, to, duration)
{ EasingFunction = ease };
if (whenDone != null)
anim.Completed += whenDone;
anim.Freeze();
return anim;
}
最终结果可以在这个低分辨率的 GIF 动画中看到
DataBinding
可下载的源代码展示了该控件如何在 MVVM(模型-视图-ViewModel)架构中使用,其中内容由 ViewModels 更改,而 ViewModels 完全不知道在 View 层中执行的动画。
主窗口的 ViewModel,MainWindowViewModel
,包含一个名为 Content
的属性和一个名为 ChangeContentCommand
的命令。这些从 MainWindow
绑定如下
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Button Command="{Binding ChangeContentCommand}"
Content="Change Content" FontSize="20"
HorizontalAlignment="Center"
VerticalAlignment="Center" Margin="10"
Padding="10,5" />
<local:AnimatedContentControl
Content="{Binding Content}" Grid.Row="1" />
</Grid>
当 ViewModel 中的命令被执行时,它只是将其 Content
属性设置为 MyContentViewModel
的新实例。属性通知系统然后会通知我们的控件新内容,从而触发动画。
关注点
- 此示例中的动画非常简单。它只动画了 X 轴的平移,并且由于缓动函数,仍然提供了视觉上吸引人的效果。不过,尽管如此,视觉设计师可以很容易地对其进行增强以提供更丰富的体验,而且好消息是,与该控件的编程接口*完全不变*。
- 本文附带的示例代码包含来自Laurient Bugnion 的 MVVM Light 工具包的
RelayCommand
类。这个特定的类显然是他的版权,并根据MIT 许可证授权。
历史
- v1.0 (2010 年 12 月 15 日) - 初始发布。