如何用拖拽手势识别器替代 mouseDown 并适配触摸输入?
可以用 NSPanGestureRecognizer 替换处理拖拽的 mouseDown(with:) 和事件跟踪循环,再通过 macOS 27 的 beginDraggingSession(items:gesture:source:) 启动跨应用拖放。单击、双击和长按分别处理,自定义画布的迁移无需等待 NSControlGestureRecognizer 开放。
Apple 的迁移资料包括 Adopting gesture recognizers in your app 和 Handling AppKit gestures。新拖放入口也有独立的代码示例。
拆分原来 mouseDown 中的职责
原来的方法同时处理选择、编辑、视图内移动和跨应用导出。迁移时可以按交互拆分:
| 交互 | 处理方式 |
|---|---|
| 单击选择文本框 | NSClickGestureRecognizer |
| 双击进入编辑 | numberOfClicksRequired = 2 的 NSClickGestureRecognizer |
| 在画布内移动文本框 | NSPanGestureRecognizer,更新位置 |
| 将合成图像拖出应用 | 手势识别后启动 NSDraggingSession |
| 长按显示其他操作 | NSPressGestureRecognizer,另行协调优先级 |
视图内移动只需修改布局,跨应用拖放则需要拖放会话和 pasteboard 数据。两者可以使用相同类型的手势识别器,但进入识别状态后,要确定执行哪一种操作。
用 pan 手势启动图像拖放
下面的代码沿用问题中的 image、delegate、overlay 和 draggingImage,假设画布类名为 ImageCanvas,并已遵循 NSDraggingSource。
初始化画布时调用一次 installImageDragGesture()。示例只处理图像导出,还需要排除文本框区域。
@available(macOS 27.0, *)
extension ImageCanvas {
func installImageDragGesture() {
let pan = NSPanGestureRecognizer(
target: self,
action: #selector(handleImageDrag(_:))
)
// 保留主鼠标按钮拖拽。
pan.buttonMask = 0x1
// 接受直接触摸;这不会关闭鼠标按钮识别。
pan.allowedTouchTypes = [.direct]
addGestureRecognizer(pan)
}
@objc private func handleImageDrag(
_ gesture: NSPanGestureRecognizer
) {
guard gesture.state == .began,
image != nil,
let delegate = delegate else {
return
}
let item = NSDraggingItem(
pasteboardWriter:
delegate.pasteboardWriter(forImageCanvas: self)
)
// draggingFrame 必须使用启动拖放的画布坐标系。
let previewFrame = overlay.convert(overlay.bounds, to: self)
item.setDraggingFrame(
previewFrame,
contents: draggingImage
)
guard let session = beginDraggingSession(
items: [item],
gesture: gesture,
source: self
) else {
return
}
session.animatesToStartingPositionsOnCancelOrFail = true
}
}
在 .began 时启动一次会话即可,无需继续等待 .leftMouseDragged,也无需通过 NSApp.currentEvent 拼出鼠标事件。新方法可能返回 nil,需要处理启动失败的情况。接口文档还要求拖拽预览的 frame 使用调用视图的坐标系。
复制导出图像通常还需要实现下面的 source 方法,已有实现可以保留:
func draggingSession(
_ session: NSDraggingSession,
sourceOperationMaskFor context: NSDraggingContext
) -> NSDragOperation {
.copy
}
allowedTouchTypes 的配置适用于支持相应输入的系统环境。各 macOS 版本的触摸能力并不相同,旧版系统仍需保留兼容路径。Apple 的 TN3212说明了 macOS 27 与 Sidecar 触摸输入的迁移要求。
文本框移动和图像导出应在开始时分流
画布上的 pan 手势也可能收到文本框区域内的输入。应在手势开始前,通过 delegate 的 gestureRecognizerShouldBegin(_:) 判断当前位置是否适合启动该手势;文本框编辑区域可以拒绝画布导出手势。
在画布内移动文本框时,保存被拖动对象和起始 frame,后续始终更新同一个对象:
// 在 .began 时保存:
startFrame = textField.frame
// 在 .began 和 .changed 时更新:
let translation = gesture.translation(in: self)
let proposedFrame = startFrame.offsetBy(
dx: translation.x,
dy: translation.y
)
textField.frame = constrainRectCenterToBounds(proposedFrame)
这段计算要求 textField.frame 与 translation 使用相同坐标系,也就是文本框为画布的直接子视图。若层级不同,应在文本框的父视图坐标系中计算。使用 Auto Layout 时,应更新约束。
在 .ended 时提交位置,在 .cancelled 时按产品需要恢复 startFrame 并清理状态。不要在每次 .changed 时重新做命中测试,否则拖动目标可能随指针位置改变。
如果需要准确保留最初按下的位置,要单独处理。pan 进入 .began 时,输入通常已经移动了一段距离,此时的位置不能当作原始按下位置。
单击、立即按下和长按需要分别设计
单击回调发生在点击识别完成后,晚于刚按下的时刻。迁移时要先确定,是否需要按下后立即选中或高亮。
单击选择和双击编辑可以分别使用一个 NSClickGestureRecognizer。如果双击前执行一次选择没有副作用,通常无需延迟选择;如果两者必须互斥,可以通过 gestureRecognizer(_:shouldRequireFailureOf:) 让单击识别器等待双击识别器失败,但单击响应也会因此延后。手势处理指南说明了这种失败依赖关系。
长按使用 NSPressGestureRecognizer,通过 minimumPressDuration 和 allowableMovement 设置按压要求。它与 pan 同时存在时,需要先确定移动和长按谁优先,再配置识别器之间的关系。不要让所有识别器都能同时识别,否则一次操作可能同时触发选择、菜单和拖拽。NSPressGestureRecognizer 文档列出了这些配置项。
将 minimumPressDuration 设为 0 也无法自动解决这些协调问题,立即识别的按压可能抢先影响其他手势。
文件承诺部分可以继续使用
beginDraggingSession(items:gesture:source:) 改变了拖放的启动方式,原来的 NSDraggingItem、NSPasteboardWriting 和文件承诺实现仍可复用。
如果希望将尚未保存的合成图像拖到 Finder 并生成图像文件,应继续让 pasteboardWriter(forImageCanvas:) 提供 NSFilePromiseProvider。拖拽预览只负责显示,接收方拿到的内容取决于 pasteboard writer。
文件承诺需要提供文件名,并在 filePromiseProvider(_:writePromiseTo:completionHandler:) 中写入目标 URL,完成后调用 completion handler。Apple 的文件承诺示例仍适用于这部分工作。用于导出的图像和文本应在拖放开始时生成快照,避免延后写入时读到已被修改的画布内容。
不要禁用标准控件内部的识别器
调试输出中出现 NSControlGestureRecognizer,只能说明标准控件正在使用这个运行时对象,无法据此判断它是否为应用可以直接实例化或配置的公开 API。
禁用它后,NSColorWell 的点击和拖拽失效,这与控件依赖内部识别器处理交互的情况一致。应保留这些识别器,通过公开的 target-action、control events 和手势 delegate 扩展行为,并检查覆盖视图是否挡住了控件的命中测试。
题目列出的 NSControl.Events 适合接收标准控件的跟踪或语义动作;exclusiveGestureBehavior 控制视图层级之间的手势独占范围。它们都不提供图像拖放数据,同一位置上 pan、click 和 press 的优先级也仍需单独设计。
WebKit 那次提交展示了 updateDragWithGesture: 驱动会话的实现路径,而当前公开接口文档说明,使用 beginDraggingSession(items:gesture:source:) 后由系统接管拖放跟踪。编写新代码时,应遵循所用 SDK 的文档,不能直接将该提交中的内部桥接逻辑搬入应用。
备注:内容仅供参考。
