UI 前沿技术

Silverlight 4 中的流畅 UI

Charles Petzold

下载代码示例

“流畅 UI”这个词最近常被用来形容一种 UI 设计技术,这种技术能够避免让可视化对象突然进入视野或者从一个位置跳到另一个位置。流畅的可视化对象在进入视野和变换位置时更加优雅,有时就像从雾中浮现或者滑入视野。

我在本专栏的前两篇文章中介绍过一些您自己实现流畅 UI 的技术,当时的部分灵感就来源于 Silverlight 4 中即将推出的流畅 UI 功能。现在,Silverlight 4 已经正式发布,本文就为您介绍其功能。Silverlight 4 中流畅 UI 的使用范围很窄,只用于加载和卸载 ListBox 中的项,但是却能给我们一些重要的启发,告诉我们如何在自己的实现中扩展流畅 UI 技术。Expression Blend 4 中具备更多的流畅 UI 行为。

模板和 VSM

如果您不知道新的流畅 UI 功能究竟在 Silverlight 4 的什么地方,您可能需要花几个小时去寻找。它不是类;不是属性;不是方法;也不是事件。实际上,它是 ListBoxItem 类上的三个新的视觉状态。图 1 显示了这个类的文档,其中的 TemplateVisualState 属性项进行了微调,以符合组的名称。

图 1 ListBoxItem 类文档

[TemplateVisualStateAttribute(Name = "Normal", GroupName =  
  "CommonStates")]
[TemplateVisualStateAttribute(Name = "MouseOver", GroupName = 
  "CommonStates")]
[TemplateVisualStateAttribute(Name = "Disabled", GroupName = 
  "CommonStates")]
[TemplateVisualStateAttribute(Name = "Unselected", GroupName = 
  "SelectionStates")]
[TemplateVisualStateAttribute(Name = "Selected", GroupName =  
  "SelectionStates")]
[TemplateVisualStateAttribute(Name = "SelectedUnfocused", GroupName = 
  "SelectionStates")]
[TemplateVisualStateAttribute(Name = "Unfocused", GroupName = 
  "FocusStates")]
[TemplateVisualStateAttribute(Name = "Focused", GroupName = 
  "FocusStates")]
[TemplateVisualStateAttribute(Name = "BeforeLoaded", GroupName = 
  "LayoutStates")]
[TemplateVisualStateAttribute(Name = "AfterLoaded", GroupName = 
  "LayoutStates")]
[TemplateVisualStateAttribute(Name = "BeforeUnloaded", GroupName =  
  "LayoutStates")]
public class ListBoxItem : ContentControl

视觉状态管理器 (VSM) 是 Silverlight 中最重要的更改之一,它改编自 Windows Presentation Foundation。在 WPF 中,样式或模板(几乎总是用 XAML 定义)可以包含名为触发器 的元素。这些触发器被定义为检测属性更改或检测事件,然后启动一段动画或更改另一个属性。

例如,一个控件的样式定义可以包含一个针对 IsMouseOver 属性的触发器,当该属性为 True 时,触发器将控件的背景设置为蓝色画笔。也可以定义针对 MouseEnter 和 MouseLeave 事件的触发器,当这些事件发生时可以启动几段简短的动画。

在 Silverlight 中,大部分触发器都被弃用,取而代之的是 VSM。这么做的部分原因是希望提供更加结构化的方法,以便在运行时动态更改控件的特征;还有部分原因是避免在定义多个触发器后,处理各种可能的组合。VSM 被认为是对触发器的极大改进,因此包含在 Microsoft .NET Framework 4 的 WPF 中。

您从图 1 可以看到,ListBoxItem 控件支持 11 种视觉状态,这些状态被分为四组。在每一组中,任意时刻只能有一种视觉状态是活动的。这一简单的规则极大地减少了可能的组合数量。例如,您无需去思考当鼠标悬停在已选定但未聚焦的项上时,ListBoxItem 该如何显示;而且每个组都可以独立于其他组进行处理。

ListBoxItem 的代码部分通过调用静态的 VisualStateManager.GoToState 方法,来负责更改视觉状态。ListBoxItem 的控件模板负责响应这些视觉状态。模板使用一个情节提要来响应特定的视觉状态,该情节提要中包含一个或多个以可视化树中的元素为目标的动画。如果您希望控件立即响应视觉状态的更改而不使用动画,只需将动画的持续时间定义为 0 即可。但为什么要这么麻烦呢?可以像使用动画一样,轻松地让控件的视觉效果更流畅。

支持流畅 UI 的新视觉状态分别是 BeforeLoaded、AfterLoaded 和 BeforeUnloaded,它们都包含在 LayoutStates 组中。通过将动画关联到这些视觉状态,您可以让 ListBox 中的项在第一次加入 ListBox 时淡入或滑入视野,而从 ListBox 删除时呈现一些别的效果。

改写 ListBoxItem 模板

大部分程序员可能会通过 Expression Blend 访问 ListBoxItem 的流畅 UI 功能,但我要为您介绍如何在标记中直接访问该功能。

ListBoxItem 的默认控件模板没有与 LayoutStates 组中的视觉状态相关联的动画。这是您的工作。遗憾的是,您并不能从现有的 ListBoxItem 模板“派生”模板然后用自己的内容进行补充,而必须在程序中包含整个模板。幸运的是,您所要做的只是复制和粘贴。在 Silverlight 4 文档中,找到“控件”部分,然后依次是“控件自定义”、“控件样式和模板”、“ListBox 样式和模板”。您会在以下面内容开头的标记中找到 ListBoxItem 的默认样式定义(包括模板定义):

<Style TargetType="ListBoxItem">

在 Template 属性的 Setter 元素下,您将看到为每个 ListBoxItem 构建可视化树所用的完整 ControlTemplate。可视化树的根是单格的 Grid。在 Grid 定义的上部,大部分模板定义都是 VSM 标记。下部是 Grid 的实际内容:三个 Rectangle 形状(两个实心矩形,一个空心矩形)和 ContentPresenter,如下所示:

<Grid ... >
  ...  <Rectangle x:Name="fillColor" ... />
  <Rectangle x:Name="fillColor2" ... />
  <ContentPresenter x:Name="contentPresenter" ... />
  <Rectangle x:Name="FocusVisualElement" ... />
</Grid>

前两个实心 Rectangle 对象分别用于在鼠标经过和选定时提供背景底纹。第三个对象显示一个空心矩形以指示输入焦点。这些矩形的可见性由 VSM 标记控制。请注意每个可视化组如何让其元素得到处理。ContentPresenter 用于承载该项,就像其显示在 ListBox 中一样。一般来说,ContentPresenter 的内容是另一个可视化树,在设置为 ListBox 的 ItemTemplate 属性的 DataTemplate 中定义。

VSM 标记包含 VisualStateManager.VisualStateGroups、VisualStateGroup 和 VisualState 类型的元素,每个元素都有 XML 命名空间前缀“vsm”。在早期版本的 Silverlight 中,需要为该前缀定义命名空间声明:

xmlns:vsm="clr-namespace:System.Windows;assembly=System.Windows"

但是在 Silverlight 4 中,您可以删除所有 vsm 前缀,并忽略此命名空间声明。若要对此模板进行更改,您需要将整个标记部分复制到 XAML 文件的资源部分,并为其指定一个键名:

<Style x:Key="listBoxItemStyle" TargetType="ListBoxItem">
  ... </Style>

然后可以将此样式设置为 ListBox 的 ItemContainerStyle 属性:

<ListBox ... ItemContainerStyle="{StaticResource listBoxItemStyle}" ....

“项容器”对象是 ListBox 为 ListBox 中的每一项创建的包装,并且是 ListBoxItem 类型的对象。

您的程序中加入此 ListBoxItem 样式和模板后,您可以对其进行更改。

淡入和淡出

让我们用一个简单的演示程序来说明淡入和淡出的工作方式。本文随附的可下载代码是名为 FluidUserInterfaceDemo 的解决方案。它包含两个程序,您可以从我的网站 charlespetzold.com/silverlight/FluidUserInterfaceDemo 运行该方案。这两个程序位于同一个 HTML 页面上,每个都占据了整个浏览器窗口。

第一个程序是 FluidListBox。看上去包含一个 ListBox 和两个按钮,用于添加和删除项。我使用了曾在前两篇专栏文章中使用的同一组杂货店商品,因此 MainPage.xaml 也包含一个名为 produceDataTemplate 的 DataTemplate。

我决定先从简单的入手,让项在加入 ListBox 时淡入视野,在从 ListBox 删除时淡出视野。这需要对构成可视化树的 Grid 的 Opacity 属性进行动画处理。若要成为动画处理的目标,该 Grid 需要有名称:

<Grid Name="rootGrid" ...>

首先在 VisualStateManager.VisualStateGroups 标记中插入新的 VisualStateGroup:

<VisualStateGroup x:Name="LayoutStates">  ...
</VisualStateGroup>

这里就要对 LayoutStates 组中的 BeforeLoaded、AfterLoaded 和 BeforeUnloaded 添加标记。

淡入是两项任务中比较简单的。当一个项首次加入到可视化树中时,是说将它“加载”到可视化树中。在加载之前,该项具有 BeforeLoaded 视觉状态,然后视觉状态变为 AfterLoaded。

有几种方法可以定义淡入。第一种方法需要将 Grid 标记中的 Opacity 初始化为 0:

<Grid Name="rootGrid" Opacity="0" ... >

然后可以为 AfterLoaded 状态提供一段动画,以便在 1 秒钟内将 Opacity 属性增加到 1:

<VisualState x:Name="AfterLoaded">
  <Storyboard>
    <DoubleAnimation Storyboard.TargetName="rootGrid"
                     Storyboard.TargetProperty="Opacity"
                     To="1" Duration="0:0:1" />
  </Storyboard>
</VisualState>

或者将 Grid 的 Opacity 属性保留为默认值 1,然后为 BeforeLoaded 和 AfterLoaded 提供动画:

<VisualState x:Name="BeforeLoaded">
  <Storyboard>
    <DoubleAnimation Storyboard.TargetName="rootGrid"
                     Storyboard.TargetProperty="Opacity"
                     To="0" Duration="0:0:0" />
  </Storyboard>
</VisualState>
                                    
<VisualState x:Name="AfterLoaded">
  <Storyboard>
    <DoubleAnimation Storyboard.TargetName="rootGrid"
                     Storyboard.TargetProperty="Opacity"
                     To="1" Duration="0:0:1" />
  </Storyboard>
</VisualState>

请注意,BeforeLoaded 状态的 Duration 是 0,可以有效地将 Opacity 属性设置为 0。使用完整的情节提要和 DoubleAnimation 来设置一个属性可能有些过分,但也说明了动画的灵活性。系统开销实际上并不多。

我个人偏好的方法(主要是因为简单)是将 Grid 的 Opacity 属性保留为其默认值 1,并通过指定 From 值(而不是 To 值)为 AfterLoaded 状态提供动画:

<VisualState x:Name="AfterLoaded">
  <Storyboard>
    <DoubleAnimation Storyboard.TargetName="rootGrid"
                     Storyboard.TargetProperty="Opacity"
                     From="0" Duration="0:0:1" />
  </Storyboard>
</VisualState>

现在,动画从 0 值进行到其基准值 1。您可以使用与此相同的技术来处理 BeforeLoaded 状态。但是请注意:BeforeLoaded 状态发生在 ListBoxItem 创建并初始化之后,但在它加入到可视化树之前,就发生了 AfterLoaded 状态。这里存在一小段时间差。如果您为 BeforeLoaded 定义了动画,还为 AfterLoaded 定义了空的 VisualState 标记,就会遇到麻烦:

<VisualState x:Name="BeforeLoaded">
  <Storyboard>
    <DoubleAnimation Storyboard.TargetName="rootGrid"
                     Storyboard.TargetProperty="Opacity"
                     From="0" Duration="0:0:1" />
  </Storyboard>
</VisualState>
                                    
<VisualState x:Name="AfterLoaded" />

加载项之后,BeforeLoaded 的情节提要就结束,您不会得到任何淡入效果。但是,您如果按照以下方式添加,则可以让该标记发挥作用:

<VisualStateGroup.Transitions>
  <VisualTransition From="BeforeLoaded"
                    To="AfterLoaded"
                    GeneratedDuration="0:0:1" />
</VisualStateGroup.Transitions>

这在 BeforeLoaded 状态和 AfterLoaded 状态之间定义了一段长 1 秒的过渡期。这段过渡期使 BeforeLoaded 动画能够在 AfterLoaded 状态结束之前播放完毕。

淡出过程就不那么简单了。当要从 ListBox 删除项时,BeforeUnloaded 状态已设置,但随后该项就立即被删除,因此已经开始的动画根本看不到!我找到了两种解决问题的方法。第一种方法是为 BeforeUnloaded 状态定义动画,同时还为该状态定义过渡期:

<VisualState x:Name="BeforeUnloaded">
  <Storyboard>
    <DoubleAnimation Storyboard.TargetName="rootGrid"
                     Storyboard.TargetProperty="Opacity"
                     To="0" Duration="0:0:1" />
  </Storyboard>
</VisualState>

<VisualStateGroup.Transitions>
  <VisualTransition From="AfterLoaded" 
                    To="BeforeUnloaded" 
                    GeneratedDuration="0:0:1" />
</VisualStateGroup.Transitions>

第二种方法是为 BeforeUnloaded 状态定义空标记,而为 VisualTransition 定义动画:

<VisualStateGroup.Transitions>
  <VisualTransition From="AfterLoaded" 
                    To="BeforeUnloaded" 
                    GeneratedDuration="0:0:1">
    <Storyboard>
      <DoubleAnimation Storyboard.TargetName="rootGrid"
                       Storyboard.TargetProperty="Opacity"
                       To="0" Duration="0:0:1" />
    </Storyboard>
  </VisualTransition>
</VisualStateGroup.Transitions>

图 2 是 FluidListBox 项目的 MainPage.xaml 文件中 ListBoxItem 模板所显示的 AfterLoaded 和 BeforeUnloaded 状态的完整标记。

图 2 节选自 FluidListBox 中的 ListBoxItem 模板

<ControlTemplate TargetType="ListBoxItem">
  <Grid Name="rootGrid" Background="{TemplateBinding Background}">
    <VisualStateManager.VisualStateGroups>

      <!-- Additions to standard template -->
      <VisualStateGroup x:Name="LayoutStates">
                                    
        <VisualState x:Name="AfterLoaded">
          <Storyboard>
            <DoubleAnimation Storyboard.TargetName="rootGrid"
                             Storyboard.TargetProperty="Opacity"
                             From="0" Duration="0:0:1" />
          </Storyboard>
        </VisualState>
                                    
        <VisualState x:Name="BeforeUnloaded" />

          <VisualStateGroup.Transitions>
            <VisualTransition From="AfterLoaded" 
                              To="BeforeUnloaded" 
                              GeneratedDuration="0:0:1">
              <Storyboard>
                 <DoubleAnimation Storyboard.TargetName="rootGrid"
                                  Storyboard.TargetProperty="Opacity"
                                  To="0" Duration="0:0:1" />
              </Storyboard>
            </VisualTransition>
          </VisualStateGroup.Transitions>
        </VisualStateGroup>
        <!-- End of additions to standard template -->
            ...
  </Grid>
</ControlTemplate>

One more warning: By default, the ListBox stores its items in a VirtualizingStackPanel. This means the actual items and their containers aren’t generated until they’re required to be visually displayed. If you define an animation for the After-Loaded state, and then fill the ListBox up with items, the items will fade in as they’re scrolled into view. This is probably undesirable. The easy solution is to replace the VirtualizingStackPanel with a regular StackPanel. The required markup on the ListBox is trivial:

<ListBox.ItemsPanel>
  <ItemsPanelTemplate>
    <StackPanel />
  </ItemsPanelTemplate>
</ListBox.ItemsPanel>

扩展到 ItemsControl

由于流畅 UI 功能实现为 ListBoxItem 的视觉状态,因此不能在 ItemsControl 中使用。正如您所知,ItemsControl 只是显示一组项,让用户可以浏览这些项。这些项不存在选定或输入焦点的概念。因此,ItemsControl 不需要像 ListBoxItem 这样的类来承载项。它只使用 ContentPresenter。因为 ContentPresenter 派生自 FrameworkElement 而不是 Control,所以它不存在用于定义视觉状态行为的模板。

但您可以从 ItemsControl 派生一个类,该类使用 ListBoxItem 来承载其项。这实际上要比您想象的容易。图 3 显示了 FluidableItemsControl 的完整代码。

图 3 FluidableItemsControl 类

using System.Windows;
using System.Windows.Controls;

namespace FluidItemsControl
{
  public class FluidableItemsControl : ItemsControl
  {
    public static readonly DependencyProperty ItemContainerStyleProperty =
      DependencyProperty.Register("ItemContainerStyle",
      typeof(Style),
      typeof(FluidableItemsControl),
      new PropertyMetadata(null));

    public Style ItemContainerStyle
    {
      set { SetValue(ItemContainerStyleProperty, value); }
      get { return (Style)GetValue(ItemContainerStyleProperty); }
    }

    protected override DependencyObject GetContainerForItemOverride()
    {
      ListBoxItem container = new ListBoxItem();

      if (ItemContainerStyle != null)
        container.Style = ItemContainerStyle;

      return container;
    }

    protected override bool IsItemItsOwnContainerOverride(object item)
    {
      return item is ListBoxItem;
    }
  }
}

起决定性作用的方法是 GetContainerForItemOverride。该方法返回用于包装每一项的对象。ItemsControl 返回 ContentPresenter,但 ListBox 返回 ListBoxItem,FluidableItemsControl 也返回 ListBoxItem。该 ListBoxItem 必须已应用样式,因此 FluidableItemsControl 也定义与 ListBox 相同的 ItemContainerStyle 属性。

另一个应该实现的方法是 IsItemItsOwnContainerOverride。如果 ItemsControl 中的项已经与其容器是同一类型(在本例中是 ListBoxItem),则无需将其放入另一个容器。现在您可以将 ListBoxItem 样式定义设置为 FluidableItemsControl 的 ItemContainerStyle 属性。样式定义中的模板可以大幅度简化。它不需要针对鼠标悬停、选定或输入焦点的逻辑,因为所有这些视觉状态都可以省略,也不需要三个 Rectangle 对象。

FluidItemsControl 程序显示了结果。它与 FluidListBox 极其相似,但缺少所有 ListBox 选择逻辑。ItemsControl 的默认面板是 StackPanel,因此是另一项简化。为了补偿这些简化,我增强了加载和卸载项的动画。现在,PlaneProjection 的变换动画可以使项看起来像是转进和转出视野。

限制和建议

即使已经拥有为 ItemsControl 或 ListBox 中的项定义动画的方案,还是存在一个重要的限制:如果控件集成了 ScrollViewer,则您无法为项定义有创意的变换。ScrollViewer 规定了严格的剪裁区域,不允许超出(就我所知这是可以肯定的)。这意味着我在上一个月的专栏文章中介绍的技术在 Silverlight 4 中仍然有效并且很重要。

但使用 VSM 在 Silverlight 4 中实现此流畅 UI 功能是一个好的信号,表明 VSM 在未来链接代码和 XAML 方面承担的角色越来越重要。我们作为应用程序开发人员现在可以开始考虑针对自定义的行为实现自己的视觉状态。

Charles Petzold 是《MSDN 杂志》的长期特约编辑。他目前正在撰写《Programming Windows Phone 7 Series》,该书将在 2010 年秋季作为可免费下载的电子书发布。现在,已通过其网站 charlespetzold.com 提供了预览版本。