若要將 SwiftUI 選單命令傳送至作用中的 NSTextView,請在命令執行時解析 key window 的第一回應者,然後將動作傳送至該檢視。本教學會建置一個包含兩個編輯器的範例,其中 Select First Line 命令只會影響目前取得焦點的編輯器。

這項工作是在你自己的 macOS app 中建立自訂選單動作。範例使用合成文字、由 app 擁有的 NSTextView 子類別,以及公開的 SwiftUI 和 AppKit API。關鍵設計決策在於:命令應沿用 AppKit 的回應者鏈,還是只要求特定的作用中編輯器。

決定「作用中的文字檢視」代表什麼

在這個範例中,作用中的編輯器是目前作為 key window 第一回應者的 SampleTextView 執行個體。Apple 將 key window 定義為接收鍵盤事件的視窗;其 firstResponder 會識別通常最先接收事件與動作的回應者。在命令呼叫時讀取這些屬性,便能將命令送往目前的目標。Apple 的 keyWindow 文件、Apple 的 firstResponder 文件。

AppKit 也支援未指定目標的動作:將 nil 作為目標傳給 NSApplication.sendAction(_:to:from:),應用程式便會搜尋實作該選取器的回應者。搜尋會從 key window 的第一回應者開始,但也可能繼續搜尋委派者和主視窗,最後才到應用程式層級的處理常式。因此,未指定目標的動作並不代表「只在這個 key window 的作用中文件檢視上執行」。Apple 的動作路由文件。

所需行為 路由選擇 結果
讓 AppKit 尋找符合條件的處理常式 使用 to: nil 傳送動作 會套用一般的回應者鏈遞補機制。
只影響 app 擁有的作用中編輯器 解析並檢查第一回應者的型別,然後明確指定為目標 若該編輯器並未作用中,命令便會停止。
無論焦點在哪裡,都操作特定編輯器 將該編輯器設為目標 這與操作作用中編輯器的互動方式不同。

以下實作選擇第二種行為。檢查自訂子類別也能避免無關的文字欄位編輯器只因為是文字檢視,就成為動作目標。

建置完整的 SwiftUI 與 AppKit 範例

建立 macOS app 專案並使用 SwiftUI;此範例的部署目標設為 macOS 13 或更新版本。將產生的 app 進入點替換為以下單一 Swift 檔案;移除其他任何 @main 宣告。程式會提供兩個編輯器、其初始文字、選單與動作處理常式。

SwiftUI 的 NSViewRepresentable 是內嵌 AppKit 檢視的支援橋接方式。Scene commands 用來提供選單內容,而 CommandMenu 會在 macOS 選單列建立 app 專屬選單。Apple 的 NSViewRepresentable 文件、scene commands、command menus。

import SwiftUI
import AppKit
import Foundation

@main
@MainActor
struct RoutingDemoApp: App {
    var body: some Scene {
        WindowGroup("Routing Demo") {
            ContentView()
        }
        .commands {
            CommandMenu("Sample Actions") {
                Button("Select First Line") {
                    Self.routeSelectFirstLine()
                }
            }
        }
    }

    private static func routeSelectFirstLine() {
        let application = NSApplication.shared
        guard let editor = application.keyWindow?.firstResponder
                as? SampleTextView else {
            return
        }

        _ = application.sendAction(
            #selector(SampleTextView.selectFirstSampleLine(_:)),
            to: editor,
            from: nil
        )
    }
}

@MainActor
struct ContentView: View {
    @State private var windowLabel = "Sample Window"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Window label", text: $windowLabel)

            HStack(spacing: 12) {
                SampleEditor(initialText: "Sample Alpha\nAlpha details")
                SampleEditor(initialText: "Sample Beta\nBeta details")
            }

            Text("Click an editor, then use Sample Actions.")
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 640, minHeight: 320)
    }
}

@MainActor
final class SampleTextView: NSTextView {
    @objc func selectFirstSampleLine(_ sender: Any?) {
        guard isSelectable, !hasMarkedText() else { return }

        let contents = string as NSString
        guard contents.length > 0 else { return }

        let firstLine = contents.lineRange(
            for: NSRange(location: 0, length: 0)
        )
        setSelectedRange(firstLine)
        scrollRangeToVisible(firstLine)
    }
}

@MainActor
struct SampleEditor: NSViewRepresentable {
    let initialText: String

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.borderType = .bezelBorder

        let editor = SampleTextView(
            frame: NSRect(x: 0, y: 0, width: 300, height: 240)
        )
        editor.isEditable = true
        editor.isSelectable = true
        editor.isRichText = false
        editor.allowsUndo = true
        editor.font = NSFont.monospacedSystemFont(
            ofSize: 14, weight: .regular
        )
        editor.minSize = .zero
        editor.maxSize = NSSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        editor.isVerticallyResizable = true
        editor.isHorizontallyResizable = false
        editor.autoresizingMask = [.width]
        editor.textContainer?.containerSize = NSSize(
            width: 300,
            height: CGFloat.greatestFiniteMagnitude
        )
        editor.textContainer?.widthTracksTextView = true
        editor.string = initialText

        scrollView.documentView = editor
        return scrollView
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        // AppKit owns the editable contents in this example.
    }
}

這是完整的 app 範例,編輯器內容保存在記憶體中。本文已根據取得的 API 文件檢視此範例;由於目前環境是 Linux,且沒有 Swift 工具鏈,因此未在此處編譯或執行。

選取器為何會送達預期的編輯器

路由函式會在派送動作前立即取得第一回應者。它只接受 SampleTextView,並將該物件本身指定為動作目標。因此,選單不會保留先前點選的編輯器;型別檢查失敗時,也不會搜尋其他視窗。

動作方法使用 @objc,因為 target-action 派送會使用 Objective-C 選取器。#selector 運算式參照已宣告的方法,而非手寫的選取器字串。Apple 說明了這種透過 Objective-C target-action API 呼叫 Swift 方法的組合方式。在 Swift 中使用 Objective-C 執行階段功能。

sendAction 的傳回值表示訊息是否已送出,不代表處理常式是否變更了選取範圍:處理常式可以接收訊息,並在內容為空或存在標記文字時提早返回。此範例刻意忽略派送結果,因為目標與方法都已確定。Apple 的 sendAction 傳回值文件。

選取命令實際會選取哪些內容

處理常式會要求 NSString.lineRange(for:) 傳回包含字串起始位置的邏輯行範圍。若該行有行尾字元,範圍也會包含它。因此,即使該行在畫面上換行成多列,它仍會選取第一個以換行符分隔的行。Apple 的行範圍文件。

對於提供的文字,左側編輯器會選取 Sample Alpha 及其後的換行符;右側編輯器會選取 Sample Beta 及其後的換行符。這個命令會取代原先的選取範圍,不會變更文字。

setSelectedRange(_:) 會套用選取範圍,而 scrollRangeToVisible(_:) 會將選取範圍的開頭移至可見區域。空內容檢查可避免在沒有文字時嘗試執行選取操作。標記文字檢查則是明確的範例政策:輸入組字期間,延後這個自訂選取動作。選取範圍、將範圍捲動至可見區域、標記文字。

分開處理檢視建立、編輯與焦點

這個 representable 會建立 NSScrollView,並將自訂文字檢視設為其文件檢視。編輯器會垂直延伸,而其文字容器會追蹤寬度。這符合 Apple 文件所述的可捲動文字檢視配置。該配置出自 Apple 已封存的 AppKit 指南;此處引用的 SwiftUI 橋接與動作 API 則採用目前的文件。將 NSTextView 物件放入 NSScrollView。

SwiftUI 負責管理該 representable 捲動檢視的版面配置。程式會設定內嵌文件檢視的初始幾何尺寸與 AppKit 調整大小行為;它不會反覆指定該 representable 捲動檢視的 frame。Apple 特別提醒,不要變更 SwiftUI 為 representable 管理的版面配置屬性。Apple 的 representable 版面配置指引。

範例只在建立檢視時設定 initialText。空白的更新方法刻意讓後續輸入由 AppKit 管理。如果你的應用程式需要 SwiftUI 文件模型,請加入 coordinator 和以委派者為基礎的同步機制,並明確定義編輯期間收到模型變更時的處理政策。每次更新都重新指定初始字串,會覆寫使用者目前的內容。Apple 說明 coordinator 是將 AppKit 互動傳回 SwiftUI 的橋接方式。Apple 的 representable 協調指引。

在此操作示範中,點擊編輯器即可設定焦點。如果你的 app 需要以程式方式移動焦點,請在檢視加入該視窗後,使用其所屬視窗的 makeFirstResponder(_:)。Apple 明確指出,不要直接呼叫文字檢視的 becomeFirstResponder()。避免在每次 SwiftUI 更新時都要求焦點,否則會反覆變更使用者的編輯目標。Apple 的文字檢視焦點指引。

保留現有的 TextKit 設定

選單路由不需要變更文字版面配置引擎。此範例使用 NSTextView(frame:);Apple 文件指出,該初始化器預設使用 NSLayoutManager。從 macOS 12 開始,也支援明確使用 NSTextLayoutManager 建構檢視。Apple 的文字檢視初始化文件。

若是現有的 TextKit 2 編輯器,請保留其建構方式,並將動作加入其子類別。避免只為了尋找編輯器或將動作路由至編輯器而讀取 layoutManager:Apple 文件指出,在 TextKit 2 文字檢視上存取此屬性可能會使其切換至相容模式。路由程式碼只需要回應者與動作方法。Apple 的 NSTextView 相容模式文件。

驗證路由並處理無法執行的命令

請依照以下步驟在 macOS 上檢查預期行為。這些是根據實作推導出的預期結果,並非實際執行結果。

  1. 點擊左側編輯器,然後選擇 Sample Actions → Select First Line。左側編輯器的第一個邏輯行應該會被選取。
  2. 點擊右側編輯器,並呼叫相同命令。只有右側編輯器的選取範圍應該會變更。
  3. 點擊 Window label 並呼叫命令。兩個自訂編輯器都應保持不變,因為第一回應者未通過子類別檢查。
  4. 清除某個編輯器的所有內容,並在該編輯器呼叫命令。空內容檢查應直接返回,不變更文字。
  5. 加入多行文字、捲動至下方,並呼叫命令。第一行應該會被選取並捲動至可見區域。

在這個精簡範例中,即使焦點位於其他地方,命令仍會保持啟用。命令執行時的檢查會決定是否執行動作。只在 SwiftUI 的 .disabled 運算式中讀取 keyWindow?.firstResponder,本身無法確保 AppKit 焦點變更時該運算式也會更新。

若要在較大型的 SwiftUI 介面中使用,請透過可感知焦點的狀態發布適當的命令能力,使用 @FocusedValue 觀察該狀態,並從中推導按鈕的 .disabled 條件。AppKit 橋接必須讓該能力與實際編輯器焦點保持一致,也應保留執行時檢查。Apple 說明 FocusedValue 用來觀察取得焦點的檢視或其祖先所提供的值。焦點值、停用互動。

如果你改用 AppKit 選單項目,並將編輯器設為其目標,validateMenuItem(_:) 會提供 AppKit 的驗證掛鉤。只在這個子類別中加入該方法,並不會自動驗證 SwiftUI 按鈕:按鈕的動作是一個閉包,接著會派送另一個動作。Apple 要求驗證物件必須是選單項目的目標。Apple 的選單項目驗證文件。

新增更多命令之前,先選定目標政策。若預期行為包含回應者鏈遞補,請使用未指定目標的動作;若操作只屬於作用中的編輯器,則解析第一回應者並將其設為目標。如此一來,當使用者在文字檢視與其他控制項之間移動時,同一個選單也能維持可預期的行為。

來源