For a Mac canvas with overlapping drop zones, put native drop handling on one stable ancestor and resolve the logical destination from current model state. Recheck eligibility when committing the drop; a rejected callback is not a documented handoff to another view.

This article builds that interaction for an app containing synthetic shape labels and two overlapping panels. The concrete task is to let a lower panel receive a drop when the upper panel stops accepting items, including when its eligibility changes during a drag. The routing policy below is an application design derived from the documented callbacks.

Why doesn’t changing state reliably hand the drag to another target?

Start by separating three questions: which native view receives the drag, which logical panel your app considers eligible, and which model receives the final item. A state change can alter your answer to the second question without establishing a new answer to the first.

Apple documents SwiftUI’s validateDrop(info:) as validation when a compatible drop enters a destination. It documents dropUpdated(info:) as an update when a validated drop moves inside the modified view. Neither description promises a fresh destination search whenever unrelated owning-view state changes. SwiftUI validation and drop updates.

The AppKit boundary is more explicit: only one destination receives a sequence of draggingUpdated(_:) messages at a time. When two overlapping views are valid destinations, the uppermost receives those messages until the image is released or dragged out. That describes AppKit routing; it does not establish a general SwiftUI sibling-priority contract. AppKit destination updates.

Returning false from the older SwiftUI drop action reports that the drop was unsuccessful. Apple does not describe that return value as a request to retry an underlying destination. Likewise, returning nil from dropUpdated(info:) preserves the last valid proposal or defaults to copying; it does not mean “pass through.” Closure-based drop handling and proposal behavior.

The practical consequence is to make fallback an explicit application rule. For a single canvas, one native receiver can choose among several logical panels without depending on native handoff between overlapping child receivers.

Define ownership and fallback before attaching modifiers

For this example, the orange panel sits behind the blue panel. Orange always accepts the sample labels. Blue accepts them only while blueEnabled is true. The intended behavior is:

Pointer location Blue accepts drops Logical recipient
Orange-only area Either value Orange
Overlap Yes Blue
Overlap No Orange
Blue-only area No None
Outside both panels Either value None

This table is the decision aid for the implementation. In particular, disabling blue does not make every point inside its rectangle an orange destination. Orange receives the item only when the pointer also lies inside orange’s rectangle.

Keep the state that governs routing above the visual panels. Apple recommends placing a state value at the highest view that needs it and sharing it with descendants. Here, one parent owns a model containing eligibility, geometry, and received items. The child rectangles supply appearance rather than independent drop handlers. SwiftUI state ownership.

Read that model when the drop action runs. Avoid saving an eligibility Boolean or destination identifier during entry and later treating it as authoritative. Swift closures can retain values and references from their creation context; a retained reference can also continue pointing to an older model after an owner replacement. Audit which object a callback actually captures. Swift closure capture.

For owner replacement, choose a separate policy: either resolve against the new owner or reject work belonging to the previous owner. A useful rejection design attaches a revision number to the owner, increments it on replacement, and compares the captured revision before committing delayed work. A transient “currently highlighted” value should not decide ownership.

Build one stable SwiftUI drop surface

The following complete example requires a macOS SwiftUI app with a macOS 26 deployment target and an SDK containing dropDestination(for:isEnabled:action:). Apple documents that overload as accepting a Transferable type, an enabled flag, and an action receiving decoded items plus a DropSession. The destination occupies the modified view’s size and position. Current drop-destination API.

Create a new macOS App project using SwiftUI. Replace its generated app declaration with the two code blocks below, placed consecutively in one Swift file. Together they form one program. It uses only in-memory strings and rectangles. The source labels remain available after a drop, so the example behaves as a copy operation.

Model and deterministic routing

The model checks blue first to express the chosen visual priority. Both rendering and routing use the same rectangles. Its commit method recalculates the recipient using current eligibility and validates the whole sample batch before appending anything.

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 and drop receiver

The parent rectangle owns the sole drop modifier. Neither colored panel registers a competing destination. The DropSession supplies a location in the destination’s local coordinate space and a localSession that identifies a drag originating in the app. The model also checks the sample values. Drop-session information.

@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’s draggable(_:) installs the gestures needed to begin the drag through user input. The delayed button changes only the example’s eligibility state; it does not start, restart, or transfer a drag session. SwiftUI drag-source behavior.

Press Toggle blue in 3 seconds, then drag Circle into the overlap and hold it there until blue fades. Release inside the overlap: the intended recipient is orange. Release in blue’s nonoverlapping area while blue is disabled: neither item array changes, and the status explains why.

This receiver remains enabled across the whole canvas so it can apply the complete routing policy. Consequently, the example does not provide a location-specific native rejection cursor or hover highlight. Its status reports application acceptance after release. Add proposal and hover handling separately if the product needs that feedback.

The code was inspected against the retrieved API declarations. It was not compiled or run here because the available environment has no Swift toolchain or macOS runtime.

Handle hover updates and older deployment targets separately

If you support systems without the newer overload, preserve the same model and geometry policy while choosing an available receiving API. The older dropDestination(for:action:isTargeted:) passes decoded items and a local point; its Boolean result reports success. Its isTargeted closure describes entry and exit of the native destination area, which in this design is the whole canvas. It does not identify the logical panel underneath the pointer. Older drop-destination contract.

For explicit validation and operation proposals, onDrop(of:delegate:) installs a DropDelegate. Use validateDrop(info:) for initial eligibility, dropUpdated(info:) for current proposals, and performDrop(info:) for final validation and incorporation. Returning .forbidden describes disallowed activity at that location; it does not document a handoff to another native receiver. Delegate-based destinations and drop operations.

There is an important loading boundary when using providers: DropInfo.itemProviders(for:) is valid only during performDrop. Start loading provider contents before that method returns. Completion may arrive later, so recheck the destination’s identity, revision, and continued eligibility before changing the model. Report a rejected or failed load instead of silently discarding the result. Provider access and drop completion requirements.

For hover presentation, store transient session information separately from received items. The newer DropSession documentation points to onDropSessionUpdated(_:) for observing phase changes. Do not assume a model-state change creates another pointer update; if you already have a valid hover location, recompute the logical highlight when eligibility changes as well. Final acceptance should still call the routing function again. Drop-session lifecycle.

When should the canvas use AppKit instead?

Use an AppKit destination when the required interaction depends on documented periodic native updates while the pointer stays still. AppKit’s wantsPeriodicDraggingUpdates() controls this behavior: returning false limits updates to mouse movement or modifier changes; otherwise periodic updates occur even when nothing changes. That supplies a documented way to reevaluate the current operation during a stationary drag. Periodic dragging updates.

The corresponding architecture is still one destination for the canvas. Register its accepted pasteboard types, resolve logical panels during draggingEntered(_:) and draggingUpdated(_:), check acceptance at release, and incorporate the item in performDragOperation(_:). Apple specifically describes tailoring an operation to graphics or text areas contained within one destination view. AppKit destination lifecycle and position-dependent operations.

When embedding that destination with NSViewRepresentable, update its model reference, eligibility configuration, and callbacks in updateNSView(_:context:). Initial creation alone cannot synchronize later owner changes; Apple identifies the update method as the place to bring the AppKit view into agreement with SwiftUI state. Updating an AppKit view.

Preserve the source event boundary too. AppKit’s beginDraggingSession(with:event:source:) starts a session from dragging items and the originating mouse-down event. It is not a destination-retargeting API. Keep an existing SwiftUI source when it meets the task; use an AppKit source only when you also need to own that source-side interaction. Starting an AppKit drag.

Verify state changes at the points that affect ownership

Use the following manual checks on the Mac deployment versions you support. They distinguish correct routing from a visual change that merely appears to work:

  • Drop in the overlap with blue enabled: only blue’s array gains an item.
  • Disable blue before dragging: the overlap routes to orange.
  • Disable blue during a stationary drag: final acceptance uses the disabled state.
  • Drop in blue-only space while disabled: both arrays remain unchanged and the status explains the outcome.
  • Reenable blue during the drag: the overlap routes to blue at commit time.
  • Cancel a drag: neither array changes.
  • If your app replaces canvas owners, replace one during a drag and verify the chosen owner-replacement policy.
  • If your implementation loads providers asynchronously, change the owner before completion and verify revision checks and visible failure reporting.

For a scrolling, zooming, or resizable canvas, extend the same design by converting every panel’s bounds and the drop location into one coordinate space. The fixed rectangles in this example avoid that conversion; copying their numbers into a transformed layout would break the routing policy.

A stable native receiver and a current-state routing function give this interaction an explicit outcome: the top eligible panel receives the item, an eligible lower panel receives the overlap fallback, and an uncovered location leaves the model unchanged. Keep that policy shared by hover feedback and final acceptance, with final acceptance performing its own check.

Sources