若要在編輯 SwiftUI 清單時讓插入游標保持穩定,請為每個項目指定不可變的 ID、直接繫結至其文字,並保留編輯器在檢視階層中的位置。在 macOS 15 或更新版本中,若你的 App 還需要控制插入點,請使用選取範圍繫結。

本指南會建構一個小型 Mac App,用來編輯「Blue tile」這類合成標籤。目標是在文字和周圍清單更新時,仍能在同一個項目中繼續輸入。本文也會說明,何時需要額外狀態或 AppKit 編輯器才能還原選取範圍。

為什麼清單項目變更時插入游標會移動?

先確認項目的識別是否會隨文字變更。SwiftUI 會使用識別來連結前後的檢視值,而持續存在的檢視狀態會隸屬於該識別。重新計算檢視的 body 很正常;替換其識別則會結束它的生命週期。Apple 在剖析 SwiftUI中說明了這種關係。

對於可編輯的集合,請避免以可編輯的標籤識別資料列。使用 id: \.self 時,字串一旦變更,其識別也會跟著改變。若模型的雜湊值包含標籤,也可能發生相同問題。以陣列位置識別可移動的項目,則會把識別綁定至位置,而非邏輯上的項目。

請改用儲存於項目中的識別碼,並讓它在編輯後仍保持不變。Apple 的 ForEach 文件支援可識別元素的集合,或明確指定識別碼索引鍵路徑。對這個編輯器而言,實用的選擇是屬於該項目的已儲存 UUID。

也請從編輯器及其祖先檢視移除 .id(item.text) 或 .id(UUID()) 等修飾子。Apple 明確記載,變更傳遞給 id(_:) 的值會重設檢視識別,包括其狀態。新增穩定的資料列 ID,無法補救每次編輯都會改變識別的祖先檢視。

分別處理識別、焦點與選取範圍

這三種狀態分別回答不同問題。識別決定更新前後哪個邏輯資料列會延續。焦點決定哪個控制項會接收鍵盤輸入。選取範圍則決定該控制項內的插入點或選取文字。

FocusState 讓 App 能觀察和變更焦點。請為每個欄位指定不同的焦點值,例如項目 ID。若每個資料列都繫結至同一個布林值,就無法辨識應該將程式化焦點請求傳給哪個個別項目。

TextSelection 可表示 macOS 上的插入點、選取範圍或多重選取。從 macOS 15 開始,可使用含選取範圍繫結的 TextField 初始化器。

以下實作遵循上述文件所述的職責分工:模型負責識別與文字,清單負責目前聚焦項目的 ID,每個存續中的資料列則負責自己的選取範圍。此設計適用於資料列仍存在時不中斷編輯;它不保證在移除並重建資料列後,能還原所有編輯細節。

建立具有穩定項目識別的清單

在 Xcode 中建立 macOS SwiftUI App,並將部署目標設為 macOS 15 或更新版本。請使用包含選取範圍繫結初始化器的 SDK。以以下完整範例取代產生的 App 和內容檢視宣告,並確保目標中只保留一個 @main 宣告。

import Foundation
import SwiftUI

struct LabelItem: Identifiable {
    let id: UUID
    var text: String

    init(id: UUID = UUID(), text: String) {
        self.id = id
        self.text = text
    }
}

@main
struct CaretListApp: App {
    var body: some Scene {
        WindowGroup {
            LabelListView()
        }
    }
}

struct LabelListView: View {
    @State private var items = [
        LabelItem(text: "Blue tile"),
        LabelItem(text: "Round pebble"),
        LabelItem(text: "Paper square")
    ]
    @FocusState private var focusedID: UUID?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Button("Add label") {
                    items.append(LabelItem(text: "New label"))
                }
                .keyboardShortcut("n", modifiers: [.command, .shift])

                Button("Reverse rows") {
                    items.reverse()
                }
                .keyboardShortcut("r", modifiers: [.command, .shift])
            }

            List {
                ForEach($items) { $item in
                    LabelRow(item: $item, focus: $focusedID)
                }
            }
        }
        .padding()
        .frame(minWidth: 420, minHeight: 260)
    }
}

struct LabelRow: View {
    @Binding var item: LabelItem
    let focus: FocusState<UUID?>.Binding
    @State private var selection: TextSelection? = nil

    var body: some View {
        TextField("Label", text: $item.text, selection: $selection)
            .textFieldStyle(.roundedBorder)
            .focused(focus, equals: item.id)
    }
}

已儲存的 id 會在建立項目時產生。變更 text 或反轉現有陣列,都會保留該 ID。以繫結為基礎的 ForEach 會將可編輯的模型值提供給每個資料列,而欄位則會直接將文字寫入該項目。

以字串繫結的 TextField 會在編輯期間持續更新其繫結。因此,此範例不需要額外的變更處理常式,就能把每次按鍵輸入複製到第二個陣列或重建清單。

資料列中的編輯器位置始終一致。沒有依文字而變的識別修飾子、選取範圍自動重設,也沒有在每次更新時提出焦點請求。選取範圍繫結讓欄位能傳達目前的選取範圍,而不必由 App 程式碼反覆覆寫它。

此範例已根據取得的 API 宣告進行檢視。由於此環境沒有 Swift 編譯器或 macOS 執行環境,因此未在此處編譯或執行。

更新模型時保留 ID

更新模型時,必須保留仍代表相同標籤之項目的識別碼。若為每個現有項目重新建立 LabelItem(text:),即使所有可見文字都未變更,也會產生新的 ID。

從 App 自己儲存的記錄重建集合時,請將每筆記錄原有的 ID 帶入新的模型值。只有建立真正的新項目時,才產生新的 ID。若記錄沒有持續存在的識別碼,請在建立項目時新增一個,而不是從可編輯的文字推導。

除非希望項目立即移動,否則請避免在每次按鍵輸入後依標籤排序。穩定識別能讓 SwiftUI 知道哪個項目移動了,但有人輸入時資料列若改變位置,仍會影響編輯體驗。明確的排序動作能讓介面呈現更清楚的轉換。

依照實用順序診斷插入游標仍然跳動的問題

以下判斷輔助內容是根據文件中識別、焦點與選取範圍的區分整理而成,可在新增還原程式碼前協助縮小問題範圍。

觀察到的行為 優先檢查 適當的下一步
每輸入一個字元,編輯器就重設 從文字衍生的 ID;變動中的 .id(...) 值 保持項目 ID 與祖先檢視識別穩定
鍵盤輸入不再傳到欄位 焦點變更或編輯器遭移除 檢查 focusedID 和條件式檢視分支
焦點仍在欄位中,但插入游標移動 App 對文字或選取範圍進行寫入 移除無條件的覆寫和選取範圍指定
重新排序後,編輯狀態跟著某個位置移動 以索引為基礎的識別 使用已儲存的 ID 識別邏輯上的項目
篩選掉資料列再顯示時,選取範圍消失 資料列區域狀態的生命週期 將還原資訊存放在可移除資料列之上的位置

先在不排序、不篩選、不格式化、不更新模型的情況下重現問題。在 Blue tile 的中間編輯文字。如果這樣正常,再一次重新加入一項操作。如此可找出與跳動相關的變更。

接著檢查結構性變更。若在含有不同編輯器的兩個條件式分支間切換,可能會替換編輯器的識別。Apple 的識別與生命週期說明指出,若只需變更外觀,應讓單一編輯器持續存在。

最後檢查對文字繫結的寫入。編輯期間會修剪、轉成大寫或以其他方式替換字串的處理常式,會引入第二個編輯動作。對於標籤清理,實用的做法是在輸入期間保留草稿不變,並在提交時才套用轉換。

決定移除資料列後應保留什麼

若資料列仍存續,區域性的選取範圍狀態就足以使用。但當介面移除該資料列時,它不適合作為唯一的還原資料來源。Apple 的 macOS 15 版本說明也指出,ForEach 可以回收屬於未使用子檢視的持續性狀態。

如果返回某個項目時必須還原其編輯位置,請將還原資訊存放在可移除資料列之上的擁有者中,並以項目 ID 為索引。將資訊與其描述的文字版本關聯。項目永久刪除時,請移除該資訊。

還原焦點與還原選取範圍是兩個不同操作。如果項目已不存在,請勿將其已儲存的插入游標轉移到現在佔據相同陣列位置的項目。

以明確的插入游標策略處理程式化文字變更

穩定識別無法決定 App 替換文字後插入游標應移至何處。這需要依據操作訂定規則:保留其邏輯位置、選取替換後的文字,或移至末尾。

SwiftUI 的 TextSelection 初始化器可接受基於 String.Index 的插入點或範圍。App 變更字串時,請根據變更後的字串建立新的選取範圍。不要把舊的選取範圍視為永久的數字書籤。

Swift 字串使用 String.Index,而其字元數可能不同於 NSString 使用的 UTF-16 長度。這些差異記載於字串與字元。在 SwiftUI 選取範圍與 Cocoa 文字範圍之間轉換時,這些差異很重要。

舉例來說,假設文字是 Blue tile,插入游標位於 Blue 後面。若在開頭插入 Soft ,而目標是保留插入游標相對於原始文字的位置,就應將插入游標向前移動五個位置。若保持數字偏移量不變,插入游標就會落在標籤的不同部分。

此例使用簡單的 ASCII 文字。對一般字串,請明確定義偏移量單位,並驗證結果邊界。若替換內容與選取文字重疊,也需要明確決定結果;單純將偏移量限制在新字串長度內,無法確定預期的選取範圍。

何時應使用 AppKit 或 TextKit?

若要支援較早版本的 macOS,macOS 15 的 SwiftUI 選取範圍繫結初始化器便無法使用。你仍可在純 SwiftUI 欄位中修正不穩定的識別。若 App 還需要明確控制選取範圍,一種受支援的整合方式是使用包裝在 NSViewRepresentable 中的 AppKit 編輯器。

Apple 將 NSTextView 說明為封裝了 TextKit 功能的編輯檢視。若只是要保留插入游標,無須建置自訂版面引擎。當你需要直接控制編輯狀態時,才選用專用的文字檢視。

包裝器應遵循以下更新順序:

  1. 在 makeNSView(context:) 中建立 AppKit 編輯器,並透過協調器連接其委派物件。
  2. 將使用者編輯內容傳回繫結模型,並將選取範圍變更傳至與項目 ID 關聯的狀態。
  3. 在 updateNSView 中,先比較模型文字與編輯器目前的文字,再決定是否指定任何值。
  4. 若確實要以程式替換文字,請根據替換後的文字計算選取範圍,並套用一次。
  5. 停止委派物件回報由該 App 更新造成的變更,並在套用延後的轉換前處理標記文字組字。

這些是橋接層的實作規則,並非另一個完整程式碼範例。Apple 提供 selectedRanges 來讀取和寫入文字檢視的選取範圍,並提供 textViewDidChangeSelection(_:) 來接收選取範圍變更。若編輯器支援多個範圍,請保留它們。

若要配合組字處理更新,hasMarkedText() 可回報是否存在標記文字。實用的做法是在組字期間延後非必要的 App 轉換,待組字結束後再套用。

AppKit 的 NSTextField 還有另一項生命週期考量:Apple 記載,簡易控制項會共用其視窗的欄位編輯器。請將已儲存的選取範圍與項目和目前編輯工作階段關聯,而不要把該共用編輯器視為每個資料列永久專用的物件。

請讓包裝器遵守文件所述的整合界線。SwiftUI 會控制被包裝檢視的版面配置,而且 Apple 提醒不要直接指定其管理的框架或邊界。也請避免假設 SwiftUI 內部如何實作清單:macOS 15 版本說明指出,非大綱清單已不再使用 NSTableView。

在自己的視窗中確認編輯結果

使用範例標籤分別檢查各種行為。以下是確認步驟,並非已回報的測試結果。

  1. 將插入游標放在 Blue 和其後的空格之間。輸入 small。確認輸入會在預期的插入點繼續。
  2. 選取 tile,並以 square 取代。確認只有選取的文字受到影響。
  3. 編輯時,使用 Command-Shift-N 執行 Add label。分別檢查作用中的項目、焦點與插入游標。
  4. 使用 Command-Shift-R 執行 Reverse rows。確認編輯後的值仍隸屬於原本的項目 ID,接著檢查焦點和選取範圍。
  5. 在實際介面中,依照所選的還原策略,測試資料列移除、重新顯示及程式化文字替換。

按一下按鈕可能會變更焦點,因此請分辨互動引起的焦點轉換,以及模型更新引起的選取範圍重設。請測試 App 提供的實際控制項和鍵盤指令。

先使用穩定的 ID 和直接的文字繫結。只有在特定轉換需要時才新增明確的還原機制,並為程式化替換訂定選取範圍策略。如此能讓一般標籤編輯保持簡單,同時明確說明移除資料列和替換文字時的限制。

來源