Drag Containers en SwiftUI: drag & drop más limpio en iOS 27
Arturo Rivas Arias
Con iOS 27, SwiftUI añade una nueva capa sobre el sistema de drag & drop (arrastrar y soltar): los nuevos Drag Containers. A primera vista puede parecer una variante más de .draggable, pero el cambio de modelo es bastante interesante.
Hasta ahora, lo habitual era que cada vista draggable conociese el objeto completo que iba a arrastrarse. Con la nueva API, una vista hija puede limitarse a exponer un identificador, mientras que el contenedor se encarga de convertir esos identificadores en valores reales que conforman Transferable cuando comienza el arrastre.
La idea es sencilla, pero tiene mucha importancia a nivel de arquitectura: la celda no necesita saber cómo se construye el payload o contenido. Solo necesita decir “yo represento este elemento”. El contenedor, que ya conoce la colección completa, la selección activa y las reglas de negocio, se encarga del resto.
El modelo clásico de draggable
Antes de esta API, el patrón más directo en SwiftUI era aplicar draggable(_:) sobre cada vista, pasando el valor completo que queríamos arrastrar.
TicketCard(ticket: ticket)
.draggable(ticket)
Este enfoque funciona bien en casos sencillos: una vista, un dato, un único elemento arrastrado.
El problema aparece cuando la interfaz representa una colección más compleja. Pensemos en un tablero Kanban con tickets, incidencias o tareas. Cada tarjeta puede mostrar solo una pequeña parte del modelo, mientras que la colección completa vive en un ViewModel, en un @Observable, en SwiftData o en cualquier otra capa de estado.
Además, el usuario puede seleccionar varios elementos y esperar que un arrastre iniciado desde una tarjeta seleccionada mueva todos esos elementos a la vez.
En ese escenario, meter toda la lógica de drag & drop dentro de cada tarjeta resulta incómodo. La tarjeta tiene contexto visual. El contenedor tiene contexto de colección. dragContainer devuelve esa responsabilidad al lugar donde normalmente debería estar.
La idea detrás de dragContainer
El nuevo modelo introduce una separación clara de responsabilidades.
- La tarjeta conoce únicamente su identidad.
- El contenedor conoce la colección completa.
- El contenedor decide qué elementos forman parte del payload final.
- En lugar de proporcionar el objeto completo, cada vista únicamente expone un identificador:
TicketCard(ticket: ticket)
.draggable(containerItemID: ticket.id)
Posteriormente, el contenedor convierte esos identificadores en objetos reales cuando comienza la operación de arrastre:
.dragContainer(for: Ticket.self) { ids in
let draggedIDs = Set(ids)
return tickets.filter {
draggedIDs.contains($0.id)
}
}
Este diseño encaja mucho mejor con la arquitectura típica de SwiftUI, donde el estado y la lógica suelen residir en niveles superiores de la jerarquía de vistas.
Transferable sigue siendo obligatorio
Los elementos que se arrastran deben seguir conformando el protocolo Transferable.
struct Ticket: Identifiable, Transferable {
let id: UUID
let title: String
let priority: Priority
let projectID: UUID
}
La diferencia está en quién construye el dato que se arrastra. La vista hija ya no tiene que saber cómo exportar un Ticket, ni cómo resolver una selección múltiple, ni cómo aplicar reglas de negocio antes de permitir el arrastre. Su única responsabilidad es indicar qué elemento representa. El contenedor, por su parte, transforma esos identificadores en valores Transferable: la relación entre vista e información transferida queda mucho más desacoplada.
Soporte nativo para múltiples elementos
Uno de los detalles más importantes es que el closure de dragContainer recibe siempre una colección de identificadores. Eso permite que la misma API sirva tanto para arrastrar un único elemento como para arrastrar varios.
.dragContainer(for: Ticket.self) { ids in
tickets.filter { ids.contains($0.id) }
}
El closure no devuelve un único Ticket, sino una colección de valores. Esto permite que SwiftUI gestione el arrastre de elementos individuales y múltiples sin cambiar el modelo mental. También hay una consecuencia práctica interesante: si el closure devuelve una colección vacía, el arrastre se cancela.
.dragContainer(for: Ticket.self) { ids in
guard permissions.canMoveTickets else {
return []
}
return tickets.filter { ids.contains($0.id) }
}
Esto abre la puerta a la implementación de reglas de negocio muy claras y limpias. Podemos impedir el arrastre si el usuario no tiene permisos, si el ticket está cerrado, si pertenece a un proyecto archivado o si la operación no tiene sentido en el estado actual de la aplicación. La vista puede parecer draggable, pero el contenedor mantiene la última palabra.
Cuando el modelo no es Identifiable
La versión más sencilla de dragContainer funciona especialmente bien cuando el tipo conforma Identifiable, porque SwiftUI puede utilizar Item.ID como conexión entre la vista hija y el payload. Pero también existe una sobrecarga para modelos que no conforman Identifiable o para casos en los que queremos utilizar una propiedad concreta como identificador.
Imaginemos un modelo de etiqueta de proyecto donde el identificador funcional es un código interno.
struct ProjectLabel: Transferable {
let code: String
let name: String
}
En ese caso, podemos indicar explícitamente qué propiedad funciona como identificador:
LabelBadge(label: label)
.draggable(containerItemID: label.code)
Y después resolver los valores reales en el contenedor:
.dragContainer(
for: ProjectLabel.self,
itemID: \.code
) { codes in
let draggedCodes = Set(codes)
return labels.filter { draggedCodes.contains($0.code) }
}
El contrato sigue siendo el mismo: las vistas hijas proporcionan identificadores y el contenedor los transforma en valores Transferable.
Selección múltiple con dragContainerSelection
Una de las ventajas más interesantes de esta API aparece cuando trabajamos con múltiples elementos seleccionados.
Imaginemos una aplicación de gestión de proyectos similar a Trello, Linear o Jira. El usuario selecciona varias incidencias críticas y decide moverlas juntas desde la columna “Backlog” hasta “En progreso”. La selección sigue siendo responsabilidad de nuestra aplicación:
@State private var selectedTicketIDs: [Ticket.ID] = []
Y SwiftUI puede recibir esa selección mediante dragContainerSelection:
.dragContainerSelection(selectedTicketIDs)
Un ejemplo completo podría ser algo así:
struct TicketColumnView: View {
let tickets: [Ticket]
@Binding var selectedTicketIDs: [Ticket.ID]
var body: some View {
LazyVStack(spacing: 12) {
ForEach(tickets) { ticket in
TicketCard(
ticket: ticket,
isSelected: selectedTicketIDs.contains(ticket.id)
)
.onTapGesture {
toggleSelection(ticket.id)
}
.draggable(containerItemID: ticket.id)
}
}
.dragContainer(for: Ticket.self) { ids in
let draggedIDs = Set(ids)
return tickets.filter { draggedIDs.contains($0.id) }
}
.dragContainerSelection(selectedTicketIDs)
}
private func toggleSelection(_ id: Ticket.ID) {
if selectedTicketIDs.contains(id) {
selectedTicketIDs.removeAll { $0 == id }
} else {
selectedTicketIDs.append(id)
}
}
}
SwiftUI utiliza esta selección para determinar qué identificadores enviará al closure de dragContainer. El comportamiento es el que el usuario espera en una interfaz de colección profesional:
- Si arrastramos una incidencia seleccionada, se moverán todas las incidencias seleccionadas.
- Si arrastramos una incidencia no seleccionada, únicamente se moverá esa incidencia.
Este detalle puede parecer pequeño, pero evita añadir mucha lógica de forma manual y acerca SwiftUI al comportamiento que ya conocemos en aplicaciones de escritorio y en otras herramientas de productividad.
Namespaces para evitar conflictos
Los nuevos modificadores dragContainer, draggable(containerItemID:) y dragContainerSelection también soportan namespaces (o espacios de nombres). Esto es importante cuando una misma pantalla contiene varios contenedores de arrastre que usan el mismo tipo de identificador. Siguiendo con el ejemplo del tablero Kanban, podríamos tener una columna de backlog y otra de incidencias en progreso. Ambas trabajan con Ticket.ID, pero cada una tiene su propia colección y su propia selección.
struct KanbanBoardView: View {
@Namespace private var backlogNamespace
@Namespace private var inProgressNamespace
let backlogTickets: [Ticket]
let inProgressTickets: [Ticket]
@State private var selectedBacklogIDs: [Ticket.ID] = []
@State private var selectedInProgressIDs: [Ticket.ID] = []
var body: some View {
HStack(alignment: .top, spacing: 24) {
ticketColumn(
tickets: backlogTickets,
selectedIDs: selectedBacklogIDs,
namespace: backlogNamespace
)
.dragContainer(for: Ticket.self, in: backlogNamespace) { ids in
let draggedIDs = Set(ids)
return backlogTickets.filter { draggedIDs.contains($0.id) }
}
.dragContainerSelection(
selectedBacklogIDs,
containerNamespace: backlogNamespace
)
ticketColumn(
tickets: inProgressTickets,
selectedIDs: selectedInProgressIDs,
namespace: inProgressNamespace
)
.dragContainer(for: Ticket.self, in: inProgressNamespace) { ids in
let draggedIDs = Set(ids)
return inProgressTickets.filter { draggedIDs.contains($0.id) }
}
.dragContainerSelection(
selectedInProgressIDs,
containerNamespace: inProgressNamespace
)
}
}
private func ticketColumn(
tickets: [Ticket],
selectedIDs: [Ticket.ID],
namespace: Namespace.ID
) -> some View {
LazyVStack(spacing: 12) {
ForEach(tickets) { ticket in
TicketCard(
ticket: ticket,
isSelected: selectedIDs.contains(ticket.id)
)
.draggable(
containerItemID: ticket.id,
containerNamespace: namespace
)
}
}
}
}
El namespace permite que SwiftUI relacione correctamente cada tarjeta con su contenedor, incluso cuando varias zonas de la vista utilizan el mismo tipo de dato. Sin esta separación, sería fácil que una selección o un identificador acabasen asociados al contenedor equivocado.
Configuración de operaciones permitidas
dragContainer controla qué datos viajan en el arrastre. DragConfiguration, en cambio, permite describir qué operaciones soporta el origen del arrastre. El caso más habitual es permitir movimiento:
.dragConfiguration(
DragConfiguration(
allowMove: true
)
)
También podemos permitir operaciones de borrado:
.dragConfiguration(
DragConfiguration(
allowMove: false,
allowDelete: true
)
)
Conviene entender bien esta separación. La configuración no modifica el modelo por sí sola. No mueve tickets, no elimina incidencias y no archiva tareas automáticamente. Solo indica qué operaciones puede soportar el origen del arrastre o drag. La vista de destino, el dropConfiguration correspondiente o la lógica de sesión serán quienes decidan qué operaciones son aceptadas y cómo se actualizará el estado de la aplicación.
Un ejemplo de archivado
Imaginemos que una columna permite arrastrar tickets hacia una zona para su archivado. El origen puede declarar que soporta el borrado o la eliminación lógica:
struct ArchiveEnabledColumn: View {
@State private var tickets: [Ticket]
@State private var selectedTicketIDs: [Ticket.ID] = []
var body: some View {
LazyVStack(spacing: 12) {
ForEach(tickets) { ticket in
TicketCard(
ticket: ticket,
isSelected: selectedTicketIDs.contains(ticket.id)
)
.draggable(containerItemID: ticket.id)
}
}
.dragContainer(for: Ticket.self) { ids in
let draggedIDs = Set(ids)
return tickets.filter { draggedIDs.contains($0.id) }
}
.dragContainerSelection(selectedTicketIDs)
.dragConfiguration(
DragConfiguration(
allowMove: false,
allowDelete: true
)
)
}
}
Este código no elimina nada por sí él mismo. Lo único que expresa es que el origen del arrastre puede participar en una operación de eliminación o archivado. La mutación real del modelo debería estar en el destino o en una capa superior de estado.
Una API más alineada con MVVM (o MV simplemente) y el framework Observation
Uno de los aspectos más interesantes de dragContainer es que encaja mucho mejor con la forma en que solemos construir aplicaciones con SwiftUI. Las vistas hijas ya no necesitan conocer cómo exportar datos ni cómo construir objetos Transferable. Su única responsabilidad consiste en representar un elemento concreto. La lógica de negocio permanece en el contenedor o incluso puede delegarse a un ViewModel o modelo reactivo:
.dragContainer(for: Ticket.self) { ids in
viewModel.tickets(for: ids)
}
En una aplicación basada en Observation, el modelo podría centralizar reglas como permisos, filtros, elementos bloqueados o transformación de datos:
@Observable
final class BoardViewModel {
var tickets: [Ticket] = []
var selectedTicketIDs: [Ticket.ID] = []
func draggableTickets(for ids: [Ticket.ID]) -> [Ticket] {
let draggedIDs = Set(ids)
return tickets.filter { ticket in
draggedIDs.contains(ticket.id) && ticket.canBeMoved
}
}
}
Y la vista quedaría mucho más declarativa:
LazyVStack {
ForEach(viewModel.tickets) { ticket in
TicketCard(ticket: ticket)
.draggable(containerItemID: ticket.id)
}
}
.dragContainer(for: Ticket.self) { ids in
viewModel.draggableTickets(for: ids)
}
.dragContainerSelection(viewModel.selectedTicketIDs)
Esto reduce el acoplamiento entre las vistas y el modelo de datos, facilita los tests unitarips y evita duplicar la lógica en cada elemento de la colección.
Conclusión
Los nuevos Drag Containers representan una evolución natural del sistema de drag & drop de SwiftUI. En lugar de obligar a cada vista a construir el contenido que es arrastrado, Apple traslada esa responsabilidad al contenedor que es el que realmente posee el contexto necesario para hacerlo. Este enfoque encaja mejor con arquitecturas basadas en estado compartido, facilita el soporte para selección múltiple y simplifica la implementación en colecciones complejas.
Para aplicaciones con listas, cuadrículas, tableros Kanban, gestores documentales o interfaces basadas en colecciones, dragContainer probablemente se convierta en la forma más limpia y sencilla de implementar drag & drop a partir de iOS 27. La vista hija solo necesita revelar el ID. El contenedor decide qué se arrastra y la lógica de negocio permanece donde debe estar.