PAYATHON 2026

如何用拖拽手势识别器替代 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 的文档,不能直接将该提交中的内部桥接逻辑搬入应用。

备注:内容仅供参考。