對於具有單行可編輯列的 Mac App,使用 onSubmit 處理 Return,在目前列正下方插入新的模型,並透過 FocusState 將焦點設到其欄位。為每列指定穩定 ID,讓插入操作能維持文字、列與焦點之間的關聯。
本教學會建立一個小型的本機清單編輯器,使用 Item A 等合成項目。按下 Return 會在正在編輯的欄位下方建立空白列、保留現有文字,並將鍵盤焦點移至新欄位。它不會在插入點拆分文字。
哪種文字控制項支援這種行為?
當每列代表一筆簡短項目時,請使用單行 TextField。Apple 明確說明,使用者按下硬體或軟體 Return 鍵時,TextField 會觸發其 onSubmit 動作。Apple 也說明,指定 FocusState 值會將焦點移至相關聯的檢視。這兩個 API 適用於 macOS 12 及更新版本。Apple:onSubmit(of:_:)、Apple:FocusState。
請依使用者正在編輯的內容選擇控制項:
| 編輯工作 | 起始選項 | 要實作的 Return 行為 |
|---|---|---|
| 每列一筆簡短項目 | 單行 TextField |
提交、在下方插入、聚焦新列 |
| 每列一段文字 | TextEditor 或 AppKit 文字檢視 |
定義插入列與插入換行符號有何不同 |
| 具有自訂文字命令的文件 | 嵌入 SwiftUI 的 NSTextView |
處理相關文字命令並協調模型變更 |
Apple 將 TextEditor 描述為多行、可捲動的編輯器。其文件並未確立與 TextField 相同的 Return 即提交契約。因此,若將範例改為 TextEditor,就必須重新檢視命令處理方式;只加上相同的修飾子,並非文件所述的等效做法。Apple:TextEditor、Apple:onSubmit(of:_:)。
讓列的識別與列的位置各自獨立
三個值各有不同用途:列的 ID 用來識別項目,陣列索引決定插入位置,而焦點 ID 用來識別應接收鍵盤輸入的欄位。
Apple 的 ForEach 會從具識別性的資料建立檢視。請使用 Identifiable 模型,並在建立列時儲存其 ID。在此設計中,同一個 ID 也會成為列的焦點值。這種關聯讓項目在其他項目插入其上方時仍能保有原本的識別。Apple:ForEach。
避免使用可編輯文字作為 ID:兩個空白列會有相同識別,而且編輯文字也會改變識別。對於這種插入工作,陣列位置也不適合作為識別,因為位置會改變。這些是所提議資料模型的結果,並非額外的 SwiftUI 要求。
每個欄位都需要不同的焦點值。Apple 警告,將相同焦點值綁定到多個檢視會造成焦點繫結不明確。每列儲存一個 UUID,便能簡單區分欄位。Apple:FocusState。
建立完整的 macOS 範例
在 Xcode 中使用 SwiftUI 建立 macOS App 專案,並將部署目標設為 macOS 12 或更新版本。以以下單一檔案取代產生的 App 進入點和內容檢視,並移除原本的 App 宣告,確保目標中只有一個 @main 進入點。
範例在捲動檢視中使用一般的 VStack。它會將新列記錄為待處理的焦點目標,接著在該欄位出現時要求聚焦。這是根據 Apple 的焦點與外觀 API 推導出的實作策略;Apple 並未將外觀回呼文件化為通用的焦點就緒保證。onAppear 的確切時機取決於檢視。Apple:onAppear(perform:)。
import SwiftUI
import Foundation
import Dispatch
private struct EditableRow: Identifiable {
let id = UUID()
var text: String
}
@main
struct RowEntryDemoApp: App {
var body: some Scene {
WindowGroup {
RowEditor()
}
}
}
private struct RowEditor: View {
@State private var rows = [
EditableRow(text: "Item A"),
EditableRow(text: "Item B")
]
@State private var pendingFocusID: UUID?
@FocusState private var focusedRowID: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Local row editor")
.font(.headline)
Text("Press Return in a field to insert a row below it.")
.font(.subheadline)
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 10) {
ForEach($rows) { $row in
TextField("New item", text: $row.text)
.textFieldStyle(.roundedBorder)
.focused($focusedRowID, equals: row.id)
.onSubmit {
insertRow(after: row.id)
}
.id(row.id)
.onAppear {
focusIfPending(row.id) {
proxy.scrollTo(row.id, anchor: .center)
}
}
}
}
.padding(4)
}
}
}
.padding(20)
.frame(minWidth: 420, minHeight: 280)
}
private func insertRow(after id: UUID) {
guard pendingFocusID == nil,
let index = rows.firstIndex(where: { $0.id == id })
else { return }
let newRow = EditableRow(text: "")
pendingFocusID = newRow.id
rows.insert(newRow, at: index + 1)
}
private func focusIfPending(
_ id: UUID,
scroll: @escaping () -> Void
) {
guard pendingFocusID == id else { return }
DispatchQueue.main.async {
guard pendingFocusID == id,
rows.contains(where: { $0.id == id })
else { return }
scroll()
focusedRowID = id
pendingFocusID = nil
}
}
}
此模型包含所有範例資料,檔案也定義了所有參照的型別和函式。研究環境中沒有 Swift 編譯器和 macOS 執行階段,因此此程式經過檢視,但未在此處編譯或執行。
插入與聚焦如何配合
使用者編輯時,欄位的字串繫結會更新對應列。因此,提交處理常式可以插入新列,而不會取代或清除目前項目。Apple 文件說明,字串繫結的文字欄位會持續更新。Apple:TextField。
插入處理常式會依 ID 找出提交的列,並在 index + 1 處插入。若是中間列,新項目會置於下一個既有項目之前。若是最後一列,位置會等於陣列的 endIndex,這是有效的插入位置,因此會將元素附加到陣列末端。Apple:insert(_:at:)。
新欄位的出現回呼會檢查其 ID 是否符合待處理的要求。派送的區塊會在捲動和設定焦點前再次檢查。這些檢查可避免不相關的出現回呼取用該要求。當焦點要求仍待處理時,範例也會忽略後續提交。
額外的派送會讓工作延後至外觀回呼之後執行;它並非文件所述的呈現屏障。請將這項排程選擇視為需要在支援的 macOS 版本和實際容器階層中驗證的事項。
捲動和聚焦是兩個獨立操作。scrollTo 會依檢視識別碼找出子檢視並將其捲動至可見位置;指定 focusedRowID 則會要求鍵盤焦點。明確的 .id(row.id) 會提供捲動目標。Apple:scrollTo(_:anchor:)。
檢查結果並診斷常見問題
使用範例項目進行以下驗收:
- 點擊
Item A,輸入更多文字,然後按一次Return。 - 確認兩個原有項目之間出現一個空白欄位。
- 立即輸入
Item C,確認它出現在新欄位中。 - 在最後一個欄位按下
Return,確認新列出現在末端。 - 在空白欄位按下
Return,確認可以再新增一個空白列。 - 繼續新增,直到需要捲動,然後確認插入的欄位會變成可見並可接受輸入。
這些是預期在本機驗證的結果。範例刻意允許空白項目,並保留目前字串的全部內容,即使插入點位於字串中間也一樣。如果工作要求將字串拆成兩筆項目,還需要處理選取範圍和文字區間。
如果列已出現,但輸入仍留在舊欄位,請檢查焦點 ID、新欄位的出現回呼,以及視窗是否處於作用中狀態。也請確認是否有其他回呼重設焦點。計算檢視時,避免重新產生列的 ID。
如果插入發生兩次,請檢查檢視階層中更上層的提交處理常式。Apple 允許在個別控制項和容器檢視上使用 onSubmit,並說明 submitScope 可用來阻止提交向上傳遞至更高層的處理常式。請讓插入動作由單一處理常式負責。Apple:onSubmit(of:_:)。
變更提交標籤並不會實作導覽或插入。Apple 將 submitLabel 定義為設定語意上的提交標籤。模型變更和焦點指定仍需自行撰寫程式碼。Apple:submitLabel(_:)。
若集合規模大得多,請重新評估立即建立所有子項目的堆疊。Apple 指出,List 和 LazyVStack 等容器會以延遲方式查詢 ForEach 元素。因此,畫面外的欄位可能需要先捲動至畫面中,其出現回呼才能完成焦點要求。未測試這段流程前,不要更換容器。Apple:ForEach。
何時應改用 AppKit 和 TextKit?
短小且彼此獨立的項目適合使用 SwiftUI 範例。若列中含有多行文字,或需要精確處理文字命令,一種可行方案是透過 NSViewRepresentable 嵌入自己的 NSTextView。Apple 文件說明,Representable 是在 SwiftUI 中建立和更新 AppKit 檢視的橋接方式,並可透過協調器轉送委派互動。Apple:NSViewRepresentable。
使用這個方案時,保留相同的列 ID 模型,並實作以下流程:
- 建立文字檢視,並將協調器指定為其委派。
- 將文字變更轉送至對應列的模型。
- 實作
textView(_:doCommandBy:),並依編輯器所選擇的行為識別insertNewline(_:)命令。 - 若將該命令處理為插入列,請保留目前文字、插入新列,並回傳
true以取用該命令。若委派不處理該命令,則回傳false。 - 新文字檢視附加至其所屬視窗後,透過該視窗的
makeFirstResponder(_:)方法要求聚焦。
Apple 文件說明了換行命令和委派的布林值契約:true 表示委派已處理該命令,而 false 則讓文字檢視嘗試處理。這為 App 自行管理的編輯器提供明確的命令處理路徑。Apple:insertNewline(_:)、Apple:textView(_:doCommandBy:)。
新增自訂命令處理時,請保留作用中的文字組字。AppKit 提供 hasMarkedText() 以回報標記文字;決定委派是否應將命令交由文字系統處理時,請使用此狀態。請在完成的編輯器中測試文字組字,不要假設所有與 Return 相關的互動都應插入列。Apple:hasMarkedText()。
關於焦點,Apple 明確指示呼叫端使用所屬視窗的 makeFirstResponder(_:),而不要直接呼叫文字檢視的 becomeFirstResponder()。TextKit 版面配置存取也有依版本而異的影響:在 macOS 12 及更新版本中,明確存取文字檢視的 layoutManager 會使其切換至使用 NSLayoutManager 的相容模式。列插入本身不需要這項存取。Apple:NSTextView。
對於單行編輯工作,請先採用完整的 SwiftUI 範例,並在 Mac App 中驗證插入、可見性和輸入流程。穩定的識別、明確的插入位置和指定目標的焦點要求,是這項行為的核心;當編輯工作需要命令和選取控制時,再改用 AppKit 文字檢視。
來源
- onSubmit(of:_:) — Apple
- FocusState — Apple
- TextField — Apple
- TextEditor — Apple
- ForEach — Apple
- onAppear(perform:) — Apple
- insert(_:at:) — Apple
- scrollTo(_:anchor:) — Apple
- submitLabel(_:) — Apple
- NSViewRepresentable — Apple
- insertNewline(_:) — Apple
- textView(_:doCommandBy:) — Apple
- hasMarkedText() — Apple
- NSTextView — Apple