Windows Phone 上的触控手势
http://msdn.microsoft.com/zh-cn/magazine/gg650664.aspx
有人在工作中花费大量的时间来观察 API 的发展变化,而我却一直醉心于多点触控在广袤的 API 领域所占据的一隅之地。我不确定我是否需要统计分散在 Windows Presentation Foundation (WPF)、Microsoft Surface、Silverlight、XNA 和 Windows Phone 中的不同多点触控 API 的数量,但是很明显,多点触控的“统一理论”仍然难以实现。
当然,对于一种相对仍很年轻的技术来说,存在这么多的触控 API 也不足为奇。而且,多点触控比鼠标要复杂得多。这部分是因为多个手指的潜在交互,但它也反映了纯人工设备(如鼠标)与人类的手指之间的差异。我们人类一生都在使用我们的手指,即使我们触摸视频显示器的光亮的表面,我们也希望它们通过众所周知的方式与世界交互。
Windows Phone 7 为应用程序编程人员定义了 4 种不同的触控界面。
为 Windows Phone 7 编写的 Silverlight 应用程序可以选择通过静态 Touch.FrameReported 事件获取低级触控输入,或者通过各种 Manipulation 路由事件获取高级输入。这些 Manipulation 事件主要是 WPF 中相似事件的一个子集,但是它们之间的差异足以令人头痛。
Windows Phone 7 的 XNA 应用程序使用静态的 TouchPanel 类获取触控输入,但是该类实际上整合了两个触控界面:GetState 方法获取低级手指活动,而 ReadGesture 方法获取高级手势。ReadGesture 方法支持的手势不是笔针样式的手势,如对号和圆。它们是非常简单的手势,由 Tap、Drag 和 Pinch 等名称描述。为了与 XNA 体系结构保持一致,触控输入由应用程序轮询,而不是通过事件传送。
Silverlight 中添加了手势功能
我自然地假设 Silverlight for Windows Phone 7 已经具有足够多的多点触控 API,因此看到组合中又添加了一个 API,我感到非常惊讶 — 虽然包含这个 API 的工具包上市得太晚,以致我未能在编写《Programming Windows Phone 7》(Microsoft Press, 2010) 一书时介绍它。
您可能知道,在过去的几年里发布的 WPF 和 Silverlight 的各种版本已由通过 CodePlex 发布的工具包做出了补充。这些工具包使得 Microsoft 能够为“常规发送圈”之外的开发人员提供新的类,并且经常给我们提供抢先了解未来版本可能会纳入的框架增强功能的机会。完整的源代码是它的另一个优点。
Windows Phone 7 现在也从此自定义功能中受益。Silverlight for Windows Phone 工具包(可从 silverlight.codeplex.com 中下载)包含 Windows Phone 7 用户已经熟悉的 DatePicker、TimePicker 和 ToggleSwitch 控件,以及 WrapPanel(便于处理手机方向更改);此外还支持多点触控手势。
在工具包中新增此 Silverlight 手势支持旨在提供与 XNA TouchPanel.ReadGesture 方法相似的功能,但它是通过路由事件而不是轮询实现的。
它有多相似?比我期望的要高很多!看一下源代码,我非常吃惊地发现这些新的 Silverlight 手势事件完全是由对 XNA TouchPanel.ReadGesture 方法的调用派生而来的。我也不会想到 Windows Phone 上的 Silverlight 应用程序会被允许调用此 XNA 方法,但事实就是这样。
尽管 Silverlight 和 XNA 手势非常相似,但是与手势相关的属性却不相似。例如,XNA 属性使用的是矢量,而 Silverlight 不包含 Vector 结构(我觉得这是一个荒谬的遗漏),所以必须按照某些简单的方式为 Silverlight 重新定义属性。
由于我一直在使用这些手势事件,因此它们已经成为我最喜欢的用于 Silverlight for Windows Phone 的多点触控 API。我发现它们对于我需要做的来说非常全面,而且使用起来也非常简单。
让我给这些手势分配一些实际工作来演示一下。
手势服务和侦听器
本专栏的所有源代码都包含在名为 GestureDemos 的可下载 Visual Studio 解决方案中,此解决方案包含三个项目。您需要安装 Windows Phone 7 开发工具,当然,还有 Silverlight for Windows Phone 工具包。
安装工具包后,您可以通过添加对 Microsoft.Phone.Controls.Toolkit 程序集的引用来在您自己的 Windows Phone 项目中使用它。在“添加引用”对话框中,它将列在“.NET”选项卡的下面。
然后,在 XAML 文件中,您需要如下所示的 XML 命名空间声明(但所有内容位于一行):
xmlns:toolkit=
"clr-namespace:Microsoft.Phone.Controls;
assembly=Microsoft.Phone.Controls.Toolkit"
以下是 12 个可用的手势事件,我将按大致顺序对其进行讨论(我将其分组到同一行的事件是相关的,而且发生顺序相同):
GestureBegin, GestureCompleted
Tap
DoubleTap
Hold
DragStarted, DragDelta, DragCompleted
Flick
PinchStarted, PinchDelta, PinchCompleted
假设您要处理发生在 Grid 或任何子 Grid 的 Tap 和 Hold 事件。您可以在 XAML 文件中进行指定,指定方法如下所示:
<Grid ...
>
<toolkit:GestureService.GestureListener>
<toolkit:GestureListener
Tap="OnGestureListenerTap"
Hold="OnGestureListenerHold" />
</toolkit:GestureService.GestureListener>
...
</Grid>
您在 GestureListener 标记中指定事件和处理程序,此标记是 GestureService 类的 GestureListener 附加属性的子项。
此外,在代码中,您将需要用于 Microsoft.Phone.Controls 命名空间的命名空间指令和以下代码:
GestureListener gestureListener =
GestureService.GetGestureListener(element);
gestureListener.Tap += OnGestureListenerTap;
gestureListener.Hold += OnGestureListenerHold;
不管是哪种情况,如果要在面板上设置此手势侦听器,请确保至少将 Background 属性设为 Transparent!事件将通过一个默认背景为空的面板来实现。
Tap 和 Hold
所有手势事件都附带类型为 GestureEventArgs 或从 GestureEventArgs 派生的类型的事件参数。OriginalSource 属性指示碰到屏幕的第一根手指触摸到的最顶层的元素;GetPosition 方法提供这根手指相对于任何元素的当前坐标。
可对手势事件进行路由,这意味着它们可以在可视树中传递,并且可以针对安装了 GestureListener 的任何元素进行处理。与往常一样,事件处理程序可以将 GestureEventArgs 的 Handled 属性设置为 true,以防止事件在可视树中进一步传递。但是,这只能影响使用这些手势事件的其他元素。将 Handled 设置为 true 并不能防止可视树中较高级别的元素通过其他界面获取触控输入。
GestureBegin 事件指示有手指触摸了以前手指未触摸到的屏幕;当所有手指都离开屏幕后,GestureCompleted 会发出信号。这些事件可以为初始化或清理提供便利,但是您通常会更关注在这两个事件之间发生的手势事件。
我不会在较简单的手势上花费太多的时间。当有手指触摸屏幕,然后在大约 1.1 秒内抬起,并且手指从原始位置并未移动很远时,会发生 Tap。如果相继两次点击的时间间隔太短,那么第二次点击会被当作 DoubleTap 接收。当有手指在屏幕上按下并在大致相同的位置停留大约 1.1 秒时会发生 Hold。Hold 事件在这个时间结束时生成,不需要等到手指离开屏幕。
Drag 和 Flick
当有手指触摸屏幕、在屏幕上移动或离开屏幕时会发生 Drag 序列(包含一个 DragStarted 事件、零个或更多 DragDelta 事件以及一个 DragCompleted 事件)。由于并不知道手指第一次触摸屏幕时会发生拖动操作,所以 DragStarted 事件会延迟到手指实际开始在 Tap 阈值外的范围移动时。如果将手指放在屏幕上,并在大约一秒内没有移动,则在 DragStarted 事件之前可能会先执行 Hold 事件。
由于当引发 DragStarted 事件时手指已经开始移动,因此 DragStartedEventArgs 对象可以包含一个类型为 Orientation(水平或垂直)的 Direction 属性。DragDelta 事件附带的 DragDeltaEventArgs 对象包含更多信息:HorizontalChange 属性和 VerticalChange 属性(可以方便地添加到 TranslateTransform 的 X 属性和 Y 属性),或者 Canvas.Left 和 Canvas.Top 附加属性。
当手指离开屏幕时如果手指仍在移动,表明用户想执行延时,此时会发生 Flick 事件。事件参数包含 Angle 值(从正 X 轴顺时针测量),以及 HorizontalVelocity 和 VerticalVelocity 值,它们都以像素/秒为单位。
Flick 事件可以独立发生,可以在 DragStarted 和 DragCompleted 事件之间发生(无需任何 DragDelta 事件),也可以在一系列 DragDelta 事件之后 DragCompleted 事件之前发生。通常情况下,您需要将 Drag 事件与 Flick 事件一起处理,几乎就像 Flick 是 Drag 的延续一样。但是,您需要添加您自己的延时逻辑。
DragAndFlick 项目中演示了这些操作过程。屏幕包含一个可以让用户用手指轻松地来回拖动的椭圆。如果使手指轻弹一下屏幕便离开屏幕,则会发生 Flick 事件,而且 Flick 处理程序会保存一些信息,并为 CompositionTarget.Rendering 事件安装一个处理程序。此事件(与视频显示刷新同步发生)使椭圆继续移动,但同时会降低移动速度。
处理弹离边缘的操作有点特别:程序会保持一个位置,就好像椭圆一直在朝一个方向不断移动直至其停止;该位置包含在椭圆的弹跳区域内。
掐我一下,我肯定是在做梦
当有两根手指触摸屏幕时会发生 Pinch 序列;它通常被解释为扩展或收缩屏幕上的对象,也可能是对其进行旋转。
毫无疑问,捏指操作是最令人捉摸不定的多点触控处理领域之一,而且更高级别的界面无法提供充足的信息也不罕见。众所周知,Windows Phone 7 ManipulationDelta 事件使用起来特别复杂。
处理手势时,Drag 序列和 Pinch 序列是相互排斥的。它们不会重叠,但是它们可能会一个接一个地发生。例如,用一根手指触按屏幕,并拖动屏幕。这会生成一个 DragStarted 事件和多个 DragDelta 事件。现在用另一根手指触按屏幕。您将获得一个 DragCompleted 以结束 Drag 序列,该序列之后将发生一个 PinchStarted 事件和多个 PinchDelta 事件。现在保持第一根手指继续移动,但抬起第二根手指。这是一个用于结束 Pinch 序列的 PinchCompleted,接下来将发生 DragStarted 和 DragDelta。根据触摸屏幕的手指的数量,您基本上是在 Drag 序列和 Pinch 序列之间交替。
此 Pinch 手势的一个有用特征是,它不会丢弃信息。您可以使用事件参数的属性完全重建两根手指的位置,这样,在需要时,您始终可以回到基本原理。
在 Pinch 序列中,某根手指(让我们称其为主要手指)的当前位置在 GetPosition 方法中始终可用。在本次讨论中,我们称此返回值为 pt1。对于 PinchStarted 事件,PinchStartedGestureEventArgs 类具有两个附加属性,分别名为 Distance 和 Angle,用于指示第二根手指相对于第一根手指的位置。使用以下语句,您可以轻松地计算出此实际位置:
- Point pt2 = new Point(pt1.X + args.Distance * Cos(args.Angle),
- pt1.Y + args.Distance * Sin(args.Angle));
-
Angle 属性以度为单位,因此在调用 Math.Cos 和 Math.Sin 之前,您需要使用 Cos 和 Sin 方法将其转换为弧度。在 PinchStarted 处理程序结束前,您还希望将 Distance 和 Angle 属性保存到名字可能是 pinchStartDistance 和 pinchStartAngle 的字段中。
PinchDelta 事件附带一个 PinchGestureEventArgs 对象。同样,通过 GetPosition 方法您可以获得主要手指的位置,主要手指可能已经从其原来位置移动到其他位置。对于第二根手指,事件参数提供 DistanceRatio 和 TotalAngleDelta 属性。
DistanceRatio 是手指之间的当前距离与原始距离的比率,也就是说,您可以按如下方式计算当前距离:
- double distance = args.DistanceRatio * pinchStartDistance;
-
TotalAngleDelta 是手指之间的当前角度与原始角度之间的差异。您可以按如下方式计算当前角度:
double angle = args.TotalAngleDelta + pinchStartAngle;
现在您可以按照以前的方式计算第二根手指的位置:
- Point pt2 = new Point(pt1.X + distance * Cos(angle),
- pt1.Y + distance * Sin(angle));
-
在 PinchDelta 处理期间,您不需要将任何附加信息保存到字段中来进一步处理 PinchDelta 事件。
TwoFingerTracking 项目通过显示在屏幕上跟踪一根或两根手指的蓝色和绿色椭圆来演示此逻辑。
Scale 和 Rotate
PinchDelta 事件还为针对对象执行缩放和旋转操作提供了大量信息。我不得不提供我自己的矩阵乘法方法,但是这种方法有点儿麻烦。
为了进行演示,我将让 ScaleAndRotate 项目实现现在已经用作“传统”类型的演示,这种类型的演示允许您拖动、缩放甚至还可以旋转图片。为了执行这些转换,我使用有双重用途的 RenderTransform 定义了 Image 元素,如图 1 所示。
图 1 ScaleAndRotate 中的 Image 元素
<Image Name="image"
Source="PetzoldTattoo.jpg"
Stretch="None"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<Image.RenderTransform>
<TransformGroup>
<MatrixTransform x:Name="previousTransform" />
<TransformGroup x:Name="currentTransform">
<ScaleTransform x:Name="scaleTransform" />
<RotateTransform x:Name="rotateTransform" />
<TranslateTransform x:Name="translateTransform" />
</TransformGroup>
</TransformGroup>
</Image.RenderTransform>
</Image>
当正在进行 Drag 或 Pinch 操作时,会在嵌套 TransformGroup 中执行三种转换,以在屏幕上来回移动图片、缩放图片和旋转图片。发生 DragCompleted 或 PinchCompleted 事件后,会将 MatrixTransform 中名为 previousTransform 的 Matrix 与作为 TransformGroup 的 Value 属性提供的复合转换相乘。然后,此 TransformGroup 中的三种转换会被重新设置为它们的默认值。
缩放和旋转始终是相对于一个中心点进行的,此点在执行转换时一直保持在同一位置。相对于图片左上角对图片进行缩放或旋转与相对于图片右下角对其进行缩放或旋转 — 两种操作最终使图片移动到的位置是不同的。
ScaleAndRotate 代码如图 2 所示。我将主要手指用作缩放和旋转的中心;处理 PinchStarted 时为转换设置这些中心点,而且这些中心点在 Pinch 序列的整个持续期间不会发生改变。在 PinchDelta 事件期间,DistanceRatio 和 TotalAngleDelta 属性提供相对于此中心的缩放和旋转信息。然后主要手指移动时的任何变化(必须通过保存的字段检测到)都会成为全局转换因子。
图 2 ScaleAndRotate 代码
- public partial class MainPage : PhoneApplicationPage
- {
- bool isDragging;
- bool isPinching;
- Point ptPinchPositionStart;
-
- public MainPage()
- {
- InitializeComponent();
- }
-
- void OnGestureListenerDragStarted(object sender, DragStartedGestureEventArgs args)
- {
- isDragging = args.OriginalSource == image;
- }
-
- void OnGestureListenerDragDelta(object sender, DragDeltaGestureEventArgs args)
- {
- if (isDragging)
- {
- translateTransform.X += args.HorizontalChange;
- translateTransform.Y += args.VerticalChange;
- }
- }
-
- void OnGestureListenerDragCompleted(object sender,
- DragCompletedGestureEventArgs args)
- {
- if (isDragging)
- {
- TransferTransforms();
- isDragging = false;
- }
- }
-
- void OnGestureListenerPinchStarted(object sender,
- PinchStartedGestureEventArgs args)
- {
- isPinching = args.OriginalSource == image;
-
- if (isPinching)
- {
- // Set transform centers
- Point ptPinchCenter = args.GetPosition(image);
- ptPinchCenter = previousTransform.Transform(ptPinchCenter);
-
- scaleTransform.CenterX = ptPinchCenter.X;
- scaleTransform.CenterY = ptPinchCenter.Y;
-
- rotateTransform.CenterX = ptPinchCenter.X;
- rotateTransform.CenterY = ptPinchCenter.Y;
-
- ptPinchPositionStart = args.GetPosition(this);
- }
- }
- void OnGestureListenerPinchDelta(object sender, PinchGestureEventArgs args)
- {
- if (isPinching)
- {
- // Set scaling
- scaleTransform.ScaleX = args.DistanceRatio;
- scaleTransform.ScaleY = args.DistanceRatio;
-
- // Optionally set rotation
- if (allowRotateCheckBox.IsChecked.Value)
- rotateTransform.Angle = args.TotalAngleDelta;
-
- // Set translation
- Point ptPinchPosition = args.GetPosition(this);
- translateTransform.X = ptPinchPosition.X - ptPinchPositionStart.X;
- translateTransform.Y = ptPinchPosition.Y - ptPinchPositionStart.Y;
- }
- }
-
- void OnGestureListenerPinchCompleted(object sender, PinchGestureEventArgs args)
- {
- if (isPinching)
- {
- TransferTransforms();
- isPinching = false;
- }
- }
-
- void TransferTransforms()
- {
- previousTransform.Matrix = Multiply(previousTransform.Matrix,
- currentTransform.Value);
-
- // Set current transforms to default values
- scaleTransform.ScaleX = scaleTransform.ScaleY = 1;
- scaleTransform.CenterX = scaleTransform.CenterY = 0;
-
- rotateTransform.Angle = 0;
- rotateTransform.CenterX = rotateTransform.CenterY = 0;
-
- translateTransform.X = translateTransform.Y = 0;
- }
-
- Matrix Multiply(Matrix A, Matrix B)
- {
- return new Matrix(A.M11 * B.M11 + A.M12 * B.M21,
- A.M11 * B.M12 + A.M12 * B.M22,
- A.M21 * B.M11 + A.M22 * B.M21,
- A.M21 * B.M12 + A.M22 * B.M22,
- A.OffsetX * B.M11 + A.OffsetY * B.M21 + B.OffsetX,
- A.OffsetX * B.M12 + A.OffsetY * B.M22 + B.OffsetY);
- }
- }
-
这的确是我写过的最简单的关于捏指操作的代码,但这也许是我可以为此新手势界面提供的最有意义的支持。
也许,多点触控的统一理论不久就要问世了。