WWDC Quick Look 💓 By SwiftGGTeam
Handle trackpad and mouse input

Handle trackpad and mouse input

观看原视频

Highlight

UIKit 可以用 UIHoverGestureRecognizer、prefersPointerLocked、allowedScrollTypesMask、buttonMask、modifierFlags 和 UIApplicationSupportsIndirectInputEvents 精确处理 iPad 与 Mac Catalyst App 中的触控板、鼠标滚轮、额外按键和键盘修饰键输入。


核心内容

iPadOS 13.4 让鼠标和触控板进入了普通 iPad App 的日常操作。用户把指针移到视频播放器边缘时,期待控制条自动出现;用两指滑动抽屉时,期待内容跟着移动;右键点击时,期待打开更直接的上下文操作。这些行为不能只靠触摸路径兜底,UIKit 为间接输入提供了单独的事件和手势入口。

这场 session 的主线很清楚:先让每个 App 处理常见输入,再给重度用户增加高级控制。常见输入包括 pointer movement、pointer lock、scroll input、pinch 和 rotate。高级控制包括鼠标按钮、键盘修饰键、手势接收过滤,以及从兼容模式切换到新的 indirect input 事件模型。

关键差别在于事件来源。手指触摸仍然走传统 UITouch 路径;触控板滚动会驱动 EventType.scroll;精确的捏合和旋转会走 EventType.transform;鼠标按钮和键盘修饰键会挂在 UIEventUIGestureRecognizer 上。App 需要在正确的层级读取这些信息,避免把所有交互都压回 touchesBegan 一条路径。


详细内容

1. 用 UIHoverGestureRecognizer 响应指针移动

01:49UIHoverGestureRecognizer 在 Mac Catalyst 的 Catalina 中引入,并在 iPadOS 中可用。它适合响应指针进入或离开某个 view 的边界,例如显示或隐藏视频播放控制条。修改指针外观或 hover effect 时,应使用 UIPointerInteraction

let controlsHover = UIHoverGestureRecognizer(target: self, action: #selector(handleHover))

@objc func handleHover(_ recognizer: UIHoverGestureRecognizer) {
    switch recognizer.state {
    case .began:
        // Pointer entered our view - show controls
        self.showsPlaybackControls = true
    case .ended:
        // Pointer exited our view - hide controls
        self.showsPlaybackControls = false
    default:
        break
    }
}

关键点:

  • UIHoverGestureRecognizer 和其他 gesture recognizer 一样,通过 target-action 工作。
  • .began 对应指针进入当前 gesture view 的边界。
  • .ended 对应指针离开当前 gesture view 的边界。
  • transcript 明确区分了 gesture state 和 window 级别的 pointer phases,例如 RegionEnteredRegionMovedRegionExited

2. 用 prefersPointerLocked 申请 pointer lock

05:33)iPadOS 14 和 Mac Catalyst Big Sur 增加了锁定指针的 API。典型场景是游戏:用户不想让指针在屏幕上移动,而是希望相对位移直接驱动视角或角色。App 通过 view controller 表达偏好,最终是否锁定由系统根据场景状态决定。

class GameViewController: UIViewController {

    var shouldLockPointer: Bool = true

    override var prefersPointerLocked: Bool {
        return self.shouldLockPointer
    }

    func disablePointerLock() {
        self.shouldLockPointer = false
        self.setNeedsUpdateOfPrefersPointerLocked()
    }
}

关键点:

  • prefersPointerLocked 的默认值是 false
  • 需要锁定指针的 view controller 覆写该属性并返回当前偏好。
  • 状态变化后调用 setNeedsUpdateOfPrefersPointerLocked(),让系统重新读取偏好。
  • iPadOS 上 scene 需要全屏且处于 foregroundActive;Mac Catalyst 上 App 需要是 frontmost application。

05:53)如果业务逻辑依赖真实锁定状态,读取 scene 的 pointerLockState,并观察 UIPointerLockState.didChangeNotification。session 强调,App 不能假设偏好一定会被满足。

if let pointerLockState = self.window.windowScene?.pointerLockState {
    self.observer = notificationCenter.addObserver(forName: UIPointerLockState.didChangeNotification,
                                                   object: pointerLockState,
                                                   queue: OperationQueue.main) { (note) in
        guard let lockState = note.object as? UIPointerLockState else { return }
        gameEngine.performExpensiveOperationWhile(lockState.isLocked)
    }
}

关键点:

  • pointerLockState 可能为 nil,表示当前 scene 不支持 pointer lock。
  • isLocked 是系统解析后的真实状态。
  • 当 Slide Over、Control Center、Notification Center 或前台 App 状态变化影响锁定条件时,通知会触发。

3. 给 UIPanGestureRecognizer 打开 scroll input

09:54UIScrollView 的 pan gesture recognizer 会处理所有 scroll input,但普通 UIPanGestureRecognizer 默认没有 scroll mask。自定义抽屉、pull-to-refresh、画布拖动这类交互,需要按场景设置 allowedScrollTypesMask

// Enable scroll input for touch surface devices

self.drawerPan.allowedScrollTypesMask = [.continuous]


// Enable scroll input for scroll wheel devices as well

self.pullToRefreshPan.allowedScrollTypesMask = [.all]

关键点:

  • .continuous 用于触控板这类 touch surface 的连续滚动。
  • .all 同时接收连续滚动和鼠标滚轮输入。
  • session 里的抽屉示例只接受连续滚动,因为设计师判断滚轮拉开侧边内容不自然。
  • 自定义 pull-to-refresh 可以选择 .all,让触控板和滚轮都能触发。

4. 让 pinch 和 rotate 使用精确 transform 事件

10:41UIPinchGestureRecognizerUIRotationGestureRecognizer 会自动处理触控板上的捏合和旋转。旧项目默认处在兼容模式,UIKit 会模拟两根手指的 touch。加入 UIApplicationSupportsIndirectInputEvents 后,App 会收到来自设备的 EventType.transform,手势精度更高。

<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>

关键点:

  • 这个 Info.plist key 不是启用 pointer interaction、点击、scroll input 或 trackpad gestures 的前提。
  • 它会启用 TouchType.indirectPointerEventType.transform
  • 在 transform 事件驱动下,numberOfTouches 会返回 0locationOfTouch:inView 可能抛出异常。
  • 处理 UIPanGestureRecognizerUIPinchGestureRecognizerUIRotationGestureRecognizer 时,要把依赖事件的判断迁移到新的 shouldReceive 方法。

5. 用 buttonMaskRequired 接收指定鼠标按钮

14:48UIEvent.ButtonMask 表示点击时按下的 pointing device 按钮。UITapGestureRecognizer 增加了 buttonMaskRequired,可以直接要求某个按钮才能触发手势。

self.thirdMouseButtonTap.buttonMaskRequired = .button(3)

关键点:

  • .button(3) 返回第三个鼠标按钮对应的 mask。
  • 这种写法适合给专业用户增加额外操作,例如在编辑器里把第三键绑定到临时工具。
  • buttonMask 同时存在于 UIEventUIGestureRecognizer,但读取时机不同。

15:07)键盘修饰键通过 UIKeyModifierFlags 表达,并暴露为 modifierFlags。gesture callback 中可以根据修饰键改变响应方式。

func handleHover(_ recognizer: UIHoverGestureRecognizer) {

    // Show chapter controls if alt is pressed
    let showChapterControls = recognizer.modifierFlags.contains(.alternate)

    // ...
}

关键点:

  • .alternate 对应 Option/Alt 修饰键。
  • 示例里同一个 hover gesture 可以在普通 hover 时显示播放控制,在按住 Option 时显示章节控制。
  • 这类高级交互适合隐藏频率较低但效率很高的功能。

6. 在 shouldReceive(_ event:) 里过滤事件

16:38)新的 UIGestureRecognizerUIGestureRecognizerDelegate 方法允许手势在处理前接受或拒绝 UIEvent。session 特别提醒:这些方法发生在 gesture 完整处理事件之前,所以要读取 UIEvent 上的属性,而非 UIGestureRecognizer 上的滞后属性。

class SecondaryClickGesture: UIGestureRecognizer {

    override func shouldReceive(_ event: UIEvent) -> Bool {
        // Must look at the event’s mask, not the gesture’s
        return event.buttonMask == .secondary
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        // Touch handling code ...
    }
}

关键点:

  • shouldReceive(_ event:) 可以让自定义 gesture 只接收 secondary click。
  • 这个阶段 event.buttonMask 是最新值。
  • gestureRecognizer(shouldReceive touch:) 不会覆盖 scroll 或 transform 这类非 touch 事件。

17:36)如果要让 Control + 主键点击等价于 secondary click,可以同时检查 buttonMaskmodifierFlags

class SecondaryClickGesture: UIGestureRecognizer {

    override func shouldReceive(_ event: UIEvent) -> Bool {
        // Must look at the event’s properties, not the gesture’s
        let secondaryClick = event.buttonMask == .secondary

        let controlClick = event.buttonMask == .primary && event.modifierFlags == .control

        return secondaryClick || controlClick
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        // Touch handling code ...
    }
}

关键点:

  • secondaryClick 覆盖右键或两指点击。
  • controlClick 覆盖主按钮加 Control 修饰键。
  • 这个模式适合上下文菜单、检查器、专业编辑工具等操作。

18:10)delegate 方法也可以过滤 hover 事件。下面的示例只在按住 Option/Alt 时接收 closed caption hover。

let ccHover = UIHoverGestureRecognizer(target: self,
                                       action: #selector(handleClosedCaptionHover))

ccHover.delegate = self

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                       shouldReceive event: UIEvent) -> Bool {

    if gestureRecognizer == self.closedCaptionHover {
        return event.modifierFlags.contains(.alternate)
    }

    return true
}

关键点:

  • gesture delegate 可以为不同 gesture 定义不同过滤条件。
  • event.modifierFlags.contains(.alternate) 是接收 closed caption hover 的唯一条件。
  • 其他 gesture 返回 true,保持原有事件流。

核心启发

1. 视频播放器的指针浮层

做什么:指针进入播放区域时显示控制条,按住 Option/Alt 时显示章节或字幕入口。

为什么值得做:session 的 hover 示例和 modifier flags 示例正好覆盖这两个交互层级。

怎么开始:给播放器 view 加 UIHoverGestureRecognizer,在 callback 中读取 recognizer.staterecognizer.modifierFlags

2. 游戏或 3D 视图的 pointer lock 模式

做什么:在全屏游戏、3D 建模或地图漫游场景中,把鼠标相对位移用于视角控制。

为什么值得做prefersPointerLockedUIPointerLockState.isLocked 让 App 能表达锁定偏好,并按系统解析后的状态调整渲染或输入逻辑。

怎么开始:在主 view controller 覆写 prefersPointerLocked,观察 UIPointerLockState.didChangeNotification,在 isLocked 为 true 时启用相对位移控制。

3. 自定义抽屉和画布的触控板滚动

做什么:让侧边抽屉、时间线、画布平移、pull-to-refresh 支持触控板两指滑动和鼠标滚轮。

为什么值得做:普通 UIPanGestureRecognizer 默认不接收 scroll input,用户会遇到触控板能滚列表却拖不动自定义区域的问题。

怎么开始:为每个自定义 pan gesture 设置 allowedScrollTypesMask,需要触控板连续滚动时用 .continuous,需要滚轮支持时用 .all

4. 专业工具的多按钮快捷操作

做什么:在绘图、剪辑、IDE、表格类 App 中,把第三鼠标键、右键、Control + 点击映射到不同工具或上下文操作。

为什么值得做buttonMaskRequiredevent.buttonMaskevent.modifierFlags 能把精确输入交给对应 gesture,减少模式切换。

怎么开始:简单 tap 用 UITapGestureRecognizer.buttonMaskRequired;复杂过滤写在 shouldReceive(_ event:) 或 delegate 的 gestureRecognizer(_:shouldReceive:) 中。

5. 从触摸兼容模式迁移到 indirect input

做什么:让触控板捏合和旋转直接走 EventType.transform,并用 TouchType.indirectPointer 区分鼠标点击和手指点击。

为什么值得做:旧兼容模式会模拟 touch,精确手势可能误触其他 recognizer;新的 indirect input 路径能让手势语义更清楚。

怎么开始:在 Info.plist 加 UIApplicationSupportsIndirectInputEvents,检查依赖 numberOfToucheslocationOfTouch:inViewgestureRecognizer(shouldReceive touch:) 的代码。


关联 Session

评论

GitHub Issues · utterances