若要讓以程式建立的 NSTextView 接受輸入,請啟用編輯功能、將它附加至視窗,並呼叫該視窗的 makeFirstResponder(_:)。視窗也必須能成為鍵盤焦點視窗。以下提供完整範例和精簡的疑難排解步驟。

輸入文字前必須滿足哪些條件?

本指南說明一項工作:建立一個小型 Mac App 視窗,讓使用者能輸入及編輯多行文字。指南直接使用 AppKit,接著說明將編輯器嵌入 SwiftUI 時相應的生命週期選擇。

有三個彼此獨立的條件。文字檢視必須允許編輯、視窗必須接收鍵盤事件,而文字檢視必須是該視窗中預期的第一回應者。Apple 將應用程式的 keyWindow 定義為目前接收鍵盤事件的視窗;單純顯示視窗,並不會指定哪個檢視應負責編輯文字。Apple:keyWindow

設定 isEditable = true 可允許編輯,也會讓文字檢視可供選取。Apple 說明文字檢視預設可編輯,但明確設定能讓範例的組態一目了然。僅啟用編輯功能並不會指定鍵盤輸入目的地。Apple:isEditable

若要設定焦點,請使用所屬視窗的 makeFirstResponder(textView)。Apple 明確指示開發者使用這個方法,而不要直接呼叫文字檢視的 becomeFirstResponder()。如此可讓視窗協調回應者之間的轉換。Apple:NSTextView

使用 Swift 建立完整的可編輯視窗

以下獨立程式適用於 macOS 14 或更新版本,並使用目前的 Swift 工具鏈與 macOS SDK。範例視窗標題為 Local Editor,初始文字為 Sample note。文字只保存在記憶體中;本範例未實作儲存功能。

將完整程式儲存為 EditorDemo.swift。請將它作為獨立可執行檔使用,不要把它新增至已有另一個進入點的 App。Swift 只允許一個程式進入點;下方的 @main 型別會提供這個進入點。Swift 程式語言:屬性

import AppKit

@MainActor
final class EditorDelegate: NSObject, NSApplicationDelegate {
    private var editorWindow: NSWindow?

    func applicationDidFinishLaunching(_ notification: Notification) {
        let window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
            styleMask: [.titled, .closable, .miniaturizable, .resizable],
            backing: .buffered,
            defer: false
        )
        window.title = "Local Editor"
        window.isReleasedWhenClosed = false

        let scrollView = NSTextView.scrollableTextView()
        guard let textView = scrollView.documentView as? NSTextView else {
            fatalError("Expected an NSTextView document view")
        }

        textView.isEditable = true
        textView.isSelectable = true
        textView.isRichText = false
        textView.allowsUndo = true
        textView.font = NSFont.systemFont(ofSize: 16)
        textView.textContainerInset = NSSize(width: 12, height: 12)
        textView.string = "Sample note\n"
        textView.setSelectedRange(
            NSRange(location: (textView.string as NSString).length, length: 0)
        )

        scrollView.frame = NSRect(x: 0, y: 0, width: 640, height: 420)
        scrollView.autoresizingMask = [.width, .height]
        window.contentView = scrollView
        window.initialFirstResponder = textView
        editorWindow = window

        let mainMenu = NSMenu()
        let appMenuItem = NSMenuItem()
        mainMenu.addItem(appMenuItem)
        let appMenu = NSMenu()
        appMenu.addItem(NSMenuItem(
            title: "Quit Local Editor",
            action: #selector(NSApplication.terminate(_:)),
            keyEquivalent: "q"
        ))
        appMenuItem.submenu = appMenu
        NSApp.mainMenu = mainMenu

        window.center()
        window.makeKeyAndOrderFront(nil)
        NSApp.activate()

        guard textView.window === window,
              textView.acceptsFirstResponder else {
            print("Editor is not ready to receive focus")
            return
        }

        let accepted = window.makeFirstResponder(textView)
        print("Focus request returned:", accepted)
        print("Editor is first responder:", window.firstResponder === textView)
        print("Window is key:", window.isKeyWindow)
    }

    func applicationShouldTerminateAfterLastWindowClosed(
        _ sender: NSApplication
    ) -> Bool {
        true
    }
}

@main
@MainActor
enum EditorDemo {
    static func main() {
        let application = NSApplication.shared
        application.setActivationPolicy(.regular)
        let delegate = EditorDelegate()
        application.delegate = delegate
        withExtendedLifetime(delegate) {
            application.run()
        }
    }
}

在安裝了 Xcode 或 Command Line Tools 的 Mac 上,於檔案所在目錄編譯並啟動程式:

xcrun swiftc -parse-as-library \
  -target "$(uname -m)-apple-macos14.0" \
  -framework AppKit EditorDemo.swift -o EditorDemo
./EditorDemo

本範例已對照取得的文件進行檢視,但無法在這個研究環境中編譯或執行;該環境使用 Linux,且沒有 Swift 編譯器。請依照下方的檢查步驟,在 macOS 上確認視窗行為。

為什麼這種設定能處理鍵盤輸入

便利方法 scrollableTextView() 會回傳 NSScrollView;程式透過 documentView 取得其中的文字檢視。將文字編輯器放在捲動檢視中,可為這個小型範例提供直接的多行內容容器。macOS 10.14 起即可使用這個工廠方法。Apple:scrollableTextView()

程式會先附加這個容器,再要求焦點。它也設定了 initialFirstResponder;Apple 將此屬性定義為視窗首次顯示時選取的檢視。之後呼叫 makeFirstResponder(_:),明確要求將編輯器設為回應者,並提供可檢查的結果。初始焦點與稍後的焦點要求,分別適用於視窗生命週期的不同階段。Apple:initialFirstResponder、Apple:makeFirstResponder(_:)

視窗採用含標題列的樣式,因此可以成為鍵盤焦點視窗。makeKeyAndOrderFront(nil) 同時要求顯示視窗並將它設為鍵盤焦點視窗。Apple 將這項行為與 orderFront(_:) 區分開來;後者只會改變視窗的排序,不會變更鍵盤焦點視窗。Apple:canBecomeKey、Apple:makeKeyAndOrderFront(_:)

呼叫 activate() 是這個程式要求 macOS 14 或更新版本的原因。Apple 說明,啟用 App 是一項要求,而非保證。如果視窗出現後仍無法輸入文字,請點選視窗來啟用 App,然後再檢查一次。不要將立即印出的診斷訊息視為啟用已完成的保證。Apple:activate()

進入點和委派型別都標記為 @MainActor;Apple 將其執行器定義為等同於主派送佇列。程式也透過 run() 啟動 AppKit 的事件迴圈,讓 AppKit 在設定完成後派送使用者事件。Apple:MainActor、Apple:run()

排查可見但仍無法輸入文字的編輯器

請依序完成下列步驟。這些步驟將檢查檢視是否已附加、視窗是否符合條件、回應者是否已選取,以及是否允許編輯;每項觀察結果都會指出明確的下一步行動。

檢查項目 結果代表的意義 下一步行動
textView.window === window 編輯器屬於預期的視窗。 若為 false,請先完成檢視階層的附加,再要求焦點。
window.canBecomeKey 視窗符合接收鍵盤焦點狀態的條件。 若為 false,請檢查視窗樣式或自訂子類別。
window.isKeyWindow 這個視窗目前是 App 中的鍵盤焦點視窗。 啟用 App 並選取預期的視窗,然後再次檢查。
textView.acceptsFirstResponder 編輯器接受焦點要求。 若為 false,請檢查組態及子類別覆寫。
window.firstResponder === textView 這個特定編輯器是第一回應者。 若為 false,請檢查焦點轉換和目前的回應者。
textView.isEditable 已啟用使用者編輯功能。 若預期允許編輯,請將它設為 true。

回應者檢查特別實用,因為 makeFirstResponder(_:) 有一個文件提及的細節:如果要求的回應者拒絕成為第一回應者,而視窗本身改為第一回應者,它可能回傳 true。Apple 也指出,明確提出要求前應檢查 acceptsFirstResponder。因此,請同時檢查回傳值和最後成為 firstResponder 的物件身分。Apple:makeFirstResponder(_:)

若結果為 false,可能表示現有的第一回應者拒絕放棄回應者身分。請檢查該回應者的行為,以及涉及的任何編輯委派。例如,Apple 說明文字檢視的委派可以透過 textShouldEndEditing(_:) 拒絕結束編輯。若未先解決拒絕原因就重複提出相同的焦點要求,並無法解決根本原因。Apple:makeFirstResponder(_:)、Apple:NSTextView

進行基本手動檢查時,輸入 Blue square,按 Return,再輸入 Green circle,接著使用方向鍵和 Delete 編輯文字。調整視窗大小後繼續輸入。這些是建議的驗證步驟,並非已回報的測試結果。完成後關閉視窗;範例程式隨即結束。

無邊框視窗會有哪些不同?

可見的無邊框視窗仍可能無法成為鍵盤焦點視窗。Apple 說明,未採用含標題列樣式的一般 NSWindow 預設不會成為鍵盤焦點視窗。若預期該視窗要接收鍵盤輸入,支援的自訂方式是在 NSWindow 子類別中覆寫 canBecomeKey 並回傳 true。Apple:canBecomeKey

先使用含標題列的範例,確認文字輸入功能正常。若介面之後需要無邊框視窗,請修改視窗實作,同時保留相同的附加、可編輯性和回應者檢查。改變視窗外觀並不能取代這些條件。

如何讓同一個編輯器在 SwiftUI 中運作?

使用 NSViewRepresentable 將 AppKit 文字檢視整合至 SwiftUI 階層。Apple 將建立與初始設定交由 makeNSView(context:),持續更新狀態交由 updateNSView(_:context:),而與委派或 target-action 訊息的協調則交由 coordinator 負責。Apple:NSViewRepresentable

對這個編輯器而言,實用的設計是將捲動檢視作為代表檢視回傳,並在 coordinator 或自訂容器中保留其文件文字檢視的參照。在建立時設定可編輯性。若周圍的 SwiftUI 介面需要繫結,請透過委派轉送使用者的文字變更。

請將附加視為與建立分開的事件。要求焦點前,請檢查編輯器的 window 是否非 nil。AppKit 子類別可以使用 viewDidMoveToWindow() 來觀察階層變更;Apple 說明,在此處若 window 為 nil,表示檢視目前不屬於任何視窗。這提供了有文件記載的生命週期掛鉤,可用來實作會確認附加狀態的焦點要求。Apple:viewDidMoveToWindow()

作為實作策略,請僅在預期顯示時要求一次初始焦點,或在使用者執行「Focus Editor」等明確動作時要求焦點。避免在每次 SwiftUI 更新時都無條件呼叫 makeFirstResponder(_:):這種做法會在使用者選取其他控制項後,反覆將焦點移回編輯器。提出要求時,請檢查所屬視窗,而不要假設先前儲存的視窗仍然適用。

也要避免在每次更新時重設編輯器的字串。使用繫結的包裝器時,請比較收到的狀態與目前文字,並在取代內容前處理組字文字與選取範圍。獨立範例只初始化一次字串;同步編輯器則需要明確的更新策略。

請讓 SwiftUI 調整代表根檢視的大小。Apple 警告,直接變更該檢視的 frame 或 bounds,會與 SwiftUI 的版面配置管理衝突。因此,獨立 AppKit 程式中的直接 frame 設定,不應照搬到由 SwiftUI 管理的根檢視上作為尺寸設定方式。Apple:NSViewRepresentable

接受鍵盤輸入是否需要自訂 TextKit 堆疊?

對於這項工作,請使用 NSTextView 既有的編輯行為。Apple 說明,它是文字系統的前端,負責繪製文字並處理選取、修改、輸入管理和按鍵綁定。一般文字輸入不需要重建這些功能。Apple:NSTextView

TextKit 引擎的選擇,與鍵盤焦點是兩個不同的決策。Apple 說明,init(frame:) 預設使用 NSLayoutManager;而手動組合的文字檢視可在 macOS 12 或更新版本透過指定初始化方法使用 NSTextLayoutManager。請根據版面配置需求選擇引擎;切換引擎無法取代將編輯器設為第一回應者。Apple:init(frame:textContainer:)

有一項診斷需特別留意:讀取 layoutManager 不一定只是單純檢查。Apple 說明,若在使用較新系統的文字檢視或文字容器上存取它,可能會觸發使用 NSLayoutManager 的相容模式。本範例不會查詢此屬性,也不假設可捲動便利工廠方法選用哪一種引擎。Apple:NSTextView

成功的結果很明確:預期的 App 視窗處於啟用狀態且是鍵盤焦點視窗;其中可編輯的文字檢視是第一回應者;輸入文字會改變畫面上顯示的內容。先用小型視窗確認這項行為,再於加入 SwiftUI 整合、自訂版面配置或其他控制項時,維持相同的檢查方式。

資料來源