手指之舞

探讨 Silverlight 中的多点触控支持

Charles Petzold

下载示例代码

每次去纽约的美国自然历史博物馆,我必定会好好参观一下灵长类馆。灵长类馆选择了大量的骨骼和剥制标本,展示了一幅灵长类动物进化的全景,动物从极小的树鼩、狐猴和绒猴一直到黑猩猩、大猩猩和人类。

这个展览最引人注目的是所有灵长类动物都有着惊人的共性:手的骨骼结构相同,包括一个对生拇指。这种使我们的祖先和远亲可以抓握从而爬上树枝的相同关节排列和数目,让我们的物种可以影响周围的世界和从事建造工作。我们的双手可能源于数百万年前小型灵长类动物的爪子,而双手也是使我们真正成为人类的重要因素。

我们会本能地伸出手指指点甚至触摸计算机屏幕上的显示内容,又有什么奇怪呢?

为了满足人类希望使手指与计算机更密切相融的愿望,我们的输入设备也一直在不断发展。鼠标对于选择和拖动操作游刃有余,但对于形态自由的素描和手写操作却无法胜任。我们可以用 Tablet 笔针流畅书写,但在拉伸或移动时却倍感困难。我们从 ATM 和博物馆售票处熟悉了触摸屏,但通常仅限于简单的点和按操作。

我认为称为“多点触控”的这项技术代表了一个巨大的飞跃。顾名思义,多点触控可以检测到多个手指,超越了过去的触摸屏概念,在通过屏幕可传达的移动和手势类型方面,产生了巨大差异。多点触控已经从过去的触摸式输入设备发展到一个新阶段,但同时,在本质上这是与以前不同的输入模式。

多点触控最明显的应用可能是在电视新闻节目中,大屏幕上显示一些地图供其气象预报员或专家操作。Microsoft 已在几个方面研究多点触控(从咖啡桌大小的 Microsoft Surface 计算机到 Zune HD 之类的小型设备),并且这项技术已完全成为智能手机上的标准。

尽管 Microsoft Surface 可同时响应多个手指(甚至包括数台内部照相机来查看玻璃上放置的物体),其他大多数多点触控设备仅限于离散的数。许多设备只能响应两个手指(即所谓的触摸点)。(我将把手指和触摸点作为同义词汇使用。)但协作效应发挥着作用:在计算机屏幕上,两个手指的作用大于一个手指的两倍作用。

两个触摸点的限制是多点触控显示器的特征,这种显示器最近已经可用于桌面 PC 和便携式计算机,以及去年 11 月在 Microsoft 专业开发人员会议 (PDC) 上向与会者发布的定制 Acer Aspire 1420P 便携式计算机(通常称为 PDC 便携式计算机)。PDC 便携式计算机的发布为成千上万的开发人员提供了一个难得的机会来编写多点触控感知应用程序。

我以前使用 PDC 便携式计算机探索 Silverlight 3 下的多点触控支持。

Silverlight 事件和类

多点触控支持正成为各种 Windows API 和框架中的标准。这种支持已内置到 Windows 7 和即将发布的 Windows Presentation Foundation (WPF) 4 中。(Microsoft Surface 计算机也基于 WPF,但包括了一些自定义扩展以实现其非常特殊的一些功能。)

在本文中,我准备重点介绍 Silverlight 3 中的多点触控支持。该支持稍有不足,但肯定已经够用,在探讨基本的多点触控概念时非常有用。

如果您将一个多点触控 Silverlight 应用程序发布到您的网站,谁能够使用它?用户将需要一个多点触控显示器,当然还需要在支持多点触控的操作系统和浏览器下运行 Silverlight 应用程序。目前,在 Windows 7 下运行的 Internet Explorer 8 提供这种支持,将来可能会有更多的操作系统和浏览器支持多点触控。

Silverlight 3 对多点触控的支持由 5 个类、1 个委派、1 个枚举和 1 个单一事件组成。您的 Silverlight 程序是否在多点触控设备上运行,如果是,该设备支持多少个触摸点,这些是无法确定的。

一个需要响应多点触控的 Silverlight 应用程序必须将一个处理程序连接到静态 Touch.FrameReported 事件:

Touch.FrameReported += OnTouchFrameReported;

您可以在未配备多点触控显示器的计算机上连接此事件处理程序,而不会出现问题。FrameReported 事件是静态 Touch 类的唯一公共成员。处理程序如下所示:

void OnTouchFrameReported(
  object sender, TouchFrameEventArgs args) {
  ...
}

您可以在应用程序中安装多个 Touch.FrameReported 事件处理程序,所有这些事件处理程序都会报告应用程序中任何位置的所有触控事件。

TouchFrameEventArgs 有一个名为 TimeStamp 的公共属性(我还没有机会使用)和三个重要的公共方法:

  • TouchPoint GetPrimaryTouchPoint(UIElement relativeTo)
  • TouchPointCollection GetTouchPoints(UIElement relativeTo)
  • void SuspendMousePromotionUntilTouchUp()

GetPrimaryTouchPoint 或 GetTouchPoints 的参数仅用于报告 TouchPoint 对象的位置信息。您可以将空值用于此参数,位置信息相对于整个 Silverlight 应用程序的左上角。

多点触控支持多个手指同时触摸屏幕,触摸屏幕的每个手指(数量存在上限,当前通常为两个)都是一个触摸点。主要触摸点是指在没有其他手指触摸屏幕并且未按下鼠标按钮时触摸屏幕的手指。

用一个手指触摸屏幕。这是主要触摸点。在第一个手指仍触摸着屏幕时,将第二个手指放在屏幕上。很显然,第二手指不是主要触摸点。但现在仍将第二个手指放在屏幕上,抬起第一个手指,然后再将其放回到屏幕上。这是主要触摸点吗?不,都不是。仅当没有其他手指触摸屏幕时,才会出现主要触摸点。

主要触摸点将映射到不会提升为鼠标的触摸点。在实际的多点触控应用程序中,您应该注意不要依赖主要触摸点,因为用户通常不会重视第一次触摸的特定意义。

仅为实际触摸屏幕的手指激发事件。对非常接近屏幕但并未触摸屏幕的手指,不会进行悬停检测。

默认情况下,与主要触摸点相关的活动将提升为各鼠标事件。这使现有应用程序能够响应触摸,而不需要进行任何特殊的编码。触摸屏幕将成为 MouseLeftButtonDown 事件,手指触摸屏幕的同时移动将成为 MouseMove 事件,举起手指将成为 MouseLeftButtonUp 事件。

鼠标消息附带的 MouseEventArgs 对象包含一个名为 StylusDevice 的属性,可帮助将鼠标事件与输入笔事件和触摸事件区分开来。我使用 PDC 便携式计算机的体验:当事件源于鼠标时,DeviceType 属性等同于 TabletDeviceType.Mouse,无论用手指还是笔针触摸屏幕,DeviceType 属性都等同于 TabletDeviceType.Touch。

仅主要触摸点会提升为鼠标事件,而您可以禁止该提升(从 TouchFrameEventArgs 第三个方法的名称可以看出)。稍后将对此进行详细介绍。

可能会基于一个触摸点或多个触摸点激发一个特殊的 Touch.FrameReported 事件。从 GetTouchPoints 方法返回的 TouchPointCollection 包含与特定事件关联的所有触摸点。从 GetPrimaryTouchPoint 返回的 TouchPoint 始终是一个主要触摸点。如果没有与该特定事件关联的主要触摸点,GetPrimaryTouchPoint 将返回 null。

即使从 GetPrimaryTouchPoint 返回的 TouchPoint 不是 null,它也不会与从 GetTouchPoints 返回的其中一个 TouchPoint 对象是相同对象,但在传递给这些方法的参数相同时所有属性将相同。

TouchPoint 类定义以下四个只读属性,这些属性都由依赖属性支持:

  • Action 属性,类型为 TouchAction(一个具有 Down、Move 和 Up 成员的枚举)。
  • Position 属性,类型为 Point,它相对于作为参数传递给 GetPrimaryTouchPoint 或 GetTouchPoints 方法的元素(或相对于参数为 null 的应用程序的左上角)。
  • Size 属性,类型为 Size。Size 信息在 PDC 便携式计算机上不可用,因此我根本就没有使用此属性。
  • TouchDevice 属性,类型为 TouchDevice。

只有 GetPrimaryTouchPoint 返回一个非 null 对象,并且 Action 属性等于 TouchAction.Down 时,才能从事件处理程序调用 SuspendMousePromotionUntilTouchUp 方法。

TouchDevice 对象具有两个也由依赖属性支持的只读属性:

  • DirectlyOver 属性,类型为 DirectlyOver(手指下最上面的元素)。
  • Id 属性,类型为 int。

DirectlyOver 不必是传递给 GetPrimaryTouchPoint 或 GetTouchPoints 的元素的子项。如果手指在 Silverlight 应用程序内(由 Silverlight 插件对象的尺寸定义),但不在可点击测试控件覆盖的范围内,则此属性可以为空。(具有空背景刷的面板不可点击测试。)

若要在多个手指之间区分,ID 属性至关重要。在特定手指触摸屏幕时,与该手指关联的一系列特定事件总是以 Down 操作开始,接着是 Move 事件,最后是 Up 事件。所有这些事件都将与相同的 ID 关联。(但不要以为主要触摸点的 ID 值将为 0 或 1。)

大多数重要的多点触控代码都会使用 Dictionary 集合,其中 TouchDevice 的 ID 属性是字典键。这是跨事件存储特定手指信息的方式。

检查事件

研究新输入设备时,编写一个小应用程序记录屏幕上的事件以便进行初步了解,总是很有帮助的。在本文附带的可下载代码中,有一个名为 MultiTouchEvents 的项目。该项目包括两个并列的 TextBox 控件,显示两个手指的多点触控事件。如果您拥有多点触控显示器,则可以运行这个程序,网址为 charlespetzold.com/silverlight/MultiTouchEvents。

该 XAML 文件由仅包含两列的网格组成,其中有分别名为 txtbox1 和 txtbox2 的两个 TextBox 控件。图 1 显示了代码文件。

图 1 MultiTouchEvents 的代码

using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace MultiTouchEvents {
  public partial class MainPage : UserControl {
    Dictionary<int, TextBox> touchDict = 
      new Dictionary<int, TextBox>();

    public MainPage() {
      InitializeComponent();
      Touch.FrameReported += OnTouchFrameReported;
    }

    void OnTouchFrameReported(
      object sender, TouchFrameEventArgs args) {

      TouchPoint primaryTouchPoint = 
        args.GetPrimaryTouchPoint(null);

      // Inhibit mouse promotion
      if (primaryTouchPoint != null && 
        primaryTouchPoint.Action == TouchAction.Down)
        args.SuspendMousePromotionUntilTouchUp();

      TouchPointCollection touchPoints = 
        args.GetTouchPoints(null);

      foreach (TouchPoint touchPoint in touchPoints) {
        TextBox txtbox = null;
        int id = touchPoint.TouchDevice.Id;
        // Limit touch points to 2
        if (touchDict.Count == 2 && 
          !touchDict.ContainsKey(id)) continue;

        switch (touchPoint.Action) {
          case TouchAction.Down:
            txtbox = touchDict.ContainsValue(txtbox1) ? 
              txtbox2 : txtbox1;
            touchDict.Add(id, txtbox);
            break;

          case TouchAction.Move:
            txtbox = touchDict[id];
            break;
 
          case TouchAction.Up:
            txtbox = touchDict[id];
            touchDict.Remove(id);
            break;
        }

        txtbox.Text += String.Format("{0} {1} {2}\r\n", 
          touchPoint.TouchDevice.Id, touchPoint.Action, 
          touchPoint.Position);
        txtbox.Select(txtbox.Text.Length, 0);
      }
    }
  }
}

请注意类上方的字典定义。该字典跟踪哪一个 TextBox 与两个触摸点 ID 相关联。

OnTouchFrameReported 处理程序首先禁止所有鼠标提升。这是调用 GetPrimaryTouchPoint 的唯一原因,往往也是在实际程序中调用此方法的唯一原因。

foreach 循环枚举从 GetTouchPoints 返回的 TouchPointCollection 的 TouchPoint 成员。因为该程序只包含两个 TextBox 控件,只能处理两个触摸点,所以它忽略字典已有两个并且 ID 不在该字典中的任何触摸点。(您会希望多点触控感知 Silverlight 程序能处理多个手指,但您不会希望它在检测到太多手指时出现故障!)ID 将添加到 Down 事件的字典中,然后从 Up 事件的字典中删除。

您会发现,有时在文本过多时,TextBox 控件会陷入停顿,需要选中所有文本并将其删除(Ctrl-A、Ctrl-X)才能让程序重新平稳运行。

该程序介绍的是多点触控输入是在应用程序级别上捕获的。例如,如果您将手指按在应用程序上,然后将其从应用程序上移开,则应用程序将继续接收 Move 事件,直到最后您举起手指时接收一个 Up 事件。实际上,一旦应用程序获得某种多点触控输入,将禁止到其他应用程序的多点触控输入,并且鼠标光标会消失。

这种以应用程序为中心来捕获多点触控输入的方法使 MultiTouchEvents 应用程序对自身非常确信。例如,发生 Move 和 Down 事件时,该程序仅假设 ID 将在字典中。在实际应用程序中,您可能希望万一发生意外时更加无懈可击,但是您将始终获得 Down 事件。

两个手指处理

在标准多点触控应用方案中,其中一种是允许用手指移动、缩放和旋转照片的照片库。我也决定尝试一些类似但更简单的应用场景,只是为了让自己更熟悉涉及的原理。我的程序版本只处理一个项目(单词“TOUCH”的文本字符串)。您可以运行我网站上的 TwoFingerManipulation 程序,网址为 charlespetzold.com/silverlight/TwoFingerManipulation。

编写多点触控应用程序的代码时,您可能始终会禁止多点触控感知控件的鼠标提升。但要在不具备多点触控显示器的情况下使程序可用,还需要添加特定的鼠标处理。

如果您只有一个鼠标或只使用一个手指,仍可以在 TwoFingerManipulation 程序内移动字符串,但只能改变其位置(称为转换的图形操作)。将两个手指放在多点触控屏幕上,您还可以缩放对象并进行旋转。

当我坐下来用纸笔计算出进行此缩放和旋转所需的算法时,很快就变得非常明显:没有唯一的解决方案!

假设一个手指保持在点 ptRef 上。(这里,所有点都相对于被处理对象下的显示器表面。)另一个手指从点 ptOld 移动到点 ptNew。如图 2 所示,可以仅使用这 3 个点来计算对象的水平和垂直缩放比例。

图 2 转换为缩放比例的两个手指移动

例如,水平缩放是 ptOld.X 和 ptNew.X 到 ptRef.X 距离的增加,或:

scaleX = (ptNew.X – ptRef.X) / (ptOld.X – ptRef.X)

垂直缩放与此类似。对于图 2 中的示例,水平缩放比例为 2,垂直缩放比例为 ½。

这确实是简单的编码方式。然而,如果用两个手指旋转该对象,则程序看起来运行得更自然。如图 3 所示。

图 3 转换为旋转和缩放的两个手指移动

首先,计算两个矢量的角度(从 ptRef 到 ptOld,以及从 ptRef 到 ptNew)。(Math.Atan2 方法非常适合于完成这一任务。)然后,按这些角度的差值相对于 ptRef 旋转 ptOld。接着,将旋转后的 ptOld 与 ptRef 和 ptNew 一起用来计算缩放比例。这些缩放比例少多了,因为已经除去了一个旋转分量。

实际算法(已在 C# 文件的 ComputeMoveMatrix 方法中实现)证明是相当容易的。然而,该程序还需要一组转换支持代码来弥补 Silverlight 转换类的不足,这些类不像在 WPF 中那样具有公开的 Value 属性或矩阵乘法。

在实际程序中,两个手指可同时移动,并且处理这两个手指之间的交互比最初看起来要容易得多。将另一个手指作为参考点,独立处理每个移动的手指。尽管计算的复杂性增加了,结果看起来更为自然,我认为可以这样简单解释:在现实生活中,用手指旋转物体十分常见,却很少缩放物体。

旋转在现实世界中是如此普遍,当只用一个手指或用鼠标来处理对象时,进行旋转可能十分有用。另一个程序 AltFingerManipulation(可在 charlespetzold.com/silverlight/AltFingerManipulation 上运行)对此进行了演示。对于两个手指,该程序的处理方式与 TwoFingerManipulation 一样。对于一个手指,它相对于对象的中心计算旋转,然后对中心进行额外的移动转换。

使用更多事件封装事件

通常,我喜欢使用 Microsoft 经过深思熟虑在框架中提供的类,而不是将它们封装在自己的代码中。但我思考过一些多点触控应用程序,我认为它们会受益于更成熟的事件接口。

首先,我需要一个更加模块化的系统。我希望同时使用能自行处理其触控输入的自定义控件和只让触控输入转换为鼠标输入的现有 Silverlight 控件。我还希望实现捕获。尽管 Silverlight 应用程序自身可以捕获多点触控设备,我还是希望使用一些单独的控件独立捕获特定触摸点。

我还需要 Enter 和 Leave 事件。在某种意义上,这些事件与捕获模式相反。要了解这种差别,请设想一个屏幕钢琴键盘,其中每个键都是 PianoKey 控件的一个实例。首先,您可以将这些键想象为鼠标触发的按钮。发生鼠标按下事件时,钢琴键将打开一个乐音;发生鼠标弹起事件时,钢琴键将关闭该乐音。

但这并不是您想要的钢琴键。您希望能够将手指在键盘上弹起、按下,达到滑奏效果。这些键确实不应关心 Down 和 Up 事件。它们确实只关心 Enter 和 Leave 事件。

WPF 4 和 Microsoft Surface 已具有路由的触控事件,这些事件很可能在未来引入到 Silverlight 中。但我通过一个我称为 TouchManager 的类满足了目前的需要,这个类已在 TouchDialDemos 解决方案的 Petzold.MultiTouch 库项目中实现。TouchManager 的大部分由静态方法、字段和 Touch.FrameReported 事件(允许它管理整个应用程序中的触控事件)的一个静态处理程序组成。

要在 TouchManager 中注册的类创建了一个实例,如下所示:

TouchManager touchManager = new TouchManager(element);

构造函数参数的类型为 UIElement,它通常将是创建对象的元素:

TouchManager touchManager = new TouchManager(this);

通过在 TouchManager 中注册,该类指示它关注所有多点触控事件(其中 TouchDevice 的 DirectlyOver 属性是传递给 TouchManager 构造函数的元素的子项),并且这些多点触控事件不应提升为鼠标事件。无法取消注册元素。

创建 TouchManager 的新实例后,类可以为名为 TouchDown、TouchMove、TouchUp、TouchEnter、TouchLeave 和 LostTouchCapture 的事件安装处理程序:

touchManager.TouchEnter += OnTouchEnter;

根据 EventHandler<TouchEventArgs> 委派,定义所有处理程序:

void OnTouchEnter(
  object sender, TouchEventArgs args) {
  ...
}

TouchEventArgs 定义四个属性:

  • Source 属性,类型为 UIElement(最初传递给 TouchManager 构造函数的元素)。
  • Position 属性,类型为 Point。此位置是相对于 Source 的。
  • DirectlyOver 属性,类型为 UIElement(直接从 TouchDevice 对象复制)。
  • Id 属性,类型为 int(也直接从 TouchDevice 对象复制)。

只有在处理时,TouchDown 事件才是一个类,它允许使用与该事件关联的触摸点 ID 调用 Capture 方法:

touchManager.Capture(id);

该 ID 的所有其他触控输入都将传送到与 TouchManager 实例关联的元素,直到出现 TouchUp 事件或显式调用 ReleaseTouchCapture 为止。无论在哪种情况下,TouchManager 都会激发 LostTouchCapture 事件。

这些事件的顺序通常如下所示:TouchEnter、TouchDown、TouchMove、TouchUp、TouchLeave 和 LostTouchCapture(如果有)。当然,在 TouchDown 和 TouchUp 之间,可能有多个 TouchMove 事件。未捕获到触摸点时,因为触摸点离开一个注册的元素,又进入另一个元素,所以可能存在顺序为 TouchLeave、TouchEnter 和 TouchMove 的多个事件。

TouchDial 控件

通常,如果认为控件设计和其他输入机制的前提不再适当,就需要更改用户输入模式。例如,很少 GUI 控件能够像滚动条和滑块一样根深蒂固。不仅可以使用这些控件来导航大的文档或图像,还可以将其作为媒体播放器上的小型音量控件。

我考虑制作一个能够对触控进行响应的屏幕音量控件,所以,想知道旧方法是否确实是正确的。在现实生活中,有时会将滑块作为音量控件,但一般只限于专业的混音面板或图形均衡器。现实生活中的大多数音量控件都是调节盘。对于支持触控音量的控件,调节盘是更好的解决方案?

我不会假装自己有明确的答案,不过我会向您演示如何构建调节盘。

TouchDialDemos 解决方案的 Petzold.MultiTouch 库中包含 TouchDial 控件(有关详细信息,请参阅代码下载)。TouchDial 派生自 RangeBase,因此它可以利用 Minimum、Maximum 和 Value 属性(包括让 Value 保持在 Minimum 和 Maximum 范围内的强制逻辑),以及 ValueChanged 事件。但是在 TouchDial 中,Minimum、Maximum 和 Value 属性都是以度为单位的角度。

TouchDial 对鼠标和触摸都进行响应,并且使用 TouchManager 类来捕获触摸点。通过鼠标或触控输入,在发生 Move 事件期间,TouchDial 根据相对于中心点的鼠标或手指的新位置和旧位置更改 Value 属性。此操作非常类似于图 3,但不涉及缩放。Move 事件使用 Math.Atan2 方法将笛卡尔坐标转换为角度,然后将两个角度的差值添加到 Value 中。

TouchDial 不包含默认模板,因此没有默认的可视外观。使用 TouchDial 时,您需要提供一个模板,不过它可以像一些元素一样简单。显然,此模板上的某些内容可能应该按照 Value 属性中的更改进行旋转。为方便起见,TouchDial 提供了一个只读的 RotateTransform 属性,其中 Angle 属性等同于 RangeBase 的 Value 属性,CenterX 和 CenterY 属性反映控件的中心。

图 4 显示了一个具有两个 TouchDial 控件的 XAML 文件,这两个控件引用定义为资源的样式和模板。

图 4 SimpleTouchDialTemplate 项目的 XAML 文件

<UserControl x:Class="SimpleTouchDialTemplate.MainPage"
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation" 
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:multitouch="clr-namespace:Petzold.MultiTouch;assembly=Petzold.MultiTouch">
  <UserControl.Resources>
    <Style x:Key="touchDialStyle" 
      TargetType="multitouch:TouchDial">
      <Setter Property="Maximum" Value="180" />
      <Setter Property="Minimum" Value="-180" />
      <Setter Property="Width" Value="200" />
      <Setter Property="Height" Value="200" />
      <Setter Property="HorizontalAlignment" Value="Center" />
      <Setter Property="VerticalAlignment" Value="Center" />
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="multitouch:TouchDial">
            <Grid>
              <Ellipse Fill="{TemplateBinding Background}" />
              <Grid RenderTransform="{TemplateBinding RotateTransform}">
                <Rectangle Width="20" Margin="10"
                  Fill="{TemplateBinding Foreground}" />
              </Grid>
            </Grid>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
  </UserControl.Resources>
    
  <Grid x:Name="LayoutRoot">
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="*" />
      <ColumnDefinition Width="*" />
    </Grid.ColumnDefinitions>
        
    <multitouch:TouchDial Grid.Column="0"
      Background="Blue" Foreground="Pink"
      Style="{StaticResource touchDialStyle}" />
        
    <multitouch:TouchDial Grid.Column="1"
      Background="Red" Foreground="Aqua"
      Style="{StaticResource touchDialStyle}" />
  </Grid>
</UserControl>

请注意,样式将 Maximum 属性设置为 180,将 Minimum 设置为 -180,这样,调节条可以向左和向右旋转 180 度。(奇怪的是,当我调换了这两个属性在样式定义中的顺序时,程序并没有正常运行。)调节盘仅包含根据 Ellipse 内的 Rectangle 元素生成的一个调节条。此调节条在单个单元格构成的网格中,其 RenderTransform 已绑定到由 TouchDial 计算的 RotateTransform 属性。

图 5 显示了正在运行的 SimpleTouchDialTemplate 程序。


图 5 SimpleTouchDialTemplate 程序

您可以试试该程序(也可以试试我将在本文讨论的后面两个程序),网址为 charlespetzold.com/silverlight/TouchDialDemos

用鼠标在圆内旋转调节条有一点困难,用手指就自然得多。请注意,在圆内的任意位置按下鼠标左键(或将手指放在屏幕上)都可以旋转调节条。旋转调节条时,可以移开鼠标或手指,因为它们都会被捕获。

如果希望限制用户只有把鼠标或手指直接按在调节条上才能旋转调节条,可以将 Ellipse 的 IsHitTestVisible 属性设置为 False。

我第一版的 TouchDial 控件没有包括 RotateTransform 属性。这对我更有意义,因为这样模板可以包含显式 RotateTransform,其中 Angle 属性是 TemplateBinding 到控件 Value 属性的目标。但是在 Silverlight 3 中,只有派生自 FrameworkElement 的类属性才能进行绑定,因此 RotateTransform 的 Angle 属性不能是绑定目标(这已在 Silverlight 4 中修复)。

旋转总是以中心点为参考的,而这一点使得 TouchDial 控件变得复杂。TouchDial 通过两种方式使用中心点:计算图 3 中的角度,设置其创建的 RotateTransform 的 CenterX 和 CenterY 属性。默认情况下,TouchDial 将这两个中心值作为 ActualWidth 和 ActualHeight 属性的一半计算,从而得出控件中心,但在很多情况下,这并不完全是您所希望的。

例如,在图 4 的模板中,假设您要将 Rectangle 的 RenderTransform 属性绑定至 TouchDial 的 RotateTransform 属性。它将无法正常工作,因为 TouchDial 会将 RotateTransform 的 CenterX 和 CenterY 属性设置为 100,但 Rectangle 相对于自身的中心实际上是点 (10, 90)。为了让您覆盖 TouchDial 根据控件大小计算的这些默认值,该控件定义了 RenderCenterX 和 RenderCenterY 属性。在 SimpleTouchDialTemplate 属性中,您可以通过如下样式设置这些属性:

<Setter Property="RenderCenterX" Value="10" />
<Setter Property="RenderCenterY" Value="90" />

或者,您可以将这些属性设置为零,然后设置 Rectangle 元素的 RenderTransformOrigin 以表示相对于其自身的中心:

RenderTransformOrigin="0.5 0.5"

如果鼠标或手指移动参考的点不在控件中心内,您也可能希望使用 TouchDial。在这种情况下,您可以设置 InputCenterX 和 InputCenterY 属性来覆盖默认值。

图 6 显示了 OffCenterTouchDial 项目 XAML 文件。

图 6 OffCenterTouchDial XAML 文件

<UserControl x:Class="OffCenterTouchDial.MainPage"
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation" 
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:multitouch="clr-namespace:Petzold.MultiTouch;assembly=Petzold.MultiTouch">
  <Grid x:Name="LayoutRoot">
    <multitouch:TouchDial Width="300" Height="200" 
      HorizontalAlignment="Center" VerticalAlignment="Center"
      Minimum="-20" Maximum="20"
      InputCenterX="35" InputCenterY="100"
      RenderCenterX="15" RenderCenterY="15">
      <multitouch:TouchDial.Template>
        <ControlTemplate TargetType="multitouch:TouchDial">
          <Grid Background="Pink">
            <Rectangle Height="30" Width="260"
              RadiusX="15" RadiusY="15" Fill="Lime"
              RenderTransform="{TemplateBinding RotateTransform}" />
            <Ellipse Width="10" Height="10"
              Fill="Black" HorizontalAlignment="Left"
              Margin="30" />
          </Grid>
        </ControlTemplate>
      </multitouch:TouchDial.Template>
    </multitouch:TouchDial>
  </Grid>
</UserControl>

此文件包含一个 TouchDial 控件,其中在控件自身上设置了属性,并且 Template 属性设置为由单个单元格构成的网格(具有 Rectangle 和 Ellipse)的 Control 模板。Ellipse 是 Rectangle 的小符号支点,您可以上下旋转 20 度,如图 7 所示。


图 7 OffCenterTouchDial 程序

InputCenterX 和 InputCenterY 属性相对于整个控件,所以它们表示粉色网格中 Ellipse 元素的中心位置。RenderCenterX 和 RenderCenterY 属性总是相对于应用了 RotateTransform 属性的控件部分。

音量控件和调音管

前两个示例说明了如何为 TouchDial 提供一个可视外观,方法是在标记中显式设置 Template 属性,或引用定义为资源的 ControlTemplate(如果需要在多个控件之间共享模板)。

也可以从 TouchDial 派生一个新类,并单独使用 XAML 文件来设置一个模板。Petzold.MultiTouch 库中的 RidgedTouchDial 也是如此。RidgedTouchDial 与 TouchDial 相同,但它具有特定大小和可视外观(您很快就会看到)。

也可以在派生自 UserControl 的类中使用 TouchDial(或 RidgedTouchDial 之类的派生类)。此方法的优点是可以隐藏 RangeBase 定义的所有属性(包括 Minimum、Maximum 和 Value),并将它们替换为新属性。

VolumeControl 也是如此。VolumeControl 派生自其可视外观的 RidgedTouchDial,并定义一个名为 Volume 的新属性。Volume 属性由依赖属性提供支持,对该属性的任何更改都会激发 VolumeChanged 事件。

VolumeControl 的 XAML 文件仅引用 RidgedTouchDial 控件,并设置几个属性,包括 Minimum、Maximum 和 Value:

<src:RidgedTouchDial 
  Name="touchDial"
  Background="{Binding Background}"
  Maximum="150"
  Minimum="-150"
  Value="-150"
  ValueChanged="OnTouchDialValueChanged" />

因此,TouchDial 可以从最小的位置到最大的位置旋转 300 度。图 8 显示了 VolumeControl.xaml.cs。该控件将调节盘的 300 度范围转换为 0 到 96 的对数级分贝刻度。

图 8 VolumeControl 的 C# 文件

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

namespace Petzold.MultiTouch {
  public partial class VolumeControl : UserControl {
    public static readonly DependencyProperty VolumeProperty =
      DependencyProperty.Register("Volume",
      typeof(double),
      typeof(VolumeControl),
      new PropertyMetadata(0.0, OnVolumeChanged));

    public event DependencyPropertyChangedEventHandler VolumeChanged;

    public VolumeControl() {
      DataContext = this;
      InitializeComponent();
    }

    public double Volume {
      set { SetValue(VolumeProperty, value); }
      get { return (double)GetValue(VolumeProperty); }
    }

    void OnTouchDialValueChanged(object sender, 
      RoutedPropertyChangedEventArgs<double> args) {

      Volume = 96 * (args.NewValue + 150) / 300;
    }

    static void OnVolumeChanged(DependencyObject obj, 
      DependencyPropertyChangedEventArgs args) {

      (obj as VolumeControl).OnVolumeChanged(args);
    }

    protected virtual void OnVolumeChanged(
      DependencyPropertyChangedEventArgs args) {

      touchDial.Value = 300 * Volume / 96 - 150;

      if (VolumeChanged != null)
        VolumeChanged(this, args);
    }
  }
}

为什么是 96?尽管分贝刻度基于十进制数(只要按乘法因子 10 增加信号振幅,音量就会线性增加 20 分贝),这也是事实:10 的 3 次方约为 2 的 10 次方。这意味着,振幅加倍时,音量增加 6 分贝。因此,如果以一个 16 位值表示振幅(CD 和 PC 的声音也是如此),将得到一个 16 位乘以 6 分贝每位的范围,即 96 分贝。

PitchPipeControl 类也派生自 UserControl,它定义了一个名为 Frequency 的新属性。该 XAML 文件包含一个 TouchDial 控件和一组 TextBlocks 来显示八度音阶的 12 个乐音。PitchPipeControl 还使用了我还没有讨论的 TouchDial 的另一个属性:如果将 SnapIncrement 设置为非零角度值,调节盘的移动将不平稳,而会增量跳跃。因为可以为八度音阶的 12 个乐音设置 PitchPipeControl,所以 SnapIncrement 设置为 30 度。

图 9 显示了将 VolumeControl 和 PitchPipeControl 结合起来的 PitchPipe 程序。您可以运行 PitchPipe,网址为 charlespetzold.com/silverlight/TouchDialDemos

图 9 PitchPipe 程序

附加程序

在本文前面,我在示例上下文中提到一个名为 PianoKey 的控件。PianoKey 是一个真实的控件,是您可以在以下网址运行的 Piano 程序中的多个控件之一:charlespetzold.com/silverlight/Piano。该程序将在您的浏览器中最大化显示。(也可以按 F11 使 Internet Explorer 进入全屏模式以获得更大的空间。)图 10 显示了一个小图。该键盘分为重叠的高音和低音部分。红点表示中央 C。

图 10 Piano 程序

针对此程序,我编写了 TouchManager,因为 Piano 程序将以三种不同的方式使用触控。已经讨论过的蓝色 VolumeControl 在 TouchDown 事件上捕获触摸点,而在 TouchUp 事件上释放捕获。组成键盘的 PianoKey 控件也使用 TouchManager,但这些控件仅侦听 TouchEnter 和 TouchLeave 事件。您确实可以用手指在按键上弹奏,达到滑奏效果。作为踏板的棕色矩形是普通的 Silverlight ToggleButton 控件。这些都不是专门支持触控功能,相反触摸点可以转换为鼠标事件。

Piano 程序演示了三种不同的方式来使用多点触控。我想还可以采用更多方式。

 

Charles Petzold 是《MSDN 杂志》的长期特约编辑。他的最新著作是“The Annotated Turing:A Guided Tour Through Alan Turing’s Historic Paper on Computability and the Turing Machine”(Wiley,2008)。Petzold 的博客网站是 charlespetzold.com

衷心感谢以下技术专家对本文的审阅:Robert Levy 和 Anson Tsao