Handle trackpad and mouse input
Swift & UI 进阶 26m

处理触控板和鼠标输入

Handle trackpad and mouse input

2020年6月23日

在 Apple 官方观看视频

一句话判断

如果你的 App 有自定义的手势处理逻辑(比如自定义滚动、拖拽),你需要了解 iPadOS 的触控板事件是如何转换为 UIEvent 的,否则用户用 Magic Keyboard 操作时会出现各种奇怪的行为。

这场 Session 讲了什么

iPadOS 13.4 引入了触控板和鼠标支持,但很多开发者不知道的是:触控板的手势和手指触摸的手势在 UIKit 中的事件模型是不同的。Session 详细解释了触控板输入的事件链路:手指在触控板上滑动会产生 Scroll 和 Zoom 事件,通过 UIEvent 的 scrollWheel 和 magnify 类型传递。

Session 重点讲解了三个场景。第一,自定义滚动视图:如果你重写了 touchesBegan 等方法但没有处理 scroll 事件,触控板滑动就不会生效。第二,拖拽交互:UIDragInteraction 默认支持触控板的长按拖拽,但自定义的拖拽逻辑需要额外处理。第三,右键菜单:触控板的二指点击或鼠标右键会触发 UIContextMenuInteraction,你需要确保自定义的上下文菜单在所有输入方式下都正常工作。

值得深挖的点

事件响应链的差异性

手指触摸产生的是 UITouch 事件,走 touchesBegan/moved/ended 的响应链。触控板滑动产生的是 UIScrollWheelEvent,走 scrollWheel(with:) 的响应链。如果你的自定义视图只处理了前者,用户用触控板操作时会发现”滑不动”。解决方案是同时覆写 scrollWheel(with:) 方法,或者在 UIScrollView 的子类中确保不阻断触控板的滚动事件传递。

按键修饰符(Modifier Flags)

触控板和鼠标输入支持 Command、Option、Shift 等修饰键。通过 UIEvent 的 modifierFlags 你可以检测用户是否按住了某个修饰键,从而提供不同的交互行为。比如按住 Option 键拖拽可能是复制而不是移动。这是桌面级 App 的标配功能,iPad App 也应该支持。

代码片段

同时支持触摸和触控板的滚动

场景:自定义绘图视图支持触摸和触控板两种滚动方式。

class CanvasScrollView: UIScrollView {
    
    // 触控板滚动事件
    override func scrollWheel(with event: UIEvent) {
        // 检查是否是触控板事件
        if event.type == .scrollWheel {
            let momentum = event.momentumPhase
            
            switch momentum {
            case .began:
                // 触控板滑动开始,可能需要停止当前动画
                layer.removeAllAnimations()
            case .changed:
                // 正在滑动,更新 contentOffset
                super.scrollWheel(with: event)
            case .ended:
                // 滑动结束,系统会自动处理惯性滚动
                super.scrollWheel(with: event)
            default:
                super.scrollWheel(with: event)
            }
        }
    }
}

处理触控板的手势识别

场景:区分触控板点击和触摸屏点击。

class DrawingGestureRecognizer: UIGestureRecognizer {
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        // 检查触摸来源
        if let touch = touches.first {
            switch touch.type {
            case .direct:
                // 手指直接触摸屏幕
                handleDirectTouch(touch)
            case .indirect:
                // 触控板或鼠标的间接触摸
                handleIndirectInput(touch)
            case .pencil:
                // Apple Pencil
                handlePencilInput(touch)
            default:
                break
            }
        }
    }
    
    private func handleDirectTouch(_ touch: UITouch) {
        // 手指触摸:较大的绘制笔触
        state = .began
    }
    
    private func handleIndirectInput(_ touch: UITouch) {
        // 触控板输入:精确的绘制
        state = .began
    }
    
    private func handlePencilInput(_ touch: UITouch) {
        // Apple Pencil:压感绘制
        state = .began
    }
}

支持修饰键的交互行为

场景:按住不同修饰键执行不同的操作。

class DragInteractionHandler: NSObject, UIDragInteractionDelegate {
    
    func dragInteraction(_ interaction: UIDragInteraction,
                         itemsForAddingTo session: UIDragSession,
                         with touchAtPoint: CGPoint) -> [UIDragItem] {
        
        // 检查修饰键状态
        let modifiers = session.localContext as? UIEvent.ModifierFlags ?? []
        
        if modifiers.contains(.command) {
            // Command + 拖拽:创建快捷方式
            return createShortcutDragItems()
        } else if modifiers.contains(.option) {
            // Option + 拖拽:复制
            return createCopyDragItems()
        } else {
            // 普通拖拽:移动
            return createMoveDragItems()
        }
    }
    
    // 处理拖拽预览
    func dragInteraction(_ interaction: UIDragInteraction,
                         previewForLifting item: UIDragItem,
                         session: UIDragSession) -> UITargetedDragPreview? {
        return UITargetedDragPreview(view: interaction.view!)
    }
}

正确处理右键上下文菜单

场景:同时支持长按和右键触发菜单。

class DocumentCollectionViewCell: UICollectionViewCell {
    
    func setupContextMenu() {
        let interaction = UIContextMenuInteraction(delegate: self)
        addInteraction(interaction)
    }
}

extension DocumentCollectionViewCell: UIContextMenuInteractionDelegate {
    func contextMenuInteraction(_ interaction: UIContextMenuInteraction,
                                configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
        // 无论长按还是右键,都会触发这个方法
        return UIContextMenuConfiguration(
            identifier: "document-menu" as NSCopying,
            previewProvider: { nil },
            actionProvider: { suggestedActions in
                // 返回上下文菜单
                return UIMenu(title: "操作", children: [
                    UIAction(title: "打开", image: UIImage(systemName: "doc")) { _ in
                        self.openDocument()
                    },
                    UIAction(title: "复制", image: UIImage(systemName: "doc.on.doc")) { _ in
                        self.copyDocument()
                    },
                    UIAction(title: "删除",
                             image: UIImage(systemName: "trash"),
                             attributes: .destructive) { _ in
                        self.deleteDocument()
                    }
                ])
            }
        )
    }
}

最佳实践

已有项目:如果你有自定义的手势识别器或触摸处理逻辑,在 iPad 上用 Magic Keyboard 和 Magic Mouse 测试一遍。重点检查滚动、缩放、右键菜单是否正常工作。UITouch.type 属性可以帮你区分触摸来源,对不同类型做差异化处理。

新项目:使用 UIGestureRecognizer 的标准子类来处理交互,它们已经内置了触控板支持。只在需要高度自定义的交互场景才覆写底层触摸方法。始终使用 UIContextMenuInteraction 来提供上下文菜单,不要自定义长按菜单。

还有什么值得关注

  • UIScrollView.isDirectionalLockEnabled 在触控板操作时可以锁定滚动方向。
  • 触控板的三指拖拽手势可以通过辅助功能设置开启,你的 App 不需要额外处理。
  • 在 iPadOS 14 中,按键事件可以通过 UIKeyCommand 更好地集成到菜单栏中。
WWDC 2020