Greetings, traveler!
iOS 27 adds a new layer to SwiftUI drag and drop: drag containers. At first glance, it looks like another variation of .draggable. But the model is different. Instead of giving every draggable view its full payload, you can give the view only an identifier. The container then turns those identifiers into real Transferable values when a drag begins.
The child view no longer has to know how to build the dragged data. It only needs to say, “I represent this item.” The parent container knows the collection, the selection, and the rules for building the payload. That is the main idea behind the new API.
The old model
Before this API, the common SwiftUI drag pattern was direct:
PhotoCell(photo: photo)
.draggable(photo)The view is draggable because it owns the payload. This works well for simple cases. A single value, a single view, a single drag item.
But collection UI often has a different shape. A grid cell may only display a thumbnail while the full object lives elsewhere in the app’s data model, and a drag that begins from a single cell may still need to include several selected items.
In that kind of UI, putting all drag logic into every child view feels awkward. The child has local visual context. The container has collection context. dragContainer moves that collection-level logic back to the container.
The basic shape
Assume we have a type that conforms to both Identifiable and Transferable:
struct PhotoAsset: Identifiable, Transferable {
let id: UUID
let title: String
// TransferRepresentation goes here.
}With the new API, each cell can expose only its ID:
PhotoCell(photo: photo)
.draggable(containerItemID: photo.id)Then the parent view provides the actual payload:
struct PhotoGrid: View {
let photos: [PhotoAsset]
var body: some View {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
PhotoCell(photo: photo)
.draggable(containerItemID: photo.id)
}
}
.dragContainer(for: PhotoAsset.self) { ids in
let draggedIDs = Set(ids)
return photos.filter { draggedIDs.contains($0.id) }
}
}
private var columns: [GridItem] {
[GridItem(.adaptive(minimum: 80))]
}
}The closure receives an array of item identifiers. It returns a collection of Transferable items.
The cell does not return PhotoAsset. The cell only gives SwiftUI photo.id. The container receives the dragged IDs and builds the payload from the source of truth it already owns.
The Identifiable overload
For Identifiable items, the API can use Item.ID directly:
.dragContainer(for: PhotoAsset.self) { ids in
let draggedIDs = Set(ids)
return photos.filter { draggedIDs.contains($0.id) }
}The documented signature has this shape:
func dragContainer<Item, Data>(
for itemType: Item.Type = Item.self,
in namespace: Namespace.ID? = nil,
_ payload: @escaping (Array<Item.ID>) -> Data
) -> some View
where Item: Transferable,
Item: Identifiable,
Item == Data.Element,
Data: Collection,
Item.ID: SendableItem must be Transferable, because the container eventually produces real drag data. Item must be Identifiable, because this overload uses Item.ID as the connection between child views and payload values. Item.ID must be Sendable.
The closure returns Data, not a single item. That is what allows the same API to support single-item and multi-item drags.
If you return an empty collection, the drag is disabled. That gives you a useful escape hatch. The view can be visually draggable, but the container can still refuse the drag when the model says there is nothing valid to drag.
The itemID overload
There is also an overload for models that do not conform to Identifiable, or for cases where you want to use a specific property as the drag identifier.
The signature takes a key path:
func dragContainer<ItemID, Item, Data>(
for itemType: Item.Type = Item.self,
itemID: KeyPath<Item, ItemID>,
in namespace: Namespace.ID? = nil,
_ payload: @escaping (Array<ItemID>) -> Data
) -> some View
where ItemID: Hashable,
ItemID: Sendable,
Item: Transferable,
Item == Data.Element,
Data: CollectionA simple example:
struct Fruit: Transferable {
let name: String
// TransferRepresentation goes here.
}The view can use name as the identifier:
struct FruitGrid: View {
let fruits: [Fruit]
var body: some View {
LazyVGrid(columns: columns) {
ForEach(fruits, id: \.name) { fruit in
Text(fruit.name)
.draggable(containerItemID: fruit.name)
}
}
.dragContainer(for: Fruit.self, itemID: \.name) { names in
let draggedNames = Set(names)
return fruits.filter { draggedNames.contains($0.name) }
}
}
private var columns: [GridItem] {
[GridItem(.adaptive(minimum: 100))]
}
}The contract is the same. Child views provide identifiers. The container turns identifiers into Transferable values.
With Identifiable, SwiftUI uses Item.ID. With itemID, you choose the property yourself.
Selection is separate
Multi-item drag needs one more piece: selection. iOS 27 adds dragContainerSelection(_:containerNamespace:) for that:
.dragContainerSelection(selectedPhotoIDs)You still manage selection yourself:
struct SelectablePhotoGrid: View {
let photos: [PhotoAsset]
@State private var selectedPhotoIDs: [PhotoAsset.ID] = []
var body: some View {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
PhotoCell(
photo: photo,
isSelected: selectedPhotoIDs.contains(photo.id)
)
.onTapGesture {
toggleSelection(photo.id)
}
.draggable(containerItemID: photo.id)
}
}
.dragContainer(for: PhotoAsset.self) { ids in
let draggedIDs = Set(ids)
return photos.filter { draggedIDs.contains($0.id) }
}
.dragContainerSelection(selectedPhotoIDs)
}
private var columns: [GridItem] {
[GridItem(.adaptive(minimum: 80))]
}
private func toggleSelection(_ id: PhotoAsset.ID) {
if selectedPhotoIDs.contains(id) {
selectedPhotoIDs.removeAll { $0 == id }
} else {
selectedPhotoIDs.append(id)
}
}
}The selection changes which IDs SwiftUI passes into the payload closure. If the dragged view is associated with a selected identifier, the payload should contain all selected items. If the dragged view is not selected, the payload should contain only the dragged item.
Imagine a photo grid. If five photos are selected and the user starts dragging one of those five, the drag should lift the selection. If the user drags a different photo, it would be surprising to drag the old selection instead. In that case, SwiftUI uses the dragged item.
Namespaces solve ambiguity
dragContainer, draggable(containerItemID:), and dragContainerSelection all have namespace support. It matters when the same view hierarchy contains several drag containers with the same identifier type.
For example:
struct TwoPhotoGrids: View {
@Namespace private var favoritesNamespace
@Namespace private var recentsNamespace
let favorites: [PhotoAsset]
let recents: [PhotoAsset]
@State private var selectedFavoriteIDs: [PhotoAsset.ID] = []
@State private var selectedRecentIDs: [PhotoAsset.ID] = []
var body: some View {
VStack {
photoGrid(
photos: favorites,
selectedIDs: selectedFavoriteIDs,
namespace: favoritesNamespace
)
.dragContainer(for: PhotoAsset.self, in: favoritesNamespace) { ids in
let draggedIDs = Set(ids)
return favorites.filter { draggedIDs.contains($0.id) }
}
.dragContainerSelection(
selectedFavoriteIDs,
containerNamespace: favoritesNamespace
)
photoGrid(
photos: recents,
selectedIDs: selectedRecentIDs,
namespace: recentsNamespace
)
.dragContainer(for: PhotoAsset.self, in: recentsNamespace) { ids in
let draggedIDs = Set(ids)
return recents.filter { draggedIDs.contains($0.id) }
}
.dragContainerSelection(
selectedRecentIDs,
containerNamespace: recentsNamespace
)
}
}
private func photoGrid(
photos: [PhotoAsset],
selectedIDs: [PhotoAsset.ID],
namespace: Namespace.ID
) -> some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))]) {
ForEach(photos) { photo in
PhotoCell(
photo: photo,
isSelected: selectedIDs.contains(photo.id)
)
.draggable(
containerItemID: photo.id,
containerNamespace: namespace
)
}
}
}
}A drag container finds the nearest enclosing dragContainerSelection with the same item identifier type and the same namespace, when a namespace is specified.
Drag configuration
dragContainer controls what data the drag carries. DragConfiguration controls which operations the drag source suggests. The simple case looks like this:
.dragConfiguration(DragConfiguration(allowMove: true))There is also an initializer that can allow delete:
.dragConfiguration(
DragConfiguration(
allowMove: false,
allowDelete: true
)
)The default drag configuration supports copy. The custom configuration can add move and delete support.
This is still only the source side of the conversation. A drag source can say, “I allow move.” A drop destination still decides what operation it accepts. Apple shows this split in the WWDC code-along: the source uses dragConfiguration, and the destination uses dropConfiguration to choose the final operation.
DragConfigurationdescribes what the drag source supports. Your drop handling or drag session handling still performs the model update.
A delete example
A delete flow can be shaped like this:
struct DeletablePhotoGrid: View {
@State private var photos: [PhotoAsset]
@State private var selectedPhotoIDs: [PhotoAsset.ID] = []
var body: some View {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
PhotoCell(
photo: photo,
isSelected: selectedPhotoIDs.contains(photo.id)
)
.draggable(containerItemID: photo.id)
}
}
.dragContainer(for: PhotoAsset.self) { ids in
let draggedIDs = Set(ids)
return photos.filter { draggedIDs.contains($0.id) }
}
.dragContainerSelection(selectedPhotoIDs)
.dragConfiguration(
DragConfiguration(
allowMove: false,
allowDelete: true
)
)
}
private var columns: [GridItem] {
[GridItem(.adaptive(minimum: 80))]
}
}This code says the drag source can support delete. It does not define where the trash target is, and it does not remove the items from the array. Configuration and mutation are separate concerns.
The drag source describes supported operations. The destination or session handling decides what happened and updates the model.
How this fits with reordering
These APIs also fit naturally with the new reordering API. reorderContainer answers one question: how does the model change when the user reorders items?
dragContainer answers a different question: what items are being dragged?
In Apple’s Solitaire example, reorderContainer handles moving cards between piles. dragContainer customizes the dragged payload, so dragging one card can pick up a stack of cards starting at that card.
The reorder container owns the move result. The drag container owns payload construction. DragConfiguration describes whether the source allows operations such as move or delete.
Final thoughts
The nice part of dragContainer is that it matches how collection screens are usually built.
Cells often should not know the full payload. They should know their identity. The parent view already knows the collection, the selected IDs, and the rules for exporting or moving data.
iOS 27 gives that model a first-class SwiftUI API:
.draggable(containerItemID: item.id)
.dragContainer(for: Item.self) { ids in
items(for: ids)
}
.dragContainerSelection(selectedIDs)
.dragConfiguration(...)The child view can still be marked with draggable(containerItemID:), but the container can disable a specific drag by returning an empty collection.
