對於具有重疊放置區域的 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 會替換畫布擁有者,請在拖移期間替換一次,並確認所選的擁有者替換政策。
- 若你的實作會以非同步方式載入提供者,請在完成前變更擁有者,並確認版本檢查和可見的失敗回報。
若畫布可捲動、縮放或調整大小,請擴充此設計,將每個面板的邊界和放置位置轉換到同一個座標空間。此範例使用固定矩形以避免這類轉換;若將矩形的數值直接複製到經過變換的版面配置中,路由政策就會失效。
穩定的原生接收器和使用目前狀態的路由函式,能為此互動提供明確結果:最上層符合資格的面板會接收項目,符合資格的下層面板會接收重疊區域的備援項目,而沒有覆蓋的區域則不會改變模型。懸停回饋和最終接受應共用此政策,且最終接受時必須自行再次檢查。
來源
validateDrop(info:) — Apple。
dropUpdated(info:) — Apple。
draggingUpdated(_:) — Apple。
State — Apple。
Closures — Swift.org。
DropSession — Apple。
draggable(_:) — Apple。
onDrop(of:delegate:) — Apple。
DropOperation — Apple。
itemProviders(for:) — Apple。
performDrop(info:) — Apple。
wantsPeriodicDraggingUpdates() — Apple。
NSDraggingDestination — Apple。
updateNSView(_:context:) — Apple。