UI 前沿技术

触摸和响应

Charles Petzold

下载代码示例

编程是一门工程学科,而不是一门科学或数学分支,因此一个问题只有一种正确解决方案的情况非常少。多样性和变化是常有的事,探讨这些替代方法而不是专注于一种特定的方法通常令人大开眼界。

我在八月份出版的*《MSDN 杂志》* 中发表的文章“WPF 中的多点触控操作事件”中,就已开始探讨 Windows Presentation Foundation (WPF) 版本 4 中引入的令人激动的多点触控支持。操作事件主要用于将多点触控输入整合到有用的几何转换以及帮助实现延时。

在那篇文章中,我介绍了两种相关方法来处理针对一系列 Image 元素的操作事件。在两种情况下,实际事件都由 Window 类处理。一个程序定义了用于被操作元素的操作事件的处理程序。另一种方法说明了如何重写 OnManipulation 方法,以便使相同事件通过可视化树进行路由。

自定义类方法

第三种方法也是可行的:可以为被操作元素定义一个重写其自己的 OnManipulation 方法的自定义类,而不是将此任务留给容器元素来完成。该方法的优点是您可以使用 Border 元素或其他元素修饰自定义类,从而使其更具有吸引力;这些修饰还可用于在用户触摸可操作元素时提供可视反馈。

当经验丰富的 WPF 程序员确定他们需要根据事件对控件进行可视更改时,他们可能会想到 EventTrigger,但 WPF 程序员需要开始过渡到使用视觉状态管理器。即使是从 UserControl 派生(这是我将使用的策略),实现起来也相当容易。

使用操作事件的应用程序或许应使可视反馈基于那些相同事件,而不是基于底层 TouchDown 和 TouchUp 事件。使用操作事件时,您需要使用 ManipulationStarting 或 ManipulationStarted 事件开始可视反馈。(您为此任务选择哪个事件实际上没有任何差别。)

但是,体验这种反馈时,您首先会发现第一次触摸某个元素时不会触发 ManipulationStarting 和 ManipulationStarted 事件,只有在该元素开始移动时才会触发这些事件。此行为是笔针界面遗留下来的,您需要通过对被操作元素设置以下附加属性来进行更改:

Stylus.IsPressAndHoldEnabled="False"

现在,第一次触摸某个元素时,将会触发 ManipulationStarting 和 ManipulationStarted 事件。 您需要使用 ManipulationInertiaStarting 或 Manipulation­Completed 事件关闭可视反馈,这取决于您是希望在用户的手指离开屏幕时结束反馈,还是希望在元素由于延时而停止移动后结束反馈。 如果您未使用延时(就像我不会在本文中使用一样),则使用哪个事件无关紧要。

本文的可下载代码包含在一个名为 TouchAndResponseDemos 的 Visual Studio 解决方案中,该解决方案包括两个项目。 第一个项目的名称为 FeedbackAndSmoothZ,它包括一个名为 ManipulablePictureFrame 的自定义 UserControl 派生物,用于实现操作逻辑。

ManipulablePictureFrame 定义一个类型为 Child 的属性,并使用其静态构造函数重新定义以下三个属性的默认值:HorizontalAlignment、VerticalAlignment 和至关重要的 IsManipulationEnabled。 实例构造函数调用 InitializeComponent(同往常一样),不过将控件的 RenderTransform 设置为 MatrixTransform(如果不是该值的话)。

在 OnManipulationStarting 事件期间,Manipulable­PictureFrame 类将调用以下函数:

VisualStateManager.GoToElementState(this, "Touched", false);

在 OnManipulationCompleted 事件期间,该类将调用以下函数:

VisualStateManager.GoToElementState(this, "Untouched", false);

这是我的代码文件为实现视觉状态所做的唯一贡献。 执行实际操作的代码与上个月的专栏中的代码类似,但有以下两个重大更改:

  • 在 OnManipulationStarting 方法中,ManipulationContainer 设置为元素的父项。
  • OnManipulationDelta 方法只是略为简单一些,因为所操作的元素是 Manipulable­PictureFrame 对象自身。

图 1 显示了完整的 ManipulablePictureFrame.xaml 文件。

图 1 ManipulablePictureFrame.xaml 文件  

<UserControl x:Class="FeedbackAndSmoothZ.ManipulablePictureFrame"
             xmlns=
       "https://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
             Stylus.IsPressAndHoldEnabled="False"
             Name="this">
    
    <VisualStateManager.VisualStateGroups>
        <VisualStateGroup x:Name="TouchStates">
            <VisualState x:Name="Touched">
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetName="maskBorder"
                                     Storyboard.TargetProperty="Opacity"
                                     To="0.33" Duration="0:0:0.25" />
                    
                    <DoubleAnimation Storyboard.TargetName="dropShadow"
                                     Storyboard.TargetProperty=          
                  "ShadowDepth"
                                     To="20" Duration="0:0:0.25" />
                </Storyboard>
            </VisualState>
            <VisualState x:Name="Untouched">
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetName="maskBorder"
                                     Storyboard.TargetProperty="Opacity"
                                     To="0" Duration="0:0:0.1" />

                    <DoubleAnimation Storyboard.TargetName="dropShadow"
                                     Storyboard.TargetProperty=     
                      "ShadowDepth"
                                     To="5" Duration="0:0:0.1" />
                </Storyboard>
            </VisualState>
        </VisualStateGroup>
    </VisualStateManager.VisualStateGroups>
    
    <Grid>
        <Grid.Effect>
            <DropShadowEffect x:Name="dropShadow" />
        </Grid.Effect>
        
        <!-- Holds the photo (or other element) -->
        <Border x:Name="border" 
                Margin="24" />
        
        <!-- Provides visual feedback -->
        <Border x:Name="maskBorder" 
                Margin="24" 
                Background="White" 
                Opacity="0" />
        
        <!-- Draws the frame -->
        <Rectangle Stroke="{Binding ElementName=this, Path=Foreground}" 
                   StrokeThickness="24" 
                   StrokeDashArray="0 0.9" 
                   StrokeDashCap="Round" 
                   RadiusX="24" 
                   RadiusY="24" />
        
        <Rectangle Stroke="{Binding ElementName=this, Path=Foreground}" 
                   StrokeThickness="8" 
                   Margin="16" 
                   RadiusX="24" 
                   RadiusY="24" />
    </Grid>
</UserControl>

名为“border”的边框用于承载 ManipulablePictureFrame 类的子类。该子类可能是 Image 元素,但不必一定如此。两个 Rectangle 元素在边框四周绘制了一种类型为“圆齿形”的相框,并且第二个边框用于可视反馈。

移动某个元素时,ManipulablePictureFrame.xaml 中的动画会使图片略为“变亮”(实际上这更像是“冲蚀”效果)并增加投影,如图 2 所示。

图 2 FeedbackAndSmoothZ 程序中突出显示的元素

在触摸事件期间,几乎任何类型的简单突出显示都可以提供可视反馈。但是,如果您使用的是可以触摸和操作的小元素,您将希望在触摸这些元素时使它们变大,以便不会被用户的手指完全遮盖。(另一方面,如果您还允许用户调整元素的大小,则您不会希望使元素变得更大来提供可视反馈。操作元素使其变为期望的大小,然后在您的手指离开屏幕时使元素略为缩小,这让人很为难!)

您将会注意到,在使图像缩小和变大时,相框也会相应地缩小或扩大。这种行为正确吗?可能正确。也可能不正确。在本文将要结束时我会介绍这种行为的替代行为。

平滑 Z 过渡

在我上个月介绍的程序中,触摸照片会导致该照片跳到前景。这正是我能想到的最简单方法,它要求对所有 Image 元素设置新的 Panel.ZIndex 附加属性。

简单介绍一下:通常,当 Panel 的子项重叠时,它们将按照其在 Panel 的 Children 集合中的位置从背景到前景排列。但是,Panel 类定义了一个名为 ZIndex 的附加属性,可以有效地取代子索引。(该名称暗指与屏幕的传统 XY 平面垂直的 Z 轴,从概念上说它是从屏幕移出的。)ZIndex 值较小的元素将位于背景中,而 ZIndex 值较大会将元素置于前景中。如果两个或多个重叠的元素具有相同的 ZIndex 设置(默认情况下即是如此),则将使用 Children 集合中这些元素的子索引来确定它们的排列顺序。

在先前的程序中,我使用了以下代码来设置新的 Panel.ZIndex 值,其中的变量元素是正被触摸的元素,而 pnl(类型为 Panel)是该元素及其同级元素的父元素:

for (int i = 0; i < pnl.Children.Count; i++)
     Panel.SetZIndex(pnl.Children[i],
        pnl.Children[i] == element ?
pnl.Children.Count : i);

这段代码确保被触摸的元素获得最大的 ZIndex 且显示在前景中。

遗憾的是,被触摸的元素突然跳到前景,这种移动相当不自然。 有时,其他元素会同时交换位置。 (如果有四个重叠的元素并且您触摸第一个元素,则该元素的 ZIndex 值将为 4,而其他元素的 ZIndex 值将分别为 1、2 和 3。 现在,如果您触摸第四个元素,则第一个元素的 ZIndex 值将回到 0,并且会突然显示在所有其他元素的后面。)

我的目的就是要避免元素突然对齐到前景和背景。 我希望获得一种更平滑的效果,类似于一张照片从一堆照片下面滑出然后滑落到最上面的过程。 我头脑中开始将这些过渡想成是“平滑 Z”。没有任何元素会跳到前景或背景,但当您四处移动元素时,该元素最终会位于所有其他元素的上方。 (ScatterView 控件中实现了一种备用方法,可从网址为 scatterview.codeplex.com/releases/view/24159 的 CodePlex 中下载。 处理大量项目时,ScatterView 肯定是首选。)

实现此算法时,我自己设置了一些条件。 首先,我不想维护从一个移动事件到下一个移动事件的状态信息。 也就是说,我不想分析被操作元素先前是否与另一个元素相交,但是否不再相交。 其次,我不想在 ManipulationDelta 事件期间执行内存分配,因为可能有大量这种工作。 第三,为了避免过于复杂,我想将相对 ZIndex 的更改仅限于被操作元素。

图 3 显示了完整算法。 该方法的关键点在于确定两个同级元素在视觉上是否相交。 有几种方法可以解决上述问题,但我使用的代码(在 AreElementsIntersecting 方法中)似乎是最简单的。 它重用两个存储为字段的 RectangleGeometry 对象。

图 3 平滑 Z 算法

// BumpUpZIndex with reusable SortedDictionary object
SortedDictionary<int, UIElement> childrenByZIndex = new 
SortedDictionary<int, UIElement>();

void BumpUpZIndex(FrameworkElement touchedElement, UIElementCollection siblings)
{
  // Make sure everybody has a unique even ZIndex
  for (int childIndex = 0; childIndex < siblings.Count; childIndex++)
  {
        UIElement child = siblings[childIndex];
        int zIndex = Panel.GetZIndex(child);
        Panel.SetZIndex(child, 2 * (zIndex * siblings.Count + childIndex));
  }

  int zIndexNew = Panel.GetZIndex(touchedElement);
  int zIndexCantGoBeyond = Int32.MaxValue;

  // Don't want to jump ahead of any intersecting elements that are on top
  foreach (UIElement child in siblings)
        if (child != touchedElement && 
            AreElementsIntersecting(touchedElement, (FrameworkElement)child))
        {
            int zIndexChild = Panel.GetZIndex(child);

            if (zIndexChild > Panel.GetZIndex(touchedElement))
                zIndexCantGoBeyond = Math.Min(zIndexCantGoBeyond, zIndexChild);
        }

  // But want to be in front of non-intersecting elements
  foreach (UIElement child in siblings)
        if (child != touchedElement && 
            !AreElementsIntersecting(touchedElement, (FrameworkElement)child))
        {
            // This ZIndex is odd, hence unique
            int zIndexNextHigher = 1 + Panel.GetZIndex(child);
            if (zIndexNextHigher < zIndexCantGoBeyond)
                zIndexNew = Math.Max(zIndexNew, zIndexNextHigher);
        }

  // Now give all elements indices from 0 to (siblings.Count - 1)
  Panel.SetZIndex(touchedElement, zIndexNew);
  childrenByZIndex.Clear();
  int index = 0;

  foreach (UIElement child in siblings)
        childrenByZIndex.Add(Panel.GetZIndex(child), child);

  foreach (UIElement child in childrenByZIndex.Values)
        Panel.SetZIndex(child, index++);
    }

// Test if elements are intersecting with reusable //       
RectangleGeometry objects
RectangleGeometry rectGeo1 = new RectangleGeometry();
RectangleGeometry rectGeo2 = new RectangleGeometry();

bool AreElementsIntersecting(FrameworkElement element1, FrameworkElement         
 element2)
{
 rectGeo1.Rect = new 
  Rect(new Size(element1.ActualWidth, element1.ActualHeight));
 rectGeo1.Transform = element1.RenderTransform;

 rectGeo2.Rect = new 
  Rect(new Size(element2.ActualWidth, element2.ActualHeight));
 rectGeo2.Transform = element2.RenderTransform;

 return rectGeo1.FillContainsWithDetail(rectGeo2) != IntersectionDetail.Empty;
}

BumpUpZIndex 方法执行大部分工作。 它首先确保所有同级元素具有唯一的 ZIndex 值,并且所有这些值都是偶数。 被操作元素的新 ZIndex 值不能大于与该元素相交且当前位于其上方的任何元素的任何 ZIndex 值。 考虑到此限制,该代码尝试分配一个大于所有未相交元素的 ZIndex 值的新 ZIndex 值。

到目前为止我所讨论的代码通常会导致 ZIndex 值无限制地逐步变大,最终超过最大正整数值,变成负数。 使用 SortedDictionary 可以避免这种情况。 所有同级元素都放入字典中,并将其 ZIndex 值用作关键字。 然后,可以根据字典中元素的索引为元素提供新的 ZIndex 值。

平滑 Z 算法有一两个奇怪之处。 如果被操作元素与元素 A 相交,但不与元素 B 相交,则当元素 B 的 ZIndex 值大于元素 A 的 ZIndex 值时,被操作元素将无法滑动到元素 B 的上方。 此外,尚不提供对同时处理
两个或多个元素的特殊支持。

不涉及转换的操作

在目前为止我介绍的所有示例中,都使用了随 ManipulationDelta 事件提供的信息来更改被操作元素的 RenderTransform。 这不是唯一的选择。 事实上,如果您不需要旋转,则可以实现根本不涉及任何转换的多点触控操作。

这种“无转换”方法涉及使用画布作为被操作元素的容器。 然后,您可以通过设置 Canvas.Left 和 Canvas.Top 附加属性在画布上移动这些元素。 更改元素的大小要求使用先前已使用的相同百分比 Scale 值或绝对 Expansion 值来操作 Height 和 Width 属性。

此方法的一个独特优势是您可以使用边框来修饰被操作元素,并且在您更改元素大小时,该边框自身不会变大和变小。

这种技术在 NoTransformManipulation 项目中进行了演示,该项目包括一个名为 NoTransformPictureFrame 的 UserControl 派生物,用于实现操作逻辑。

此新类中的相框远不及 ManipulablePictureFrame 中的相框那么精美。 先前的相框使用了虚线来产生圆齿形效果。 如果使这种相框变大以容纳更大的子项,但不应用转换,则线条粗细将保持不变,但虚线中的点数将会增加! 这看起来非常奇怪,并且对于实际程序来说,可能会过于让人分心。 新文件中的相框就是一个圆角的简单边框。

在 NoTransformManipulation 项目的 MainPage.xaml 文件中,画布上聚集了五个 NoTransformPictureFrame 对象,所有这些对象都包含 Image 元素并且都具有唯一的 Canvas.Left 和 Canvas.Top 附加属性。 此外,我还将每个 NoTransformPictureFrame 的宽度指定为 200,但未指定高度。 调整 Image 元素的大小时,通常最好只指定一个维度,并让元素选择自己的其他维度以保持合适的长宽比。

NoTransformPictureFrame.xaml.cs 文件的结构类似于 ManipulablePictureFrame 代码,但前者不需要转换代码。 OnManipulationDelta 重写可调整 Canvas.Left 和 Canvas.Top 附加属性并使用 Expansion 值来增大元素的 Width 属性。 当缩放功能生效时,只需要一点点技巧,因为需要调整转换因子以适应缩放的中心。

此外,还需要对在平滑 Z 过渡中发挥巨大作用的 AreElementsIntersecting 方法进行更改。 先前的方法构造了两个 RectangleGeometry 对象来反映两个元素的未转换维度,然后应用了两个 RenderTransform 设置。 图 4 显示了替换方法。 这些 RectangleGeometry 对象完全基于 Canvas.Left 和 Canvas.Top 附加属性所产生的元素偏移的实际大小。

图 4 不涉及转换的操作的备用平滑 Z 逻辑

bool AreElementsIntersecting(FrameworkElement element1, FrameworkElement element2)
{
    rectGeo1.Rect = new Rect(Canvas.GetLeft(element1), Canvas.GetTop(element1),
      element1.ActualWidth, element1.ActualHeight);

    rectGeo2.Rect = new Rect(Canvas.GetLeft(element2), Canvas.GetTop(element2),
      element2.ActualWidth, element2.ActualHeight);

    return rectGeo1.FillContainsWithDetail(rectGeo2) != IntersectionDetail.Empty;
}

遗留问题

在我讨论操作事件时,我忽略了一个重要功能,而这个不容忽视的功能已越来越强大。 该功能就是延时,我将在下一期中进行探讨。

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

*衷心感谢以下技术专家对本专栏的审阅:*Doug Kramer  Robert Levy