在 CurrencyX 1.2 中新增了列表右滑中任一项可以查看历史汇率变化趋势图功能,有用户提出建议在趋势图界面可以左滑返回,于是我们开始了这项小优化——却发现实现起来并不那么简单。在此与大家分享开发过程中的一些 Tips。
在 Safari 中,两指在 Trackpad 上左、右滑动可以操控页面前进、后退。滑动时,当前页面会随着手势水平偏移,手指离开 Trackpad 时根据偏移距离决定是否执行相应的操作。这样的操控方式在 OS X Lion(10.7) 之后才出现,这样的 Fluid Swipe 可以通过 NSPageController 来简单的实现。具体方法可以参考 NSPageController Class Reference 以及 PictureSwiper Sample Code 。
然而我们并不想为了支持 Fluid Swipe 重构现在的视图结构,只是想简单的在趋势图界面 Swipe 时能够返回列表界面,滑动时 View 是否随之滚动并不重要。
接下来将介绍具体如何实现两指滑动手势处理方法的。
当用户手指在 Trackpad 上移动或点按时,系统会生成 Multi-touch Events,Gesture Events 或 Mouse Events。Trackpad 内置支持将某些手势等价为鼠标操作(具体在 SystemPreference - Trackpad 中设置);对于某些手势 Event, NSWindow 将直接调用 NSResponder 中相对应的方法:
magnifyWithEvent:
; swipeWithEvent:
; scrollWheel:
)。 当光标悬浮在某 View 上操控 Trackpad 时,View 将接收到 Event,View 将处理 Touch 事件或者沿着 Responder Chain 向上传递直到事件被处理或者 Discarded(更多有关 Responder Chain 可以参见这篇文章)。
理论上我们要做的事情是 Swipe Back,因此考虑先尝试在 swipeWithEvent:
中处理。
首先我们需要知道,大多数情况下, Trackpad 的设置并不支持 swipeWithEvent:
事件。为了使得系统支持,我们需要在 System Preferences - Trackpad - More Gestures 中进行如下设置:
这样才能确保 NSWindow 接收 Three Finger Swipe 事件后向相应的 View(即 First Responder)发送 swipeWithEvent:
消息。
在 swipeWithEvent:
中根据 NSEvent 的 deltaX
和 deltaY
属性即可获取水平、垂直方向的偏移。
代码片段如下(CustomView):
override func swipeWithEvent(event: NSEvent) { // Handler here. let x = event.deltaX let y = event.deltaY // Do sth... }
由于很少有用户会对 Trackpad 的手势进行类似的设置,所以我们仅用这种方法作为辅助。
从 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:
phase
属性将一直是 .None,但是 momentumPhase
将 .Began/.Changed/.Ended 依次变化; phase
和 momentumPhase
属性都是 .None
,没有办法可以确定用户操作的状态。 因此,可以通过设置 View 的 wantsScrollEventsForSwipeTrackingOnAxis
属性并重写 scrollWheel:
方法将两指滑动事件作为 Swipe 进行处理。
在 scrollWheel:
通过 NSEvent 的 scrollingDeltaX
和 scrollingDeltaY
可以获取水平、垂直的偏移量。
代码片段如下(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 } } }
此外,可以直接通过 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,实现了:
scrollWheel:
处理两指滑动事件; swipeWithEvent:
实现三指滑动事件。
完整代码: SeedLabIO/SwipeGestureExample · GitHub
在利用 Trackpad 中的 Gesture 或 Touch 实现交互操作时,应该将它们视作与快捷键相同的辅助方式而不是唯一方式。要考虑到,许多用户并没有 Trackpad。所有 Touch Event 提供的 Feature 应该只作为菜单功能的快捷操作而已。
Happy Coding :smile:.
SalesX 是给 Apple 开发者使用的菜单栏工具,第一时间把 app 销售情况推送给你,7 天免费试用
CurrencyX 是 Mac 上小而美的汇率 app
如果你觉得文章对你有帮助,可以买一个支持我们
关注我们公众号,获取最新文章推送