Acciones por deslizamiento en layouts personalizados de SwiftUI con iOS 27
Arturo Rivas Arias
Hasta iOS 26, añadir desplazamientos laterales con swipeActions implicaba aceptar una condición importante: la fila debía pertenecer a un List. El modificador podía compilar aplicado a una vista dentro de un LazyVStack, un LazyVGrid o un Layout propio, pero el gesto no producía ningún resultado. Esta limitación obligaba a elegir entre el comportamiento integrado de List y la libertad visual de un contenedor personalizado.
iOS 27 elimina esa disyuntiva. El nuevo modificador swipeActionsContainer() permite que un ScrollView coordine acciones por deslizamiento entre las vistas de su contenido. Así se puede conservar la API declarativa de swipeActions en tarjetas, cuadrículas o composiciones propias sin reconstruir manualmente el gesto, el desplazamiento, la selección de una única fila activa y el cierre de las acciones.
Dos responsabilidades distintas
El modelo mental de esta API se entiende mejor separando dos niveles. Cada elemento declara qué acciones ofrece mediante swipeActions; el contenedor declara dónde se coordinan mediante swipeActionsContainer().
ScrollView {
LazyVStack {
ForEach(routes) { route in
RouteCard(route: route)
.swipeActions {
Button("Archivar", systemImage: "archivebox") {
archive(route)
}
}
}
}
}
.swipeActionsContainer()
La ubicación del segundo modificador es significativa: Apple lo aplica al ScrollView, no al LazyVStack ni a cada tarjeta. El desplazamiento es la parte interactiva que debe saber qué elemento tiene sus acciones disponibles. El contenedor puede cerrar esa presentación cuando comienza el desplazamiento, cuando se toca fuera de la fila activa o cuando se muestran las acciones de otro elemento.
swipeActionsContainer() no convierte el contenido en una lista. El diseño, el espaciado, los fondos y la carga de las vistas siguen dependiendo de los contenedores elegidos. Su responsabilidad se limita a habilitar y coordinar esta interacción.
Un ejemplo completo fuera de List
El siguiente ejemplo muestra una bandeja de lectura con tarjetas personalizadas. El array se mantiene como estado local para concentrar el ejemplo en la interacción:
import SwiftUI
struct ReadingItem: Identifiable, Equatable {
let id = UUID()
let title: String
let source: String
var isSaved = false
}
struct ReadingInboxView: View {
@State private var items = [
ReadingItem(title: "Diseño de sistemas", source: "Biblioteca"),
ReadingItem(title: "Notas sobre accesibilidad", source: "Equipo"),
ReadingItem(title: "Patrones de navegación", source: "Archivo")
]
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
ReadingCard(item: item)
.swipeActions(edge: .leading) {
Button {
toggleSaved(item)
} label: {
Label(
item.isSaved ? "Quitar" : "Guardar",
systemImage: item.isSaved
? "bookmark.slash"
: "bookmark"
)
}
.tint(.indigo)
}
.swipeActions(
edge: .trailing,
allowsFullSwipe: false
) {
Button(role: .destructive) {
remove(item)
} label: {
Label("Eliminar", systemImage: "trash")
}
}
}
}
.padding(.horizontal)
}
.swipeActionsContainer()
.navigationTitle("Lecturas")
}
private func toggleSaved(_ item: ReadingItem) {
guard let index = items.firstIndex(of: item) else { return }
items[index].isSaved.toggle()
}
private func remove(_ item: ReadingItem) {
items.removeAll { $0.id == item.id }
}
}
private struct ReadingCard: View {
let item: ReadingItem
var body: some View {
HStack(spacing: 12) {
Image(systemName: item.isSaved ? "bookmark.fill" : "doc.text")
.foregroundStyle(item.isSaved ? .indigo : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
Text(item.source)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary, in: .rect(cornerRadius: 16))
.contentShape(.rect)
}
}
El ejemplo muestra dos parámetros conocidos de swipeActions. edge decide si las acciones aparecen desde el borde inicial o final, teniendo en cuenta la dirección de escritura. allowsFullSwipe determina si un arrastre completo puede ejecutar automáticamente la primera acción de ese borde. Desactivarlo para una operación destructiva reduce el riesgo de borrar contenido accidentalmente, aunque la decisión final debería responder al contexto y a la posibilidad de deshacer.
Coordinación y estado de presentación
iOS 27 también incorpora una sobrecarga de swipeActions con el bloque onPresentationChanged. SwiftUI lo invoca cuando las acciones del elemento se muestran o se ocultan. Esto permite sincronizar otra parte de la interfaz sin implementar un gesto paralelo:
@State private var activeItemID: ReadingItem.ID?
ReadingCard(item: item)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
remove(item)
} label: {
Label("Eliminar", systemImage: "trash")
}
} onPresentationChanged: { isPresented in
activeItemID = isPresented ? item.id : nil
}
Este estado resulta útil para pausar una animación de la tarjeta, ocultar controles que competirían por el espacio o registrar qué fila está activa con fines internos. No conviene emplearlo para duplicar la coordinación que ya aporta el contenedor. Tampoco debe interpretarse como el estado persistente del modelo: indica una presentación transitoria de la interfaz, no que la acción haya sido ejecutada.
Hay que contemplar además el orden de los avisos. Si se abre una segunda fila, la primera se cerrará y ambos bloques pueden actualizar el mismo estado. Una asignación directa a nil desde la fila anterior podría borrar el identificador recién establecido. Cuando esa secuencia sea relevante, se puede proteger la escritura:
onPresentationChanged: { isPresented in
if isPresented {
activeItemID = item.id
} else if activeItemID == item.id {
activeItemID = nil
}
}
LazyVStack, rejillas y layouts propios
El nuevo contenedor no está limitado a un stack vertical. Apple presenta la función como soporte para vistas situadas en contenedores como pilas, cuadrículas y layouts personalizados. La condición esencial es que formen parte del ámbito del ScrollView configurado con swipeActionsContainer().
En colecciones largas, LazyVStack o LazyVGrid evitan construir de una vez toda la jerarquía visible y no visible. Esta característica es independiente de las acciones de swipe, pero ambas APIs encajan bien: una controla cuándo se materializa el contenido y la otra coordina su interacción. El estado importante de una fila no debería residir únicamente en una vista que el contenedor diferido puede retirar al salir de pantalla; debe vivir en el modelo, en un Binding o en una vista exterior con ciclo de vida adecuado.
Que una cuadrícula acepte la API no significa que siempre resulte una interacción apropiada. En una tarjeta demasiado estrecha, el desplazamiento horizontal puede competir con un carrusel anidado, un control deslizante o un gesto propio. También puede resultar difícil anticipar que una celda contiene acciones si el diseño no se parece demasiado a una lista. La disponibilidad técnica no sustituye una evaluación del gesto, su facilidad para descubrirlo y su accesibilidad.
Compatibilidad con sistemas anteriores
Tanto swipeActionsContainer() como la variante con onPresentationChanged requieren iOS 27. Una comprobación de disponibilidad colocada únicamente alrededor del modificador de contenedor puede ser incómoda porque produce tipos de vista diferentes. Una vista auxiliar mantiene clara la bifurcación:
extension View {
@ViewBuilder
func customSwipeActionsIfAvailable() -> some View {
if #available(iOS 27, *) {
swipeActionsContainer()
} else {
self
}
}
}
Después puede aplicarse al ScrollView:
ScrollView {
ReadingCards(items: items)
}
.customSwipeActionsIfAvailable()
En versiones anteriores, este enfoque conserva el layout pero no ofrece las acciones. Eso solo es aceptable si las mismas operaciones permanecen accesibles mediante botones visibles, un menú contextual o una pantalla de detalle. Si el deslizamiento es la única forma de borrar o archivar, la interfaz pierde funcionalidad para versiones anteriores sin que el usuario lo perciba.
Otra estrategia consiste en usar List para sistemas anteriores y el diseño personalizado a partir de iOS 27. Esta alternativa mantiene el gesto, pero obliga a sostener dos implementaciones y puede producir diferencias visuales. Conviene extraer la fila y las operaciones compartidas para que la bifurcación afecte solo al contenedor.
Errores habituales y decisiones de diseño
⚠️ Aplicar swipeActions a las tarjetas sin añadir swipeActionsContainer() al ScrollView reproduce el fallo silencioso de versiones anteriores: el código parece correcto, pero el gesto no se activa. Colocar el contenedor en un nivel demasido lejano también dificulta saber qué región coordina las acciones; es preferible mantenerlo junto al ScrollView correspondiente.
Las acciones destructivas deberían declarar role: .destructive, ofrecer una etiqueta comprensible y actualizar una fuente de verdad estable. Si una eliminación necesita confirmación, es mejor presentar un confirmationDialog o trasladar la decisión a una acción intermedia que confiar en que el gesto completo sea suficientemente explícito.
Las acciones laterales son un atajo, no una descripción completa de la funcionalidad. Sus botones deben tener etiquetas con significado para tecnologías de accesibilidad y las operaciones importantes deberían disponer de una alternativa también accesible. Un menú contextual puede complementar el gesto, mientras que un botón visible será más adecuado cuando la acción sea importante o deba descubrirse inmediatamente.
También conviene probar la interacción en un dispositivo real: el reconocimiento del gesto, el área efectiva de cada tarjeta, la convivencia con el desplazamiento y el coste de construir las filas no se evalúan bien solo con una captura estática. Para colecciones grandes, Instruments y la plantilla de SwiftUI permiten comprobar si el layout dinámico está evitando trabajo o si las vistas realizan cálculos costosos durante el desplazamiento.
Una separación que amplía las opciones de diseño
swipeActionsContainer() no reemplaza a List; elimina la necesidad de elegirlo únicamente para obtener acciones de desplazamiento lateral. List continúa siendo la opción natural para colecciones con semántica y apariencia de lista, mientras que ScrollView con pilas, cuadrículas o layouts propios ofrece más control sobre la composición.
La práctica recomendada es mantener clara la separación: las filas declaran acciones, el ScrollView coordina su presentación y el modelo conserva los cambios duraderos. Con esa estructura, iOS 27 permite crear colecciones personalizadas que preservan una interacción del sistema sin asumir el aspecto ni las restricciones de una lista tradicional.