转载

[iOS] Cocoa:为 NSView 添加手势返回

在 CurrencyX 1.2 中新增了列表右滑中任一项可以查看历史汇率变化趋势图功能,有用户提出建议在趋势图界面可以左滑返回,于是我们开始了这项小优化——却发现实现起来并不那么简单。在此与大家分享开发过程中的一些 Tips。

[iOS] Cocoa:为 NSView 添加手势返回

需求分析

在 Safari 中,两指在 Trackpad 上左、右滑动可以操控页面前进、后退。滑动时,当前页面会随着手势水平偏移,手指离开 Trackpad 时根据偏移距离决定是否执行相应的操作。这样的操控方式在 OS X Lion(10.7) 之后才出现,这样的 Fluid Swipe 可以通过 NSPageController 来简单的实现。具体方法可以参考 NSPageController Class Reference 以及 PictureSwiper Sample Code 。

然而我们并不想为了支持 Fluid Swipe 重构现在的视图结构,只是想简单的在趋势图界面 Swipe 时能够返回列表界面,滑动时 View 是否随之滚动并不重要。

接下来将介绍具体如何实现两指滑动手势处理方法的。

Trackpad Events

当用户手指在 Trackpad 上移动或点按时,系统会生成 Multi-touch Events,Gesture Events 或 Mouse Events。Trackpad 内置支持将某些手势等价为鼠标操作(具体在 SystemPreference - Trackpad 中设置);对于某些手势 Event, NSWindow 将直接调用 NSResponder 中相对应的方法:

  • Pinch:两指捏近或松开,对应缩小或者放大,将调用 magnifyWithEvent:
  • Rotate:两指沿着相对半圆移动,对应旋转,调用 'rotateWithEvent:';
  • Swipe:三指沿着同一方向扫过(Brushing across the trackpad in a common direction),对应 Swipe,调用 swipeWithEvent:
  • Scroll:两指沿着同一水平或垂直方向移动,对应 Scroll,调用相应鼠标事件(例如: scrollWheel: )。

当光标悬浮在某 View 上操控 Trackpad 时,View 将接收到 Event,View 将处理 Touch 事件或者沿着 Responder Chain 向上传递直到事件被处理或者 Discarded(更多有关 Responder Chain 可以参见这篇文章)。

Swipe Gesture

理论上我们要做的事情是 Swipe Back,因此考虑先尝试在 swipeWithEvent: 中处理。

首先我们需要知道,大多数情况下, Trackpad 的设置并不支持 swipeWithEvent: 事件。为了使得系统支持,我们需要在 System Preferences - Trackpad - More Gestures 中进行如下设置:

  • 在 Swipe between pages 中选择:Swipe with two or three fingers 或 Swipe with three fingers;
  • 在 Swipe between full-screen apps 中选择:Swipe left or right with four fingers。

这样才能确保 NSWindow 接收 Three Finger Swipe 事件后向相应的 View(即 First Responder)发送 swipeWithEvent: 消息。

swipeWithEvent: 中根据 NSEvent 的 deltaXdeltaY 属性即可获取水平、垂直方向的偏移。

代码片段如下(CustomView):

override func swipeWithEvent(event: NSEvent) {       // Handler here.         let x = event.deltaX     let y = event.deltaY     // Do sth... }

由于很少有用户会对 Trackpad 的手势进行类似的设置,所以我们仅用这种方法作为辅助。

Scroll Gesture

从 OS X Lion(10.7) 开始,提供了可以实现 Fluid Swipe Tracking 的 API,Scroll Wheel 的 NSEvent 有 phase 属性:

public var phase: NSEventPhase { get }   public struct NSEventPhase : OptionSetType {       public init(rawValue: UInt)      public static var None: NSEventPhase { get } // event not associated with a phase.     public static var Began: NSEventPhase { get }     public static var Stationary: NSEventPhase { get }     public static var Changed: NSEventPhase { get }     public static var Ended: NSEventPhase { get }     public static var Cancelled: NSEventPhase { get }     public static var MayBegin: NSEventPhase { get } }

其变化对应三种不同的 Scroll:

  • Gesture Scrolls,由 .Began 开始,中间是一系列的 .Changed,以 .Ended 结束;
  • Momentum Scrolls, phase 属性将一直是 .None,但是 momentumPhase 将 .Began/.Changed/.Ended 依次变化;
  • Legacy Scrolls, phasemomentumPhase 属性都是 .None ,没有办法可以确定用户操作的状态。

因此,可以通过设置 View 的 wantsScrollEventsForSwipeTrackingOnAxis 属性并重写 scrollWheel: 方法将两指滑动事件作为 Swipe 进行处理。

scrollWheel: 通过 NSEvent 的 scrollingDeltaXscrollingDeltaY 可以获取水平、垂直的偏移量。

代码片段如下(CustomView):

override func wantsScrollEventsForSwipeTrackingOnAxis(axis: NSEventGestureAxis) -> Bool {       return axis == .Horizontal }  override func scrollWheel(theEvent: NSEvent) {       // Not a gesture scroll event.     if theEvent.phase == .None { return }     // Not horizontal     if abs(theEvent.scrollingDeltaX) <= abs(theEvent.scrollingDeltaY) { return }      var animationCancelled = false     theEvent.trackSwipeEventWithOptions(         .LockDirection,         dampenAmountThresholdMin: 0,         max: 0) { (gestureAmount, phase, complete, stop) in         if animationCancelled {             stop.initialize(true)         }         if (phase == .Began) {              // User Touch Begans.          } else if (phase == .Ended) {             // User Touch Ended.         } else if (phase == .Cancelled) {             // User Touch Cancelled.             animationCancelled = true         }     } }

Multi-Touch Events

此外,可以直接通过 NSTouch 来处理。首先设置 View 的 acceptsTouchEvents 属性为 true ,然后便可通过 NSResponder 为 Touch Event Handling 提供的方法进行处理:

- (void)touchesBeganWithEvent:(NSEvent *)event; - (void)touchesMovedWithEvent:(NSEvent *)event; - (void)touchesEndedWithEvent:(NSEvent *)event; - (void)touchesCancelledWithEvent:(NSEvent *)event;

对于直接继承 NSView 的 View 而言,需要实现上述所有方法来支持 Touch Event Handling;如果父类已经实现了上述方法,只需要在重写的时候调用父类相应方法即可。

App 将根据 Trackpad 的每一个 Touch 进入不同的 Phase 来调用相应的方法;因此在同一时间,可能好几个方法会被同时调用,通过:

let touches = event.touchesMatchingPhase(.Touching, inView: self)

方法可以得知在方法调用时,当前 View 上处于某特殊 Phase 的 Touch Set。我们可以用如下方法判断两指滑动事件的开始,并记录相关信息:

override func touchesBeganWithEvent(event: NSEvent) {       let touches = event.touchesMatchingPhase(.Began, inView: self)     if touches.count == 2 {         let array = Array(touches)         initialTouches[0] = array[0]         initialTouches[1] = array[1]         currentTouches[0] = initialTouches[0]         currentTouches[1] = initialTouches[1]     } else if touches.count == 2 {         // More than 2 touches. Only track 2.         if isTracking {             cancelTracking()         }     } }

每一个 NSTouch 都有唯一的 identity 来标识,因此当两指开始移动时,可以用如下方法更新当前偏移:

override func touchesMovedWithEvent(event: NSEvent) {       let touches = event.touchesMatchingPhase(.Touching, inView: self)     if let fingerAInitial = initialTouches[0],         let fingerBInitial = initialTouches[1]         where touches.count == 2 {          touches.forEach { touch in             if touch.identity.isEqual(fingerAInitial.identity) {                 currentTouches[0] = touch             } else if touch.identity.isEqual(fingerBInitial.identity) {                 currentTouches[1] = touch             }         }          if !isTracking {             isTracking = true         }     } }

通过 initialTouch 和 currentTouch 中 NSTouch 的 normalizedPosition 可以计算出偏移量,在 End 时作出相应处理:

override func touchesEndedWithEvent(event: NSEvent) {       if isTracking {         if (abs(delta.x) > threshold || abs(delta.y) > threshold) {             // Do sth...         }         cancelTracking()     } }

对于 Cancel 的情况也需要作出相应处理:

override func touchesCancelledWithEvent(event: NSEvent) {       // Cancelled.     if isTracking {         cancelTracking()     } }

Demo

一个简单的 Demo,实现了:

  • 利用 Touch Event 处理两指滑动事件;
  • 利用 scrollWheel: 处理两指滑动事件;
  • 利用 swipeWithEvent: 实现三指滑动事件。

[iOS] Cocoa:为 NSView 添加手势返回

完整代码: SeedLabIO/SwipeGestureExample · GitHub

其它

在利用 Trackpad 中的 Gesture 或 Touch 实现交互操作时,应该将它们视作与快捷键相同的辅助方式而不是唯一方式。要考虑到,许多用户并没有 Trackpad。所有 Touch Event 提供的 Feature 应该只作为菜单功能的快捷操作而已。

Happy Coding :smile:.

支持我们

SalesX 是给 Apple 开发者使用的菜单栏工具,第一时间把 app 销售情况推送给你,7 天免费试用

CurrencyX 是 Mac 上小而美的汇率 app

如果你觉得文章对你有帮助,可以买一个支持我们

关注我们公众号,获取最新文章推送 [iOS] Cocoa:为 NSView 添加手势返回

原文  http://blog.seedlab.io/two-finger-swipe-back/
正文到此结束
Loading...