SwiftUI:检测 Mac 触控板上的手指位置

SwiftUI: Detect finger position on Mac trackpad

我正在为 macOS 制作一个 SwiftUI 应用程序,我想通过检测用户手指的位置将触控板用作 (x, y) 输入。我希望能够检测到触控板上 静止 的多个手指(不拖动)。我该怎么做?

A similar question 以前有人问过,但我又问了一遍,因为那是将近 10 年前的事了,答案都在 Obj-C 中(Swift 3 中有一个),并且我想知道是否有更新的方法。最重要的是,我不知道如何将 Obj-C 代码实现到我的 SwiftUI 应用程序中,所以如果没有任何更新的方法,我将不胜感激,如果有人可以解释如何实现旧的 Obj-C 代码。

为了证明我的意思,这个video demo of the AudioSwift app does exactly what I want. macOS itself also uses this for hand-writing Chinese(虽然我不需要识别字符)。

总是把你的任务分成更小的任务,一个接一个地完成。以同样的方式提问,避免涉及很多话题的宽泛问题。

目标

  • 轨迹板视图(灰色矩形)
  • 上面的圆圈表示手指的物理位置

第 1 步 - AppKit

第一步是创建一个简单的 AppKitTouchesView 通过委托转发所需的接触。

import SwiftUI
import AppKit

protocol AppKitTouchesViewDelegate: AnyObject {
    // Provides `.touching` touches only.
    func touchesView(_ view: AppKitTouchesView, didUpdateTouchingTouches touches: Set<NSTouch>)
}

final class AppKitTouchesView: NSView {
    weak var delegate: AppKitTouchesViewDelegate?

    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        // We're interested in `.indirect` touches only.
        allowedTouchTypes = [.indirect]
        // We'd like to receive resting touches as well.
        wantsRestingTouches = true
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func handleTouches(with event: NSEvent) {
        // Get all `.touching` touches only (includes `.began`, `.moved` & `.stationary`).
        let touches = event.touches(matching: .touching, in: self)
        // Forward them via delegate.
        delegate?.touchesView(self, didUpdateTouchingTouches: touches)
    }

    override func touchesBegan(with event: NSEvent) {
        handleTouches(with: event)
    }

    override func touchesEnded(with event: NSEvent) {
        handleTouches(with: event)
    }

    override func touchesMoved(with event: NSEvent) {
        handleTouches(with: event)
    }

    override func touchesCancelled(with event: NSEvent) {
        handleTouches(with: event)
    }
}

第 2 步 - 简化触控结构

第二步是创建一个简单的自定义 Touch 结构,它只包含所有必需的信息并且 SwiftUI 兼容(不翻转 y)。

struct Touch: Identifiable {
    // `Identifiable` -> `id` is required for `ForEach` (see below).
    let id: Int
    // Normalized touch X position on a device (0.0 - 1.0).
    let normalizedX: CGFloat
    // Normalized touch Y position on a device (0.0 - 1.0).
    let normalizedY: CGFloat

    init(_ nsTouch: NSTouch) {
        self.normalizedX = nsTouch.normalizedPosition.x
        // `NSTouch.normalizedPosition.y` is flipped -> 0.0 means bottom. But the
        // `Touch` structure is meants to be used with the SwiftUI -> flip it.
        self.normalizedY = 1.0 - nsTouch.normalizedPosition.y
        self.id = nsTouch.hash
    }
}

第 3 步 - 为 SwiftUI

包装

第三步是创建一个 SwiftUI 视图来包装我们的 AppKit AppKitTouchesView 视图。

struct TouchesView: NSViewRepresentable {
    // Up to date list of touching touches.
    @Binding var touches: [Touch]

    func updateNSView(_ nsView: AppKitTouchesView, context: Context) {
    }

    func makeNSView(context: Context) -> AppKitTouchesView {
        let view = AppKitTouchesView()
        view.delegate = context.coordinator
        return view
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, AppKitTouchesViewDelegate {
        let parent: TouchesView

        init(_ view: TouchesView) {
            self.parent = view
        }

        func touchesView(_ view: AppKitTouchesView, didUpdateTouchingTouches touches: Set<NSTouch>) {
            parent.touches = touches.map(Touch.init)
        }
    }
}

第 4 步 - 制作 TrackPadView

第四步是创建一个 TrackPadView,它在内部使用我们的 TouchesView 并在上面画圆圈代表手指的物理位置。

struct TrackPadView: View {
    private let touchViewSize: CGFloat = 20

    @State var touches: [Touch] = []

    var body: some View {
        ZStack {
            GeometryReader { proxy in
                TouchesView(touches: self.$touches)

                ForEach(self.touches) { touch in
                    Circle()
                        .foregroundColor(Color.green)
                        .frame(width: self.touchViewSize, height: self.touchViewSize)
                        .offset(
                            x: proxy.size.width * touch.normalizedX - self.touchViewSize / 2.0,
                            y: proxy.size.height * touch.normalizedY - self.touchViewSize / 2.0
                        )
                }
            }
        }
    }
}

第 5 步 - 在主体中使用它 ContentView

第五步是在我们的主视图中以接近真实触控板宽高比的一些宽高比使用它。

struct ContentView: View {
    var body: some View {
        TrackPadView()
            .background(Color.gray)
            .aspectRatio(1.6, contentMode: .fit)
            .padding()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

完成项目

  • 打开Xcode
  • 创建一个新项目(macOS App & Swift & SwiftUI)
  • this gist
  • 复制并粘贴 ContentView.swift