Cocoa:为 NSView 添加手势返回

阅读 985
收藏 9
2016-07-24
原文链接:blog.seedlab.io

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

CurrencyX Trending

需求分析

在 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 的手势进行类似的设置,所以我们仅用这种方法作为辅助。

从 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="" stop.initialize(true)="" (phase="=" .began)="" user="" touch="" begans.="" else="" .ended)="" ended.="" .cancelled)="" cancelled.="" <="" code="">

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: 实现三指滑动事件。

Demo Screenshot

完整代码:SeedLabIO/SwipeGestureExample · GitHub

其它

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

Happy Coding 😄.

支持我们

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

CurrencyX 是 Mac 上小而美的汇率 app

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

关注我们公众号,获取最新文章推送

评论