SwiftUI drag & drop: reliable cancellation

Summary

In a SwiftUI drag-and-drop flow, the only robust way I’ve found to detect cancellation (user drops outside any destination) is to rely on the NSItemProvider created in .onDrag and run cleanup when it’s deallocated, via a custom onEnded callback tied to its lifecycle.

On iOS 26, the provider appears to be deallocated immediately after .onDrag returns (unless I keep a strong reference), so a deinit/onEnded-based cancel hook fires right away and no longer reflects the true end of the drag session.

I’d like to know: 1. Is there a supported, reliable way to detect when a drag ends outside any drop target so I can cancel and restore the source row? 2. Is the iOS 26 NSItemProvider deallocation timing a bug/regression or intended behavior?

Minimal SwiftUI Repro

This example shows: • creating a custom NSItemProvider subclass with an onEnded closure • retaining it to avoid immediate dealloc (behavior change on iOS 26) • using performDrop to mark the drag as finished

import SwiftUI
import UniformTypeIdentifiers

final class DragProvider: NSItemProvider {
    var onEnded: (() -> Void)?
    deinit {
        // Historically: called when the system drag session ended (drop or cancel).
        // On iOS 26: can fire immediately after .onDrag returns unless the provider is retained.
        onEnded?()
    }
}

struct ContentView: View {
    struct Item: Identifiable, Equatable { let id = UUID(); let title: String }

    @State private var pool:   [Item] = (1...4).map { .init(title: "Option \($0)") }
    @State private var picked: [Item] = []

    @State private var dragged: Item?
    @State private var dropFinished: Bool = true
    @State private var activeProvider: DragProvider? // Retain to avoid immediate dealloc

    private let dragType: UTType = .plainText

    var body: some View {
        HStack(spacing: 24) {

            // Destination list (accepts drops)
            VStack(alignment: .leading, spacing: 8) {
                Text("Picked").bold()
                VStack(spacing: 8) {
                    ForEach(picked) { item in
                        row(item)
                    }
                }
                .padding()
                .background(RoundedRectangle(cornerRadius: 8).strokeBorder(.quaternary))
                .onDrop(of: [dragType], delegate: Dropper(
                    picked: $picked,
                    pool: $pool,
                    dragged: $dragged,
                    dropFinished: $dropFinished,
                    activeProvider: $activeProvider
                ))
            }
            .frame(maxWidth: .infinity, alignment: .topLeading)

            // Source list (draggable)
            VStack(alignment: .leading, spacing: 8) {
                Text("Pool").bold()
                VStack(spacing: 8) {
                    ForEach(pool) { item in
                        row(item)
                            .onDrag {
                                startDrag(item)
                                return makeProvider(for: item)
                            } preview: {
                                row(item).opacity(0.9).frame(width: 200, height: 44)
                            }
                            .overlay(
                                RoundedRectangle(cornerRadius: 8)
                                    .fill(item == dragged ? Color(.systemBackground) : .clear) // keep space
                            )
                    }
                }
                .padding()
                .background(RoundedRectangle(cornerRadius: 8).strokeBorder(.quaternary))
            }
            .frame(maxWidth: .infinity, alignment: .topLeading)
        }
        .padding()
    }

    private func row(_ item: Item) -> some View {
        RoundedRectangle(cornerRadius: 8)
            .strokeBorder(.secondary)
            .frame(height: 44)
            .overlay(
                HStack { Text(item.title); Spacer(); Image(systemName: "line.3.horizontal") }
                    .padding(.horizontal, 12)
            )
    }

    // MARK: Drag setup

    private func startDrag(_ item: Item) {
        dragged = item
        dropFinished = false
    }

    private func makeProvider(for item: Item) -> NSItemProvider {
        let provider = DragProvider(object: item.id.uuidString as NSString)

        // NOTE: If we DO NOT retain this provider on iOS 26,
        // its deinit can run immediately (onEnded fires too early).
        activeProvider = provider

        provider.onEnded = { [weak self] in
            // Intended: run when system drag session ends (drop or cancel).
            // Observed on iOS 26: may run too early unless retained;
            // if retained, we lose a reliable "session ended" signal here.
            DispatchQueue.main.async {
                guard let self else { return }
                if let d = self.dragged, self.dropFinished == false {
                    // Treat as cancel: restore the source item
                    if !self.pool.contains(d) { self.pool.append(d) }
                    self.picked.removeAll { $0 == d }
                }
                self.dragged = nil
                self.dropFinished = true
                self.activeProvider = nil
            }
        }
        return provider
    }

    // MARK: DropDelegate

    private struct Dropper: DropDelegate {
        @Binding var picked: [Item]
        @Binding var pool: [Item]
        @Binding var dragged: Item?
        @Binding var dropFinished: Bool
        @Binding var activeProvider: DragProvider?

        func validateDrop(info: DropInfo) -> Bool { dragged != nil }

        func performDrop(info: DropInfo) -> Bool {
            guard let item = dragged else { return false }
            if let idx = pool.firstIndex(of: item) { pool.remove(at: idx) }
            picked.append(item)

            // Mark drag as finished so provider.onEnded won’t treat it as cancel
            dropFinished = true
            dragged = nil
            activeProvider = nil
            return true
        }
    }
}

Questions

  1. Is there a documented, source-side callback (or best practice) to know the drag session ended without any performDrop so we can cancel and restore the item?
  2. Has the NSItemProvider deallocation timing (for the object returned from .onDrag) changed intentionally on iOS 26? If so, what’s the recommended replacement signal?
  3. Is there a SwiftUI-native event to observe the end of a drag session that doesn’t depend on the provider’s lifecycle?
SwiftUI drag & drop: reliable cancellation
 
 
Q