對於 Mac 項目清單,請將選取狀態繫結至 SwiftUI List,並透過會感知選取狀態的情境選單提供主要動作。單擊會選取資料列;雙擊會開啟它。請將選取狀態與呈現狀態分開。

本教學會在你自己的 macOS App 中,使用三個合成的記憶體內項目實作這種互動方式。也會說明自訂 SwiftUI 表面與 AppKit 檢視的適用範圍,讓你選擇做法時,不會讓多個層級爭相處理同一次點擊。

使用可選取容器的主要動作

Apple 明確記錄了 macOS 可選取容器的單擊選取與雙擊主要動作。SwiftUI 支援的進入點是 contextMenu(forSelectionType:menu:primaryAction:),從 macOS 13 起提供。將它附加至支援選取的容器,例如 List 或 Table。Apple 關於會感知選取狀態的情境選單文件。

這裡有三項彼此獨立的職責:模型用來識別每個項目,清單透過繫結維護選取狀態,而主要動作處理常式則決定開啟的含義。在這個範例中,開啟會顯示項目詳細資料工作表。選取項目只會變更反白的資料列與選取狀態標籤。

請依據負責處理互動的檢視選擇實作方式:

你的介面 合適的起點 你仍須負責的事項
macOS 13 或更新版本上的 SwiftUI 項目清單或表格 選取繫結加上會感知選取狀態的主要動作 解析動作 ID 並呈現對應內容
自訂 SwiftUI 磁貼或形狀 明確組合手勢 選取外觀、啟用策略及其他輸入路徑
嵌入 SwiftUI 的 AppKit 表格 NSTableView 選取狀態與 doubleAction 將選取狀態與啟用動作橋接至 SwiftUI 狀態
自訂 AppKit 繪圖表面 自行處理滑鼠事件 命中測試、項目識別與點擊序列策略

第一種方式最符合這裡的需求。若介面確實需要不同的表面,自訂方式就很有用;但你也必須自行實作更多互動行為。Apple 在手勢組合、AppKit 表格雙擊動作與按下滑鼠按鈕的處理文件中說明了底層的替代方案。

建立完整的單選範例

在 Xcode 中建立 macOS App 專案,使用 SwiftUI 介面,並將最低部署目標設為 macOS 13 或更新版本。將產生的 App 與內容檢視宣告替換為以下單一檔案。目標中只保留一個 @main App 宣告。

程式使用固定的整數 ID、三個有顏色的圓形,以及本機狀態。它不會讀取檔案,也不會開啟外部資源。工具列樣式的 Open 按鈕和情境式 Open 指令,會與雙擊啟用動作呼叫相同函式。

import SwiftUI

struct SampleItem: Identifiable {
    let id: Int
    let name: String
    let tint: Color
}

@main
struct ClickSelectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ItemBrowser()
        }
    }
}

struct ItemBrowser: View {
    private let items = [
        SampleItem(id: 1, name: "Circle A", tint: .blue),
        SampleItem(id: 2, name: "Circle B", tint: .orange),
        SampleItem(id: 3, name: "Circle C", tint: .purple)
    ]

    @State private var selectedID: SampleItem.ID? = nil
    @State private var openedItem: SampleItem? = nil
    @State private var message = "Select an item."

    private var selectedItem: SampleItem? {
        items.first { $0.id == selectedID }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text(selectedItem.map {
                    "Selected: \($0.name)"
                } ?? "No selection")
                Spacer()
                Button("Open") {
                    if let id = selectedID {
                        openItems([id])
                    }
                }
                .disabled(selectedItem == nil)
            }

            List(items, selection: $selectedID) { item in
                HStack(spacing: 10) {
                    Circle()
                        .fill(item.tint)
                        .frame(width: 18, height: 18)
                    Text(item.name)
                }
                .padding(.vertical, 4)
            }
            .contextMenu(forSelectionType: SampleItem.ID.self) { ids in
                if ids.count == 1 {
                    Button("Open") {
                        openItems(ids)
                    }
                }
            } primaryAction: { ids in
                openItems(ids)
            }

            Text(message)
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 360, minHeight: 280)
        .sheet(item: $openedItem) { item in
            VStack(spacing: 16) {
                Circle()
                    .fill(item.tint)
                    .frame(width: 80, height: 80)
                Text(item.name)
                    .font(.title2)
                Button("Close") {
                    openedItem = nil
                }
            }
            .padding(32)
            .frame(minWidth: 260, minHeight: 220)
        }
    }

    private func openItems(_ ids: Set<SampleItem.ID>) {
        guard ids.count == 1, let id = ids.first else {
            message = "Choose exactly one item to open."
            return
        }
        guard let item = items.first(where: { $0.id == id }) else {
            message = "That item is no longer available."
            return
        }
        openedItem = item
        message = "Opened: \(item.name)"
    }
}

我已檢查這個範例中的宣告與參照,但此處尚未編譯或執行:目前環境沒有 Swift 工具鏈或 macOS 圖形介面。請在 Xcode 中建置,並執行下方的互動檢查。

將識別、選取與開啟分開處理

SampleItem 遵循 Swift 的 Identifiable 協定。其固定的 id 代表同一個項目在檢視更新前後保持一致。Apple 將此協定定義為依據穩定識別值識別項目;顯示名稱則是獨立屬性。在較大型的 App 中,項目名稱或外觀變更時,請保留其 ID。Apple 的 Identifiable 文件。

選用的 selectedID 會建立單選清單。若改用 ID 集合,就能啟用多選。SwiftUI 的清單文件說明了這兩種繫結形式,以及容器提供的選取外觀。Apple 的 List 文件。

openedItem 獨立控制呈現狀態。將它設為某個項目時會呈現工作表;設為 nil 時則會關閉工作表。這符合文件中以項目驅動的工作表 API。由於選取資料列時不會指定 openedItem,因此在此實作中,單靠選取不會觸發工作表。Apple 關於以項目驅動工作表的文件。

解析傳入動作的 ID

情境選單 API 接受容器的識別碼型別,而其動作會收到一組 ID。因此,範例使用 SampleItem.ID.self,與清單的選取狀態相符。它會解析回呼傳入的引數,而不是以另外讀取的選取值取代該引數。Apple 關於情境選單參數的文件。

這個處理常式刻意只接受一個 ID。空集合會顯示一則訊息;無法解析的 ID 會顯示另一則訊息。如果之後啟用多選,請先定義開啟策略,再修改這項檢查:呈現群組詳細資料檢視、開啟個別視窗,或要求只能選取一個項目。任意取用第一個成員會掩蓋尚未定義的策略。

為何兩個點擊處理常式需要不同的設計決策

自訂磁貼可能需要自己的手勢。SwiftUI 的 onTapGesture(count:perform:) 可指定所需的點擊或輕點次數。然而,文件並未說明在檢視上放置兩個獨立修飾器,就能建立互斥的單擊與雙擊契約。Apple 關於輕點動作的文件。

先決定雙擊期間選取的意義。對一般項目瀏覽器而言,在開啟項目之前先選取它很實用,也與容器式方案一致。若有另一項需求,規定只有在點擊序列最後沒有變成雙擊時才執行單擊動作,就需要仲裁。

對自訂表面,SwiftUI 提供 exclusively(before:):只有一個組成手勢會成功,並優先採用第一個。以下是手勢運算式片段,並非清單範例的完整替代方案:Apple 關於互斥手勢的文件。

TapGesture(count: 2)
    .exclusively(before: TapGesture(count: 1))

這會將雙擊辨識放在優先位置。將組合後的手勢附加至自訂項目,並依據選取與開啟策略處理結果。TapGesture 文件說明了如何依所需的輕點次數進行辨識。Apple 的 TapGesture 文件。

這項策略會帶來時間上的影響:若要判定某個序列是純粹的單擊,就必須先確認它不會成為雙擊。這是互動需求造成的結果,並非文件所規定的固定 SwiftUI 延遲。若同時要求單擊動作立即完成,且不得在雙擊序列期間發生,就不要承諾能滿足這兩項要求。

對自訂磁貼,也要定義預期可點擊的區域。contentShape(Rectangle()) 可以讓有內距的矩形區域參與命中測試,但不會設定手勢優先順序。診斷點擊未觸發的問題時,請將這兩件事分開處理。Apple 關於內容形狀的文件。

使用 AppKit 時,維持事件處理權的明確歸屬

如果既有的 AppKit 表格負責管理資料列,就使用該表格支援的啟用機制。NSTableView.doubleAction 會在雙擊時對目標傳送選擇子。Apple 也記錄了 clickedRow 與 clickedColumn,可用來判斷啟用發生的位置,包括欄標題。開啟項目之前,請確認該位置確實對應到某個項目。Apple 的 doubleAction 文件。

對自訂 AppKit 表面,mouseDown(with:) 會接收滑鼠左鍵按下事件,而 NSEvent.clickCount 會回報與滑鼠按下或放開事件相關的點擊次數。在無關的事件上讀取該屬性,不符合其文件所載的契約,且可能引發例外。請只在處理適當滑鼠事件的檢視中解讀這個值。Apple 關於按下滑鼠按鈕的文件、Apple 關於點擊次數的文件。

使用 NSViewRepresentable 將自行管理的 AppKit 檢視嵌入 SwiftUI。在 makeNSView 中建立檢視,在 updateNSView 中同步目前設定;若目標動作或委派通訊需要橋接,請使用協調器。Apple 將建立、更新、協調與拆除都列為此整合的一部分。Apple 的 NSViewRepresentable 文件。

會接收滑鼠事件的覆層可能改變處理點擊的檢視。AppKit 會透過命中測試決定接收滑鼠按下事件的對象,因此不應將會攔截事件的覆層視為不可見的觀察者。對於此需求,請在可選取容器或實際負責項目互動的自訂表面中進行辨識。Apple 關於命中測試的文件。

將選取與開啟視為不同結果來驗證

建置範例後,請依序執行以下步驟:

  1. 單擊 Circle A。其資料列應變成選取狀態,選取標籤應顯示它的名稱,且不應出現工作表。
  2. 雙擊 Circle B。確認詳細資料工作表顯示 Circle B。
  3. 關閉工作表,再單擊 Circle C。確認選取狀態會變更,且不會開啟另一個工作表。
  4. 啟用畫面上可見的 Open 按鈕。確認它透過相同處理常式開啟選取的項目。
  5. 呼叫某個資料列的情境式 Open 指令,並確認它開啟的項目正確。
  6. 在清單的未使用空白處單擊或雙擊。確認不會開啟無關項目。
  7. 如果你加入了內嵌控制項、拖曳功能或覆層,請在那些確切區域重複檢查。

如果選取狀態意外開啟詳細資料,請檢查呈現狀態在哪裡被指定。如果啟用動作開啟錯誤的項目,請檢查回呼 ID 與模型查找。如果自訂磁貼的部分區域無法觸發點擊,請先檢查其版面配置與命中測試形狀,再變更手勢優先順序。這些檢查會分別找出狀態、動作目標與事件傳遞問題,避免同時變更三者。

對於一般的 Mac 項目清單,請以選取繫結和容器的主要動作為核心進行實作。只有在表面或互動確實需要時才加入自訂辨識,並確保每條開啟路徑都透過同一個明確的處理常式解析預期項目。

來源