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
- 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?
- Has the
NSItemProvider
deallocation timing (for the object returned from.onDrag
) changed intentionally on iOS 26? If so, what’s the recommended replacement signal? - Is there a SwiftUI-native event to observe the end of a drag session that doesn’t depend on the provider’s lifecycle?