WPF 的 Ken Burns 驱动的照片框架控件
展示如何在 WPF 中创建漂亮的相框控件并使用 Ken Burns 效果对其进行动画处理。
引言
曾经想在你的应用程序中包含一个图像幻灯片吗?你临时凑合的方法看起来沉闷无聊吗?
阅读本文,了解如何轻松创建一个视觉上令人愉悦的相框控件,你可以在你的 WPF 应用程序中使用它。
背景
电影制片人在展示静态图像时普遍使用的一种技巧通常被称为 Ken Burns 特效。这个名字来源于美国纪录片导演 Ken Burns,他在他的作品中广泛使用这种技巧,但这种技术甚至比他出现得更早。
当这种技术用于电影时,其效果经过精心设计,以符合导演希望观众看到的内容。例如,在一张棒球队的照片中,摄像机可能会缓慢地平移过球员的脸部,然后停留在旁白的主角身上。
在计算机软件中,这种效果常用于屏幕保护程序和电影编辑器中,它能很好地为静态内容带来更生动的感觉。由于我们要将其应用于任意图像,因此我们必须随机化动画参数,而不是根据图像内容仔细设置它们。但别担心,效果仍然相当不错。
控件
我们控件的 XAML 非常简单。它包含两个图像控件,每个控件有两个变换——一个用于平移(pan),一个用于缩放(zoom)。我们需要两个的原因是为了在切换图像时实现更平滑的过渡,因为我们希望在过渡时两张图像(旧图像 + 新图像)同时可见。
<Grid>
<Image x:Name="c_image1" Stretch="UniformToFill" RenderTransformOrigin="0.5,0.5" >
<Image.RenderTransform>
<TransformGroup>
<TranslateTransform />
<ScaleTransform />
</TransformGroup>
</Image.RenderTransform>
</Image>
<Image x:Name="c_image2" Stretch="UniformToFill" RenderTransformOrigin="0.5,0.5">
<Image.RenderTransform>
<TransformGroup>
<TranslateTransform />
<ScaleTransform />
</TransformGroup>
</Image.RenderTransform>
</Image>
</Grid>
由于 WPF 将按照 `TransformGroup` 中指定的顺序应用变换,我们将平移放在首位,这样其量就不会受到我们缩放的影响。
当图像显示时,这些变换将被动画化,从而创建上述所需的缩放和平移效果。动画的实际目标值将在运行时随机化,如下文所述,但我们在 XAML 中定义了故事板的核心。
<Storyboard x:Key="MoveStoryboard">
<DoubleAnimation Storyboard.TargetProperty=
"(UIElement.RenderTransform).(TransformGroup.Children)[0].(TranslateTransform.X)"
x:Name="moveX" />
<DoubleAnimation Storyboard.TargetProperty=
"(UIElement.RenderTransform).(TransformGroup.Children)[0].(TranslateTransform.Y)"
x:Name="moveY" />
<DoubleAnimation Storyboard.TargetProperty=
"(UIElement.RenderTransform).(TransformGroup.Children)[1].(ScaleTransform.ScaleX)"
x:Name="scaleX" />
<DoubleAnimation Storyboard.TargetProperty=
"(UIElement.RenderTransform).(TransformGroup.Children)[1].(ScaleTransform.ScaleY)"
x:Name="scaleY" />
</Storyboard>
该控件有两个可配置方面
- 读取图像的文件夹 - `ImageFolder`(类型为 `string`)
- 图像切换之间的间隔 - `Interval`(类型为 `TimeSpan`)
这些都作为依赖属性公开,以便允许从 XAML 设置它们。
作为最后的技巧,我们将 ClipToBounds 属性设置为 `true`,这样图像在动画时就不会超出控件的边界。
加载图像
在内部,控件维护一个它应该迭代的所有图像的列表。每当需要切换照片时,都会从该列表中加载下一张图像,并渲染到上述其中一个 Image 控件上。
每当 `ImageFolder` 属性被修改时,图像就会从磁盘加载,执行此操作的代码位于 `LoadFolder` 方法中。
/// <summary>
/// Loads all images from the specified folder.
/// </summary>
/// <remarks>
/// This method, even though it parallelizes itself to multiple background threads,
/// still waits
/// for all results to complete at the end.
/// It should really be called done asynchronously to avoid blocking the UI
/// during this time.
/// (With big enough files, this method can take several seconds)
/// </remarks>
private void LoadFolder(string folder)
{
c_errorText.Visibility = Visibility.Collapsed;
if (!System.IO.Path.IsPathRooted(folder))
folder = System.IO.Path.Combine(Environment.CurrentDirectory, folder);
if (!System.IO.Directory.Exists(folder))
{
c_errorText.Text = "The specified folder does not exist: " +
Environment.NewLine + folder;
c_errorText.Visibility = Visibility.Visible;
return;
}
Random r = new Random();
var sources = from file in new System.IO.DirectoryInfo(folder).GetFiles().AsParallel()
where ValidExtensions.Contains
(file.Extension, StringComparer.InvariantCultureIgnoreCase)
orderby r.Next()
select CreateImageSource(file.FullName, true);
m_images.Clear();
m_images.AddRange(sources);
}
在这里,我们使用 LINQ 查询文件夹中的所有文件。上述查询将获取文件夹中的所有文件,查看它们是否与我们支持的扩展名列表匹配,随机排序结果,最后根据结果创建一个 `BitmapImage` 对象。
你可能还会注意到第一行末尾的 `.AsParallel()` 调用。这实际上告诉 LINQ 在多个线程上并行执行查询的其余部分。这被证明非常有用,因为 `CreateImageSource` 方法开销很大,而且速度提升非常显著
图片数量 | 平均图片大小 | 加载时间(顺序) | 加载时间(并行) |
---|---|---|---|
6 | 2 MB | 4130 毫秒 | 1196 毫秒 |
14 | 0.9 MB | 1688 毫秒 | 1211 毫秒 |
8 | 0.2 MB | 401 毫秒 | 155 毫秒 |
在 WPF 中进行多线程处理时,需要记住的一件事是,图形对象通常不允许在线程之间共享——它们只能由创建它们的线程访问。幸运的是,这条规则有一个例外,那就是 Freezable 类,它是我们 `BitmapImage` 对象的基础。
因此,为了确保我们可以在后台线程上加载图像,我们唯一需要做的就是在将它们与主线程共享之前**冻结**它们。
/// <summary>
/// Creates an ImageSource object from a file on disk.
/// </summary>
/// <param name="file" />The full path to the file that should be loaded.
/// <param name="forcePreLoad" />if set to true the image file will be decoded
/// and fully loaded when created.
/// <returns>A frozen image source object representing the loaded image.</returns>
private ImageSource CreateImageSource(string file, bool forcePreLoad)
{
if (forcePreLoad)
{
var src = new BitmapImage();
src.BeginInit();
src.UriSource = new Uri(file, UriKind.Absolute);
src.CacheOption = BitmapCacheOption.OnLoad;
src.EndInit();
src.Freeze();
return src;
}
else
{
var src = new BitmapImage(new Uri(file, UriKind.Absolute));
src.Freeze();
return src;
}
}
另外值得一提的是,您会注意到 `CreateImageSource` 方法的 `forcePreLoad` 参数。这控制了 WPF 是否应该直接加载并解码整个图像。否则,这不会在图像实际显示之前完成。对于小图像来说,这无关紧要,但对于大图像来说,解码可能需要相当长的时间,导致 UI 在图像过渡中间冻结。在我们的场景中,由于我们知道所有图像都将被使用,并且我们绝对希望它们之间有平滑的过渡,因此我们选择通过在上面的 LINQ 查询中将此参数设置为 true 来预先支付性能成本。
图像过渡
现在我们已经将文件夹中的所有图像加载并解码到私有列表中。下一步是设置一个调度器计时器,以便在 `Interval` 属性指定的时间切换照片。在计时器的事件处理程序中,将调用 `LoadNextImage` 方法,该方法负责两件事:
- 从列表中获取下一个 `ImageSource` 并将其分配给当前未使用的 `Image` 控件。
- 启动过渡效果,并为新图像创建并启动 Ken Burns 特效。
如前所述,该控件在其 XAML 中定义了两个 `Image` 控件。第一个加载的图像将分配给第一个控件,下一个图像分配给第二个控件,然后第三个图像将再次分配给第一个控件,依此类推。当前未使用的图像控件通过将其不透明度动画为零而淡出,然后等待下一次图像过渡,届时它将获得新的图像源。

创建特效
实际的 Ken Burns 特效是通过控件的 XAML 中定义的变换动画实现的。为了使控件稍微更有趣,每次显示新图像时,实际的动画值(`From` 和 `To` 属性)都会被随机化。
/// <summary>
/// Creates the ken burns effect animations and applies them
/// to the specified image control.
/// </summary>
/// <param name="img" />The image control to apply the effect on.
private void CreateAndStartKenBurnsEffect(Image img)
{
var rand = new Random();
double scaleFrom = rand.Next(1100, 1500) / 1000.0;
double scaleTo = rand.Next(1100, 1500) / 1000.0;
foreach (var child in m_moveStoryboard.Children)
{
var dblAnim = child as DoubleAnimation;
if (dblAnim != null)
{
// Randomize the translation to create a more live effect
if (dblAnim.Name == "moveX" || dblAnim.Name == "moveY")
dblAnim.To = rand.Next(-50, 50);
else if (dblAnim.Name == "scaleX" || dblAnim.Name == "scaleY")
{
dblAnim.To = scaleTo;
dblAnim.From = scaleFrom;
}
// make sure the child animations has the same duration as the storyboard
// (since it could have changed since last time)
dblAnim.Duration = m_moveStoryboard.Duration;
}
Storyboard.SetTarget(child, img);
}
m_moveStoryboard.Begin(img, true);
}
缩放比例被随机设置为 `1.1` 到 `1.5` 之间的值(换句话说,以随机方向缩放 10% 到 50%——从大到小或反之),并且过渡设置为最多在任一方向(上下左右)移动 50 像素。
您可能需要根据您的间隔时间和图像大小调整这些值,但我发现在我的测试图像上,这些值提供了相当不错的结果。
Using the Code
源代码包含一个测试应用程序,它由一个主窗口组成,该主窗口在一个网格中托管 `PhotoFrame` 控件,以及一个配置面板,允许您调整间隔并更改加载图像的文件夹。
在您的应用程序中使用该控件的代码将如下所示:
<local:photoframe imagefolder="C:\Users\Public\Pictures\Sample Pictures"
interval="00:00:10">
兴趣点
- PLINQ 的使用并非我最初所想,但事实证明,这个案例几乎是并行 LINQ 的强大功能能够以很少的代码更改带来显著改进的教科书式示例。
- 从文件夹加载图像的代码应该在后台线程上调用,并向用户显示一个加载动画。我在这篇文章中故意没有这样做,以保持代码的简洁性。
- 为 Ken Burns 动画构建适用于所有图像大小的值非常棘手。在某些罕见情况下,上面源代码中提供的值会将图像的部分移到控件区域之外,从而暴露出控件的白色背景。通过使用一些巧妙的数学方法来限制随机化的动画值,可以缓解这个问题,但我认为这对于本文来说已经足够了。
历史
- 2011-06-16 - 初始版本