65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF 的 Ken Burns 驱动的照片框架控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (20投票s)

2011年6月16日

CPOL

7分钟阅读

viewsIcon

48094

downloadIcon

2658

展示如何在 WPF 中创建漂亮的相框控件并使用 Ken Burns 效果对其进行动画处理。

KenBurnsPhotoFrame.png

引言

曾经想在你的应用程序中包含一个图像幻灯片吗?你临时凑合的方法看起来沉闷无聊吗?

阅读本文,了解如何轻松创建一个视觉上令人愉悦的相框控件,你可以在你的 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` 方法,该方法负责两件事:

  1. 从列表中获取下一个 `ImageSource` 并将其分配给当前未使用的 `Image` 控件。
  2. 启动过渡效果,并为新图像创建并启动 Ken Burns 特效。

如前所述,该控件在其 XAML 中定义了两个 `Image` 控件。第一个加载的图像将分配给第一个控件,下一个图像分配给第二个控件,然后第三个图像将再次分配给第一个控件,依此类推。当前未使用的图像控件通过将其不透明度动画为零而淡出,然后等待下一次图像过渡,届时它将获得新的图像源。

LoadNextImage_flowchart.png

创建特效

实际的 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 - 初始版本
© . All rights reserved.