對於具有重疊放置區域的 Mac 畫布,請在一個穩定的祖先視圖上設定原生放置處理,並根據目前的模型狀態解析邏輯上的目的地。提交放置時重新檢查資格;遭拒的回呼並非有文件記載的轉交給其他視圖的機制。

本文將為一款包含合成圖形標籤和兩個重疊面板的 App 建立此互動。具體需求是讓下層面板在上層面板停止接受項目時仍可接收放置,包括在拖移過程中資格狀態改變的情況。以下路由政策是依據有文件記載的回呼所設計的應用程式規則。

為什麼狀態變更無法可靠地將拖移交給另一個目標?

先區分三個問題:哪個原生視圖接收拖移、你的 App 將哪個邏輯面板視為符合資格,以及哪個模型接收最後的項目。狀態變更可以改變你對第二個問題的答案,但不會因此確立第一個問題的新答案。

Apple 將 SwiftUI 的 validateDrop(info:) 說明為相容的放置進入目的地時進行的驗證。dropUpdated(info:) 則是在已驗證的放置於修飾後的視圖內移動時進行的更新。兩者都沒有承諾每當擁有該視圖的狀態發生無關變更時,就會重新搜尋目的地。SwiftUI 驗證與放置更新。

AppKit 的界線說明得更清楚:一次只有一個目的地會收到一連串的 draggingUpdated(_:) 訊息。若兩個重疊視圖都是有效目的地,最上層的視圖會持續收到這些訊息,直到放開影像或將它拖出為止。這描述的是 AppKit 路由,並未確立 SwiftUI 通用的同層視圖優先順序契約。AppKit 目的地更新。

從舊版 SwiftUI 放置動作傳回 false,表示放置未成功。Apple 並未將該傳回值說明為要求重試下層目的地。同樣地,從 dropUpdated(info:) 傳回 nil,會保留最後一個有效提案或預設為複製;這不代表「穿透」。以閉包處理放置與提案行為。

實際做法是將備援明確定義為應用程式規則。對單一畫布而言,一個原生接收器即可在多個邏輯面板中做出選擇,無須依賴重疊子接收器之間的原生轉交。

設定修飾子前,先定義擁有權和備援規則

在此範例中,橘色面板位於藍色面板後方。橘色面板永遠接受範例標籤。只有在 blueEnabled 為 true 時,藍色面板才會接受這些標籤。預期行為如下:

指標位置 藍色面板接受放置 邏輯上的接收對象
僅橘色區域 任一值 橘色面板
重疊區域 是 藍色面板
重疊區域 否 橘色面板
僅藍色區域 否 無
兩個面板之外 任一值 無

此表是實作時的判斷依據。特別是,停用藍色面板不代表其矩形內的每個位置都成為橘色面板的目的地。只有當指標也位於橘色面板的矩形內時,橘色面板才會接收項目。

將路由所依據的狀態放在視覺面板之上。Apple 建議在需要該狀態的最高層視圖放置狀態值,並與子視圖共用。在此範例中,一個父視圖擁有包含資格、幾何資訊和已接收項目的模型。子矩形負責呈現外觀,不各自設定放置處理器。SwiftUI 狀態擁有權。

在放置動作執行時讀取該模型。避免在進入時儲存資格布林值或目的地識別碼,之後又將其視為權威依據。Swift 閉包可以保留建立時情境中的值和參考;若擁有者被替換,保留的參考也可能繼續指向較舊的模型。請確認回呼實際捕捉的是哪個物件。Swift 閉包捕捉。

對於擁有者替換,請另外選定一項政策:要麼依據新的擁有者解析,要麼拒絕屬於先前擁有者的工作。一種實用的拒絕設計是在擁有者上附加版本號,並在替換時遞增;提交延遲工作前,比對捕捉到的版本號。不要用短暫的「目前反白」值來決定擁有權。

建立一個穩定的 SwiftUI 放置表面

以下完整範例需要 macOS SwiftUI App,部署目標為 macOS 26,且 SDK 包含 dropDestination(for:isEnabled:action:)。Apple 說明此多載接受 Transferable 型別、啟用旗標,以及接收解碼項目和 DropSession 的動作。目的地會佔據修飾後視圖的大小和位置。目前的放置目的地 API。

使用 SwiftUI 建立新的 macOS App 專案。以下兩個程式碼區塊請依序放在同一個 Swift 檔案中,取代專案自動產生的 App 宣告。兩者合起來構成一個程式,只使用記憶體中的字串和矩形。放置後,來源標籤仍會保留,因此此範例會以複製方式運作。

模型與確定性路由

模型會先檢查藍色面板,以表達所選的視覺優先順序。繪製和路由都使用相同的矩形。其提交方法會依據目前資格重新計算接收對象,並在附加任何項目之前驗證整批範例項目。

import SwiftUI
import Combine

@MainActor
final class CanvasModel: ObservableObject {
    enum Zone: String {
        case orange = "Orange"
        case blue = "Blue"
    }

    let orangeRect = CGRect(x: 30, y: 30, width: 230, height: 170)
    let blueRect = CGRect(x: 150, y: 100, width: 230, height: 170)
    let samples = ["Circle", "Square"]

    @Published var blueEnabled = true
    @Published private(set) var orangeItems: [String] = []
    @Published private(set) var blueItems: [String] = []
    @Published private(set) var message = "Drag a sample onto a panel."

    func recipient(at point: CGPoint) -> Zone? {
        if blueEnabled && blueRect.contains(point) {
            return .blue
        }
        if orangeRect.contains(point) {
            return .orange
        }
        return nil
    }

    func receive(_ items: [String], at point: CGPoint, isLocal: Bool) {
        guard isLocal,
              !items.isEmpty,
              items.allSatisfy({ samples.contains($0) }) else {
            message = "No items added: use this window's sample labels."
            return
        }
        guard let zone = recipient(at: point) else {
            message = "No items added: this location has no eligible panel."
            return
        }
        switch zone {
        case .orange:
            orangeItems.append(contentsOf: items)
        case .blue:
            blueItems.append(contentsOf: items)
        }
        message = "Added \(items.joined(separator: ", ")) to \(zone.rawValue)."
    }
}

App 與放置接收器

父矩形擁有唯一的放置修飾子。兩個彩色面板都不會註冊互相競爭的目的地。DropSession 提供目的地本機座標空間中的位置,以及用來識別源自 App 拖移的 localSession。模型也會檢查範例值。放置工作階段資訊。

@main
struct DropRoutingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            CanvasDemo()
        }
    }
}

@MainActor
struct CanvasDemo: View {
    @StateObject private var model = CanvasModel()
    @State private var scheduledChange: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                ForEach(model.samples, id: \.self) { item in
                    Text(item)
                        .padding(10)
                        .background(Color.gray.opacity(0.15))
                        .draggable(item)
                }
            }

            Toggle("Blue accepts drops", isOn: $model.blueEnabled)

            Button("Toggle blue in 3 seconds") {
                scheduledChange?.cancel()
                scheduledChange = Task { @MainActor in
                    do {
                        try await Task.sleep(for: .seconds(3))
                    } catch {
                        return
                    }
                    model.blueEnabled.toggle()
                }
            }

            ZStack(alignment: .topLeading) {
                Rectangle().fill(Color.gray.opacity(0.08))
                panel("Orange", rect: model.orangeRect,
                      color: .orange, opacity: 0.55)
                panel("Blue", rect: model.blueRect,
                      color: .blue,
                      opacity: model.blueEnabled ? 0.55 : 0.15)
            }
            .frame(width: 420, height: 300)
            .dropDestination(for: String.self, isEnabled: true) {
                items, session in
                model.receive(items, at: session.location,
                              isLocal: session.localSession != nil)
            }

            Text(model.message)
            Text("Orange: \(model.orangeItems.joined(separator: ", "))")
            Text("Blue: \(model.blueItems.joined(separator: ", "))")
        }
        .padding(20)
        .onDisappear {
            scheduledChange?.cancel()
        }
    }

    private func panel(_ title: String, rect: CGRect,
                       color: Color, opacity: Double) -> some View {
        Rectangle()
            .fill(color.opacity(opacity))
            .overlay(Text(title))
            .frame(width: rect.width, height: rect.height)
            .position(x: rect.midX, y: rect.midY)
    }
}

SwiftUI 的 draggable(_:) 會安裝透過使用者輸入開始拖移所需的手勢。延遲按鈕只會改變範例的資格狀態;它不會開始、重新開始或轉移拖移工作階段。SwiftUI 拖移來源行為。

按下 Toggle blue in 3 seconds,接著將 Circle 拖到重疊區域,並停在那裡直到藍色面板淡出。在重疊區域內放開時,預期的接收對象是橘色面板。在藍色面板停用時,於其未重疊區域放開,兩個項目陣列都不會改變,狀態訊息會說明原因。

此接收器在整個畫布上都保持啟用,因此能套用完整的路由政策。因此,此範例不會提供依位置而異的原生拒絕游標或懸停反白。狀態會在放開後回報應用程式是否接受。若產品需要這類回饋,請另行加入提案和懸停處理。

程式碼已對照取得的 API 宣告檢視過。由於目前環境沒有 Swift 工具鏈或 macOS 執行環境,因此未在此編譯或執行。

分別處理懸停更新和較舊的部署目標

若你支援不含新版多載的系統,請沿用相同的模型和幾何政策,並選擇可用的接收 API。較舊的 dropDestination(for:action:isTargeted:) 會傳入解碼項目和本機座標點;其布林結果表示成功與否。其 isTargeted 閉包描述原生目的地區域的進入和離開,而在此設計中該區域是整個畫布。它不會指出指標下方的邏輯面板。舊版放置目的地契約。

若要明確驗證和操作提案,onDrop(of:delegate:) 會安裝 DropDelegate。使用 validateDrop(info:) 進行初始資格判斷、使用 dropUpdated(info:) 處理目前提案,並使用 performDrop(info:) 進行最後驗證和納入。傳回 .forbidden 表示該位置不允許此操作;文件並未說明這會將操作轉交給另一個原生接收器。以委派方式設定目的地與放置操作。

使用提供者時,有一個重要的載入界線:DropInfo.itemProviders(for:) 只在 performDrop 期間有效。請在該方法傳回前開始載入提供者內容。完成通知可能稍後才送達,因此在變更模型之前,請重新檢查目的地識別、版本,以及是否仍符合資格。載入遭拒或失敗時應回報,不要悄悄丟棄結果。提供者存取與放置完成要求。

若要呈現懸停狀態,請將暫時性的工作階段資訊與已接收項目分開儲存。新版 DropSession 文件指出可使用 onDropSessionUpdated(_:) 觀察階段變化。不要假設模型狀態變更會產生另一個指標更新;若你已取得有效的懸停位置,資格變更時也要重新計算邏輯上的反白狀態。最後接受時仍應再次呼叫路由函式。放置工作階段生命週期。

何時該改用 AppKit 處理畫布?

若互動需求仰賴有文件記載的原生定期更新,且指標可以保持靜止,請使用 AppKit 目的地。AppKit 的 wantsPeriodicDraggingUpdates() 控制此行為:傳回 false 時,只會在滑鼠移動或修飾鍵變更時更新;否則即使沒有任何變化,也會定期更新。這提供了一種有文件記載的方式,可在拖移停滯時重新評估目前操作。定期拖移更新。

相應的架構仍然是讓畫布只有一個目的地。註冊其接受的貼上板類型,在 draggingEntered(_:) 和 draggingUpdated(_:) 期間解析邏輯面板,在放開時檢查是否接受,並在 performDragOperation(_:) 中納入項目。Apple 特別說明,可針對單一目的地視圖內的圖形或文字區域調整操作。AppKit 目的地生命週期與依位置決定的操作。

使用 NSViewRepresentable 嵌入該目的地時,請在 updateNSView(_:context:) 更新其模型參考、資格設定和回呼。只在初始建立時設定無法同步之後的擁有者變更;Apple 指出,更新方法是讓 AppKit 視圖與 SwiftUI 狀態保持一致的所在位置。更新 AppKit 視圖。

也要保留來源事件界線。AppKit 的 beginDraggingSession(with:event:source:) 會根據拖移項目和起始的滑鼠按下事件開始工作階段。它不是重新指定目的地的 API。若現有的 SwiftUI 來源符合需求,就繼續使用;只有在也需要掌控來源端互動時,才使用 AppKit 來源。開始 AppKit 拖移。

在影響擁有權的位置驗證狀態變更

請在你支援的 Mac 部署版本上進行以下手動檢查。這些檢查能區分正確路由和表面上看似有效的視覺變化:

  • 藍色面板啟用時,在重疊區域放置:只有藍色面板的陣列會新增項目。
  • 拖移前停用藍色面板:重疊區域會路由至橘色面板。
  • 拖移停在原地時停用藍色面板:最終接受狀態會採用停用後的狀態。
  • 藍色面板停用時,在僅藍色區域放置:兩個陣列都維持不變,且狀態訊息會說明結果。
  • 拖移期間重新啟用藍色面板:提交時,重疊區域會路由至藍色面板。
  • 取消拖移:兩個陣列都不會改變。
  • 若你的 App 會替換畫布擁有者,請在拖移期間替換一次,並確認所選的擁有者替換政策。
  • 若你的實作會以非同步方式載入提供者,請在完成前變更擁有者,並確認版本檢查和可見的失敗回報。

若畫布可捲動、縮放或調整大小,請擴充此設計,將每個面板的邊界和放置位置轉換到同一個座標空間。此範例使用固定矩形以避免這類轉換;若將矩形的數值直接複製到經過變換的版面配置中,路由政策就會失效。

穩定的原生接收器和使用目前狀態的路由函式,能為此互動提供明確結果:最上層符合資格的面板會接收項目,符合資格的下層面板會接收重疊區域的備援項目,而沒有覆蓋的區域則不會改變模型。懸停回饋和最終接受應共用此政策,且最終接受時必須自行再次檢查。

來源