使用 simultaneousGesture(_:including:) 附加父視圖的拖曳,使其能與子視圖互動共同參與。保留子按鈕的點擊動作,給拖曳設定移動門檻,並明確決定選取是否可伴隨拖曳發生。

本演示建構一個包含三個合成項目按鈕的可移動面板。讀者任務具體:點擊項目即選取它,而拖曳面板則改變其在同一 Mac App 中的位置。此範例允許選取與移動共存;另提供獨立控制點變體,在需要明確互動區域時使用。

在更改手勢優先順序前先確定互動契約

Apple 文件說明 simultaneousGesture(_:including:) 會在與現有手勢相同優先順序新增手勢。其範例顯示子視圖點擊處理器與外層容器的點擊處理器皆會對點擊做出回應。當父視圖應參與但不刻意搶佔子視圖優先權時,此為相關的組合機制。 Apple 同時手勢文件

然而,同時識別本身並不表達「拖曳必須取消所有子視圖動作」這條規則。Apple 的範例也未證明每種按鈕樣式與每個容器都以相同方式仲裁按鈕與拖曳。請將手勢參與與應用程式行為視為兩個獨立決策。

對此面板,父視圖擁有面板偏移與選取項目。每個子視圖擁有一個按鈕,透過閉包請求選取。即使同一指標互動移動了面板,選取請求仍屬可接受。若您要求的行為禁止移動期間啟動,請改用下方的獨立控制點變體,而非仰賴未文件化的回呼順序。

請對子視圖動作使用 Button。Apple 明確建議在需要按鈕行為(含標準無障礙行為)的互動中使用按鈕。自訂形狀可保留為按鈕標籤;無需變成裸露的點擊手勢。 Apple 手勢文件

建構含子按鈕的完整可移動面板

在 Xcode 建立 macOS SwiftUI App,將部署目標設為 macOS 14 或更新版本,並以下列單一檔案取代產生的 App 與內容視圖。目標中僅保留一個 App 進入點。macOS 14 需求涵蓋此處使用的現代 coordinateSpace(_:) 修飾符。 Apple 座標空間文件

所有項目與狀態皆存在記憶體中。範例移動整個面板(含按鈕)於固定舞台內。不實作項目傳遞或移動應用程式視窗。

import SwiftUI

@main
struct GesturePanelApp: App {
    var body: some Scene {
        WindowGroup {
            PanelDemo()
        }
    }
}

private struct DemoItem: Identifiable {
    let id: Int
    let title: String
    let color: Color
}

private struct ItemTarget: View {
    let item: DemoItem
    let isSelected: Bool
    let select: () -> Void

    var body: some View {
        Button(action: select) {
            HStack(spacing: 10) {
                Circle()
                    .fill(item.color)
                    .frame(width: 18, height: 18)
                Text(item.title)
                Spacer()
                if isSelected {
                    Text("Selected")
                        .font(.caption)
                }
            }
            .padding(12)
            .frame(width: 220)
            .background(
                isSelected
                    ? Color.accentColor.opacity(0.18)
                    : Color.gray.opacity(0.12)
            )
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}

private struct PanelDemo: View {
    private let items = [
        DemoItem(id: 1, title: "Item A", color: .blue),
        DemoItem(id: 2, title: "Item B", color: .orange),
        DemoItem(id: 3, title: "Item C", color: .purple)
    ]

    @State private var selectedID: Int? = nil
    @State private var committedOffset: CGSize = .zero
    @GestureState private var liveTranslation: CGSize = .zero

    private var pan: some Gesture {
        DragGesture(
            minimumDistance: 8,
            coordinateSpace: .named("stage")
        )
        .updating($liveTranslation) { value, translation, _ in
            translation = value.translation
        }
        .onEnded { value in
            committedOffset = CGSize(
                width: committedOffset.width + value.translation.width,
                height: committedOffset.height + value.translation.height
            )
        }
    }

    var body: some View {
        VStack(spacing: 12) {
            Text("Click an item or drag the panel")

            ZStack {
                Color.gray.opacity(0.06)

                VStack(spacing: 10) {
                    Text("Drag panel")
                        .font(.headline)
                        .frame(width: 244, height: 32)
                        .contentShape(Rectangle())

                    ForEach(items) { item in
                        ItemTarget(
                            item: item,
                            isSelected: selectedID == item.id,
                            select: { selectedID = item.id }
                        )
                    }
                }
                .padding(16)
                .background(Color.gray.opacity(0.15))
                .contentShape(Rectangle())
                .simultaneousGesture(pan, including: .all)
                .offset(
                    x: committedOffset.width + liveTranslation.width,
                    y: committedOffset.height + liveTranslation.height
                )
            }
            .frame(width: 640, height: 420)
            .coordinateSpace(.named("stage"))

            Button("Reset panel") {
                committedOffset = .zero
            }
        }
        .padding(20)
    }
}

此程式已對照檢索的 API 文件審查,但未於此環境編譯或互動測試:此環境無 Swift 編譯器或 macOS UI 執行環境。請在 Xcode 建置並在您支援的 macOS 版本上執行下列檢查。

為何拖曳設有非零門檻

DragGesture 公開手勢成功前的最小移動距離。示範值 8 給予普通點擊一些移動容差,讓父視圖才開始拖曳。這是應用程式的選擇,非 Apple 規定的門檻,也非完整的點擊-對-拖曳策略。零距離拖曳會移除該特定區分。 Apple DragGesture 文件

為何暫態與已認可移動分開

識別期間,updating(_:body:) 將目前位移寫入 liveTranslation。Apple 文件說明此手勢狀態在手勢結束或取消時重設。範例僅在 onEnded 認可最終偏移,故取消會捨棄未認可的預覽,而非留下過時的暫態位移。 Apple updating 文件

顯示位置為已認可偏移加上目前位移。Apple 將 translation 定義為拖曳開始起的總位移,若在每次變更時反覆將其加到已認可偏移,會重複計算移動。舉例,已認可水平偏移 40 與目前位移 12 產生顯示偏移 52;完成該拖曳一次認可 52。 Apple translation 文件

命名座標空間屬於圍繞移動面板的固定舞台。這使移動計算參照明確的參考區域。子按鈕不自行維護面板位置副本。

為何選取保留在父視圖

父視圖儲存 selectedID 並提供每個子視圖其目前選取狀態與選取閉包。這遵循 Apple 指引:將狀態置於需要它的最高層視圖,再向下傳遞值或綁定。 Apple State 文件

閉包捕獲目前項目並請求更新父視圖狀態。Swift 支援閉包捕獲周圍上下文的值;無需複製或轉發 AppKit 事件即可實作該回呼。 The Swift Programming Language: Closures

理解點擊區域與手勢遮罩

contentShape(Rectangle()) 定義點擊測試形狀。在按鈕標籤中,它跟隨內距與框架,使預期的矩形標籤區域明確。在父視圖上,它定義面板的互動形狀。它不設定辨識器優先順序,也不要求兩個手勢合作。 Apple contentShape 文件

提供給手勢修飾符的遮罩決定哪些手勢保持啟用。這些選項易誤讀:subviews 代表新增的父手勢被停用,而非自動與子視圖合作。 Apple GestureMask 文件

遮罩 文件說明效果 此處適用性
.all 啟用新增手勢與視圖及其子視圖上的其他手勢 共同參與的起點
.gesture 啟用新增手勢並停用子視圖層級的手勢 與保留子手勢衝突
.subviews 啟用子視圖手勢並停用新增手勢 停用父拖曳
.none 停用子視圖層級的手勢(含新增手勢) 停用預期互動

避免將 highPriorityGesture 視為缺少父拖曳回呼的通用修補。其文件用途是給予附加手勢更高優先權。Apple 範例示範父視圖搶佔子視圖點擊。當該優先權為預期行為時選用它。 Apple 高優先權手勢文件

當啟動必須保持獨立時使用獨立拖曳控制點

對於按鈕不應有共享拖曳區域的面板,保留相同狀態與移動計算,但僅將 pan 附加到標頭。從父面板移除 .simultaneousGesture(pan, including: .all)。

將現有標頭運算式替換為上方完整程式內的此片段。它使用已定義的 pan 屬性;無其他先決條件變更。

Text("Drag panel")
    .font(.headline)
    .frame(width: 244, height: 32)
    .contentShape(Rectangle())
    .gesture(pan)

標頭與項目按鈕現佔據不同區域,按鈕不再有面板拖曳附加到祖先。從項目開始屬按鈕互動;從標頭開始屬移動面板路徑。當共享參會導致結果模糊時,此設計很有用。

子視圖發起動作時保留原生按鈕。Apple 將 Button 描述為動作加標籤,並允許自訂標籤視圖,合成彩色項目即使用此方式。改用手寫指標處理器將要求您重建控制項已提供的行為。 Apple Button 文件

在框架邊界保留 AppKit 事件擁有權

若面板包含 AppKit 視圖,請將其點擊測試與 SwiftUI 手勢組合分開診斷。AppKit 的 hitTest(_:) 回傳最深匹配的子項,協助視窗決定滑鼠按下事件的接收者。其輸入點以視圖超視圖座標表示。 Apple AppKit 點擊測試文件

從點擊測試回傳父視圖會改變初始目標;這不等同於讓子視圖與父視圖同時識別。同理,從覆蓋層點擊測試回傳 nil 會將該覆蓋層移出考慮——不會使其成為子視圖完整互動的被動觀察者。

Apple 文件說明預設 mouseDown(with:) 實作會將訊息傳遞給下一回應者。這是回應鏈行為,非承諾每個祖先在控制項處理事件後都獨立收到副本。請透過您擁有的明確回呼或辨識器,將控制項啟動與面板移動連結。 Apple mouseDown 文件

當您擁有兩個 AppKit 手勢辨識器且需要同時識別時,AppKit 提供 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)。Apple 文件說明回傳 true 允許配對同時識別;回傳 false 不保證排除(若另一辨識器的代理人允許)。將該策略限制在預期的辨識器配對。 Apple 辨識器代理人文件

以小型行為矩陣驗證互動

測試您的 App 支援的確切按鈕樣式、層級與 macOS 版本。下列檢查將辨識問題與移動算術分開,並釐清共享表面是否符合您的預期契約。

動作 要檢查的項目
不拖曳地點擊各項目 其選取狀態改變;面板保持靜止
拖曳標頭超過門檻 面板跟隨指標並保留最終位置
在共享版本從項目拖曳 父視圖移動運作;判斷按鈕啟動是否也發生並確認可接受
連續進行兩次拖曳 第二次從第一次已認可位置開始,不累積每個中間位移
中斷進行中的拖曳 暫態位移清除;無未完成移動被認可
嘗試控制點變體 項目獨立啟動;移動從標頭開始
透過鍵盤導航啟動項目按鈕 選取運作,無需指標拖曳

若空白面板區域拖曳正確但項目區域不正確,檢查子控制項與手勢組合。若點擊消失,檢查手勢遮罩、優先權與點擊區域。若面板加速或跳動,在新增更多辨識器前,檢查位移累積與選定座標空間。

範例刻意讓移動無界限,故面板可移出可見舞台;重設面板 按鈕可恢復位置。對於成品有界工作區,請基於舞台與面板尺寸新增明確位置限制。保持相同擁有結構:子視圖請求動作,父視圖擁有移動,互動契約決定其辨識區域是否重疊。

來源