UI 前沿技术

多点触控延时

Charles Petzold

下载代码示例

计算机上的 UI 很炫,它们将底层技术的数字本质隐藏起来,取而代之模拟真实生活的感觉。这种趋势始自图形界面开始取代命令行时,然后随着照片、声音和其他媒体使用量的增加而一直在延续。

将多点触控融入视频显示器极大地推进了使用户控件更逼真且更直观的进程。我想,您已经开始感觉到触摸革新可能会改变您与计算机屏幕之间的关系。

或许某一天,我们甚至会淘汰众所周知最不美观的残余数字技术:按钮式音量控件。收音机和电视机上控制音量(和其他设置)的基本调节盘就非常简单,但已被遥控器以及电器本身上并不美观的按钮所替代。

计算机界面通常使用滑块来控制音量。这些几乎与调节盘一样好用。但是按钮式音量控件仍然存在。甚至 Zune HD 的多点触控屏幕也希望您按加号和减号来增大和降低音量。更好的将是响应触摸的调节盘。

我喜欢用调节盘来实现多点触控。或许,这就是为什么我在文章“手指之舞:探讨 Silverlight 中的多点触控支持”(刊登在 2010 年三月出版的*《MSDN 杂志》* 上:msdn.microsoft.com/magazine/ee336026)中着重调节盘模拟,以及为什么我要重返调节盘以探讨如何在 Windows Presentation Foundation (WPF) 中处理多点触控延时的原因。

延时事件

多点触控界面尝试模拟现实世界的方法之一是引入延时 — 对象维持同一速度的倾向,除非受到外力作用,如摩擦。在多点触控界面中,延时可以在手指离开屏幕后使可视对象保持移动。最常见的触摸延时应用是导航列表,延时已内置于 WPF ListBox 中。

在本文可下载的代码中,有一个称为 IntertialListBoxDemo 的小程序,它只是向 WPF ListBox 中填充了一些项,您可以在自己的多点触控显示器上测试此程序。

在您自己的 WPF 控件中,您需要确定所需延时的多少(如果有的话)。如果只是希望延时在直接操作时从您的程序引发同样的响应,那么延时的处理非常简单。比较难的部分是深入了解涉及到的数值。

正如您在之前的文章中所见,WPF 元素通过两个事件获知多点触控操作的开始:ManipulationStarting(这是执行一些初始化的良机)和 ManipulationStarted。这两个事件之后,可能是非常多的 ManipulationDelta 事件,这些事件会将一根、两根或多根手指操作整合为平移、缩放以及旋转信息。

当所有手指都离开操作的元素后,ManipulationInertiaStarting 事件发生。这是您的程序指定所需延时的机会。(马上我就会对此进行介绍。)延时的效果以其他 ManipulationDelta 事件的形式表现,直至对象“停止”,ManipulationCompleted 事件发出结束信号。如果您不采用 ManipulationInertiaStarting,其后将立即出现 ManipulationCompleted。

使用 ManipulationDelta 事件指示直接操作和延时会极大地简化这一整个架构的使用。基本上,您可以在 ManipulationInertiaStarting 事件中通过一个语句实现延时。如果有必要,您可以根据 ManipulationDeltaEventArgs 的 IsInertial 属性辨别直接操作和延时之间的不同。

正如您将看到的,可以使用两种不同方式之一指定所需延时,但是这两种方法都涉及到减速度 — 一段时间内速度都会持续降低,直至速度达到零,对象停止移动。其中将涉及到一些程序员不常遇到的物理概念,因此,可能只有少许的复习课程会有所帮助。

加速度回顾

如果某物体正在移动,就称其具有速度速率,可以用单位时间内移动的距离来表示。如果速率本身在一段时间内不断变化,则该对象就具有加速度。负加速度(指示不断减小的速率)通常称为减速度

在此探讨中,我们假定加速度或减速度本身是恒定不变的。恒定的加速度意味着速率的变化呈线性 — 单位时间内的变化量相等。如果加速度为 0,则速率保持恒定。

例如,汽车制造商有时候在推广其产品时会说,其生产的汽车能够在一定秒数(比如说 8 秒)内从 0 加速到 60 mph。汽车一开始处于停止状态,但在 8 秒结束后,其速度将达到 60 mph,如图 1 所示。

图 1 加速度

速度
0 0 mph
1 7.5
2 15
3 22.5
4 30
5 37.5
6 45
7 52.5
8 60

请注意,每秒速率增加的量是相同的。加速度表示速率在一特定时间段内的变化,在本例中,即为每秒 7.5 mph。

该加速度值理解起来有点困难,因为其中涉及到了两个时间单位:小时和秒。让我们舍弃小时,只使用秒。因为 60 mph 是 88 英尺/秒,所以可以这么说,汽车在 8 秒内从 0 加速到 88 英尺/秒。加速度是每秒 11 英尺/秒,或(通常是这么说)11 英尺/平方秒。

我们可以转换为英里和小时,但是将加速度计算为 27,000 英里/平方小时有些荒谬。这意味着在一小时结束时,汽车将以 27,000 mph 速度行驶。当然,这是不可能实现的。在实际生活中,当汽车达到 60 mph 左右时,速率稳定下来并且加速度回归至 0。这是加速度的变化,在工程界称为跃度

但对于本练习,我们假定加速度恒定。从速率为 0 开始,加速度为 a,在时间 t 内行驶的距离为 x,其结果是:

½ 和平方都是必需的,因为速率 v 是作为与时间对应的距离的第一个导数进行计算的:

如果 a 是 11 英尺/平方秒,则 t 等于 1 秒,速率为 11 英尺/秒,但汽车只行驶了 5.5 英尺。在 t 等于 2 秒时,速率为 22 英尺/秒,汽车总共行驶了 22 英尺。每秒内,汽车行驶的距离根据该秒内的平均速率计算。在 t 等于 8 秒时,速率为 88 英尺/秒,汽车总共行驶了 352 英尺。

多点触控延时就像反过来看汽车一样:当手指离开屏幕时,对象具有一定的速率。应用程序指定一个可使对象速率线性减至 0 的减速度。减速度愈大,对象停止得愈快。减速度为 0 将导致对象以同样的速率永远移动下去。

两种减速方式

ManipulationDelta 事件参数包括类型为 ManipulationVelocities 的 Velocities 属性,其本身具有与三种操作类型对应的三个属性:

  • LinearVelocity,类型为 Vector
  • ExpansionVelocity,类型为 Vector
  • AngularVelocity,类型为 double

前两个属性表示为设备无关单位/毫秒;第三个属性以角度(度)/毫秒为单位。当然,毫秒不是那种易理解的直观时间段,因此,如果需要查看这些值并了解其含义,您需要将其乘以 1,000 以换算为秒。

应用程序不常使用这些 Velocity 属性,但它们为延时提供了初始速率。用户手指离开屏幕后,ManipulationInertiaStarting 事件发生。该事件参数包括以下三个属性,可用于为那些相同的三种操作类型分别指定延时:

  • TranslationBehavior,类型为 InertiaTranslationBehavior
  • ExpansionBehavior,类型为 InertiaExpansionBehavior
  • RotationBehavior,类型为 InertiaRotationBehavior

其中每个类都具有 InitialVelocity 属性、DesiredDeceleration 属性以及第三个名为 DesiredDisplacement、DesiredExpansion 或 DesiredRotation 的属性(具体取决于该类)。

对于平移和旋转,所有 Desired 属性都具有 Double.NaN 的默认值,即指示“不是数字”的特殊位配置。对于扩展,Desired 属性的类型均为 Vector,具有 Double.NaN 的 X 和 Y 值,但道理是一样的。

我们首先来看旋转延时,因为其效果类似真实世界(如操场环形跑道),您不必担心对象飞出屏幕。

当您看到 ManipulationInertiaStarting 事件发生时,InertiaRotationBehavior 对象已创建,InitialVelocity 值已在之前的 ManipulationDelta 事件中进行了设置。例如,如果 InitialVelocity 为 1.08,即 1.08 度/毫秒或 1,080 度/秒,或三周/秒,或 180 rpm。

若要保持对象旋转,可设置 DesiredRotation 或 DesiredDeceleration,但不能设置这两者。如果尝试设置这两者,后一个设置将生效,另一个将是 Double.NaN。

第一个选项是将 DesiredRotation 设置为以度数表示的值。这是对象在停止前旋转的度数。例如,如果将 DesiredRotation 设置为 360,旋转对象将额外多转一圈,并在此过程中逐渐减速直至停止。优点是您获得了相同量的延时活动(无论初始速度如何),因此,可以轻松预测将要发生的情况。缺点是这不太自然。

另一种方式是将 DesiredDeceleration 设置为以度/平方毫秒单位表示的值,这有点难,因为难于猜测出一个比较适合的值。

如果 InitialVelocity 是 1.08 度/毫秒,而您将 DesiredDeceleration 设置为 0.01 度/平方毫秒,则速率将每毫秒减少 0.01 度:在第一毫秒结束时减少到 1.07 度/毫秒,在第二毫秒结束时减少到 1.06 度/毫秒,以此类推,速率线性降低直至为 0。整个过程将花费 108 毫秒,或比一秒的十分之一多点。

您可能希望将 DesiredDeceleration 设置为比此值更小的值,或许是 0.001 度/平方毫秒,这将使对象连续旋转 1.08 秒。

如果您希望将减速度单位转换为大家更易于思考的某种单位,请谨慎行事。速率 1.08 度/毫秒等同于 1,080 度/秒,但 0.001 度/平方毫秒的减速度则等同于 1,000 度/平方秒。若要将减速度转换为秒,您需要乘以 1,000 两次,因为时间是经过平方计算的。

如果将之前演示的两个公式合并,并去除 t,您将获得:

这意味着设置减速度的两种方法是等同的,可以相互转换。如果 InitialVelocity 是 1.08 度/毫秒,而您将 DesiredDeceleration 设置为 0.001 度/平方毫秒,则这等同于将 DesiredRotation 设置为 583.2 度。在任一示例中,旋转都将在 1,080 毫秒后停止。

实验

为了了解旋转延时,我构建了一个 RotationalInertiaDemo 程序,如图 2 所示。

图 2 运行中的 RotationalInertiaDemo 程序

左边的轮形图是您用手指在转动,顺时针或逆时针均可。它非常简单:只是一个 UserControl 派生类,在 Grid 中有两个 Ellipse 元素,所有 Manipulation 事件都在 MainWindow 中处理。

ManipulationStarting 事件通过将操作限制为仅旋转、允许单根手指旋转以及设置旋转中心来执行初始化:

args.IsSingleTouchEnabled = true;
args.Mode = ManipulationModes.Rotate;
args.Pivot = new ManipulationPivot(
             new Point(ctrl.ActualWidth / 2, 
                       ctrl.ActualHeight / 2), 50);

右侧的速度计是一个称为 ValueMeter 的类,上面显示了当前的轮速率。 如果这看起来有点眼熟,那仅仅是因为这是我三年前为该杂志撰写的一篇文章中用到的 ProgressBar 模板的增强版。 增强的地方包括通过标签增加了一些灵活性,这样我可以用它以四种不同单位显示速率。 窗口中部的 GroupBox 可供您选择这些单位。

当您的手指旋转调节盘时,该仪表盘将显示 ManipulationDelta 事件参数的 Velocities.AngularVelocity 子属性提供的当前角速度。 但我发现,要将调节盘的速率直接移至 ValueMeter 是不可能的。 结果太让人紧张不安。 我必须编写一个小 ValueSmoother 类从第四分之一秒处开始执行所有值的加权平均值。 ManipulationDelta 事件处理程序还将设置一个实际旋转调节盘的 RotateTransform 对象:

rotate.Angle += args.DeltaManipulation.Rotation;

最后,可以使用底部的滑块选择减速度值。 仅在手指离开调节盘并且 ManipulationInertiaStarted 事件触发后,才会读取滑块的值:

args.RotationBehavior.DesiredDeceleration = slider.Value;

这是 ManipulationInertiaStarted 事件处理程序的整体内容。 在操作的延时阶段期间,速率值非常有规律,不必进行调整,因此 ManipulationDelta 处理程序使用 IsInertial 属性确定将速率值直接传递至仪表盘的时间。

边界和回弹

多点触控中最常用的延时是在屏幕上四处移动对象,如滚动一个长长的列表或快速将元素移至一边。 一个重要问题是,这样很容易导致元素飞离屏幕!

但在处理这类小问题的过程中,您会发现,WPF 具有一个内置功能,可以使操作延时更真实。 您之前可能在 ListBoxDemo 程序中通过让延时滚动至列表的末尾或开头时已经注意到这点,整个窗口在 ListBox 到达终点时弹回一点。 您在自己的应用程序中也可实现此效果(如果希望)。

BoundaryDemo 程序只包含一个椭圆,其中在名为 mainGrid 的 Grid 中有一个设置为其 RenderTransform 属性设置的标识 MatrixTransform。 OnManipulationStarting 重写期间仅支持平移。 OnManipulationInertiaStarting 方法将延时减速度设置为如下所示的值:

args.TranslationBehavior.DesiredDeceleration = 0.0001;

即 0.0001 设备无关单位/平方毫秒,或 100 设备无关单位/平方秒,或大约 1 英寸/平方秒。

OnManipulationDelta 重写如图 3 所示。 请注意当 IsInertial 为真时的特殊处理。 这段代码的意思是,当椭圆部分漂离屏幕时,平移因数应当衰减。 如果椭圆位于 mainGrid 内,则衰减因数为 0,而当椭圆的小部分超过了 mainGrid 的边界时,则此因数上升至 1。 然后,将此衰减因数应用到传递至该方法的平移矢量(称为 totalTranslate)以计算 usableTranslate,这将提供实际应用到转换矩阵的值。

图 3 BoundaryDemo 中的 OnManipulationDelta 事件

protected override void OnManipulationDelta(
  ManipulationDeltaEventArgs args) {

  FrameworkElement element = 
    args.Source as FrameworkElement;
  MatrixTransform xform = 
    element.RenderTransform as MatrixTransform;
  Matrix matx = xform.Matrix;
  Vector totalTranslate = 
    args.DeltaManipulation.Translation;
  Vector usableTranslate = totalTranslate;

  if (args.IsInertial) {
    double xAttenuation = 0, yAttenuation = 0, attenuation = 0;

    if (matx.OffsetX < 0)
      xAttenuation = -matx.OffsetX;
    else
      xAttenuation = matx.OffsetX + 
        element.ActualWidth - mainGrid.ActualWidth;

    if (matx.OffsetY < 0)
      yAttenuation = -matx.OffsetY;
    else
      yAttenuation = matx.OffsetY + 
        element.ActualHeight - mainGrid.ActualHeight;

    xAttenuation = Math.Max(0, Math.Min(
      1, xAttenuation / (element.ActualWidth / 2)));

    yAttenuation = Math.Max(0, Math.Min(
      1, yAttenuation / (element.ActualHeight / 2)));

    attenuation = Math.Max(xAttenuation, yAttenuation);

    if (attenuation > 0) {
      usableTranslate.X = 
        (1 - attenuation) * totalTranslate.X;
      usableTranslate.Y = 
        (1 - attenuation) * totalTranslate.Y;

      if (totalTranslate != usableTranslate)
        args.ReportBoundaryFeedback(
          new ManipulationDelta(totalTranslate – 
          usableTranslate, 0, new Vector(), new Vector()));

      if (attenuation > 0.99)
        args.Complete();
    }
  }
  matx.Translate(usableTranslate.X, usableTranslate.Y);
  xform.Matrix = matx;

  args.Handled = true;
  base.OnManipulationDelta(args);
}

最终的效果是,椭圆在触碰到边界时没有立即停止,而是明显地慢下来,就好像碰到了一大块淤泥一样。

OnManipulationDelta 重写还将调用 ReportBoundaryFeedback 方法,以向其传递平移因数的未使用部分,即矢量 totalTranslate 减去 usableTranslate。 默认情况下,经过处理后,窗口在椭圆慢下来时会回弹一点,以演示物理原理:每个动作都存在一个相反的对等反作用力。

该效果在碰撞速率相当大时尤为明显。 如果椭圆明显慢下来,则会出现不太令人满意的震动效果,而您可能希望进行更为细致的控制。 如果根本不想要这个效果(或只想在某些情况下使用),只需避免调用 ReportBoundaryFeedback 即可。 或者,您可以自己处理 ManipulationBoundaryFeedback 事件。 您可以通过将该事件参数的 Handled 属性设置为真来禁用默认处理,或可以采用另一种方法。 我在程序中预留了空的 OnManipulationBoundaryFeedback 方法,您可以自行实验。

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

衷心感谢以下技术专家对本文的审阅:Doug KramerRobert Levy