Alertas y confirmation dialogs por item en SwiftUI: menos booleanos, menos estados imposibles
Arturo Rivas Arias
SwiftUI lleva años empujándonos hacia una idea muy concreta: la interfaz debe ser una consecuencia directa del estado. Si hay datos, se muestra una vista. Si esos datos desaparecen, la vista también desaparece. Es una idea sencilla que cuando se aplica bien elimina mucha lógica accidental y hace que la navegación de la aplicación sea predecible.
Hasta ahora, esa filosofía estaba muy clara en APIs como sheet(item:), popover(item:) o navigationDestination(item:). En todas ellas, la presentación depende de un valor opcional. Cuando el valor pasa a ser distinto de nil, SwiftUI presenta el contenido. Cuando la presentación se cierra, el valor vuelve a nil.
Sin embargo, había dos piezas muy usadas que no terminaban de encajar del todo en ese modelo: alert y confirmationDialog.
En iOS 27, SwiftUI añade nuevas sobrecargas del modificador basadas en item para alertas y diálogos de confirmación. No es una revolución visual, porque no aparece ningún componente nuevo en pantalla pero la mejora está en el modelo de estado: podemos dejar de coordinar manualmente un valor opcional y un booleano que, en realidad, representan la misma lógica.
El problema de combinar un item y un booleano
En muchas pantallas SwiftUI es habitual encontrar código como este:
@State private var selectedReservation: Reservation?
@State private var showingCancelDialog = false
La intención es clara: selectedReservation indica qué reserva se va a cancelar y showingCancelDialog indica si debe mostrarse el diálogo. El problema es que ambos estados no son independientes. El diálogo solo tiene sentido si existe una reserva seleccionada, y una reserva seleccionada normalmente implica que hay una presentación asociada.
Ese diseño permite estados imposibles:
selectedReservation = nil
showingCancelDialog = true
En ese caso el diálogo se muestra, pero no tiene datos reales sobre los que actuar.
También permite el estado contrario:
selectedReservation = reservation
showingCancelDialog = false
Aquí tenemos una reserva seleccionada, pero la interfaz no está mostrando nada. Quizá sea correcto durante unos milisegundos, quizá sea un bug, o quizá sea una situación transitoria en la que nadie se fijó después de una refactorización. El problema no es que el código no funcione. El problema es que el modelo permite representar situaciones que la aplicación no debería poder tener.
El nuevo modelo: la presentación nace del dato
Las nuevas APIs de iOS 27 permiten expresar esta relación de forma directa:
@State private var reservationToCancel: Reservation?
Ya no necesitamos un segundo @State booleano. Si reservationToCancel tiene valor, SwiftUI presenta el diálogo. Si es nil, no hay diálogo. Cuando el usuario cancela o descarta la presentación, SwiftUI vuelve a dejar el binding en nil.
Ese detalle es importante: no solo reducimos código, también reducimos errores de lógica y estados no consistentes. No hace falta recordar que hay que poner el item a nil al cerrar puesto que ahora presentación queda ligada al ciclo de vida del dato.
confirmationDialog(item:) en la práctica
Imaginemos una app para gestionar reservas de salas de reuniones. Cada reserva puede cancelarse, pero queremos pedir confirmación antes de hacerlo.
struct Reservation: Identifiable {
let id: UUID
let roomName: String
let startsAt: Date
}
Con el nuevo enfoque, la vista solo necesita guardar la reserva pendiente de cancelación:
struct ReservationsView: View {
@State private var reservations: [Reservation] = []
@State private var reservationToCancel: Reservation?
var body: some View {
List(reservations) { reservation in
HStack {
VStack(alignment: .leading) {
Text(reservation.roomName)
.font(.headline)
Text(reservation.startsAt, format: .dateTime.hour().minute())
.foregroundStyle(.secondary)
}
Spacer()
Button("Cancelar", role: .destructive) {
reservationToCancel = reservation
}
}
}
.confirmationDialog(
"Cancelar reserva",
item: $reservationToCancel,
titleVisibility: .visible
) { reservation in
Button("Cancelar \(reservation.roomName)", role: .destructive) {
cancel(reservation)
}
Button("Mantener reserva", role: .cancel) { }
} message: { reservation in
Text("La reserva de \(reservation.roomName) se eliminará de tu calendario.")
}
}
private func cancel(_ reservation: Reservation) {
reservations.removeAll { $0.id == reservation.id }
}
}
La diferencia puede parecer pequeña, pero cambia bastante el mantenimiento de la vista. El botón no activa un booleano. El botón selecciona el dato que desencadena la aparición del diálogo. La presentación deja de ser un estado paralelo y pasa a formar parte del propio flujo de datos.
Además, el bloque de código de la acción recibe directamente el item no opcional. Ya no hace falta escribir if let, ni protegerse contra una situación en la que el diálogo está visible pero el dato se ha perdido por el camino.
Alertas basadas en item
El mismo patrón llega también a alert. Esto encaja muy bien cuando una alerta necesita contexto específico, pero no necesariamente representa un tipo Error de Swift.
Por ejemplo, una app de lectura podría avisar al usuario cuando intenta añadir un libro que ya existe en su biblioteca:
struct DuplicateBook: Identifiable {
let id = UUID()
let title: String
let existingShelf: String
}
La vista puede almacenar el aviso pendiente como un valor opcional:
struct AddBookView: View {
@State private var title = ""
@State private var duplicateBook: DuplicateBook?
var body: some View {
Form {
TextField("Título", text: $title)
Button("Añadir libro") {
addBook()
}
}
.alert(
"Libro ya añadido",
item: $duplicateBook
) { duplicate in
Button("Mover a esta estantería") {
moveBook(named: duplicate.title)
}
Button("Cancelar", role: .cancel) { }
} message: { duplicate in
Text("\(duplicate.title) ya existe en la estantería \(duplicate.existingShelf).")
}
}
private func addBook() {
guard title == "The Swift Programming Language" else {
return
}
duplicateBook = DuplicateBook(
title: title,
existingShelf: "Programación"
)
}
private func moveBook(named title: String) {
// Actualizar biblioteca
}
}
De nuevo, no hay booleano. La alerta existe porque hay un DuplicateBook. Cuando deja de existir, la alerta deja de tener sentido.
Este modelo también mejora la lectura del código. Al revisar la vista, se entiende rápidamente qué situaciones pueden provocar presentaciones: basta con mirar los opcionales de estado que representan destinos, errores o acciones pendientes.
Alertas de error
iOS 27 también añade variantes específicas para errores. En lugar de conformar un error en un tipo Identifiable solo para poder presentarlo, podemos usar directamente un binding opcional a un tipo que conforme a Error.
struct DocumentExportView: View {
enum ExportError: Error {
case missingPermission
case diskFull
case unsupportedFormat
}
@State private var exportError: ExportError?
var body: some View {
Button("Exportar informe") {
export()
}
.alert(error: $exportError) { error in
switch error {
case .missingPermission:
Button("Abrir ajustes") {
openSettings()
}
case .diskFull:
Button("Gestionar almacenamiento") {
openStorageSettings()
}
case .unsupportedFormat:
Button("Aceptar", role: .cancel) { }
}
} message: { error in
switch error {
case .missingPermission:
Text("La app no tiene permiso para guardar el informe en la ubicación seleccionada.")
case .diskFull:
Text("No hay espacio suficiente para completar la exportación.")
case .unsupportedFormat:
Text("El formato elegido no es compatible con este documento.")
}
}
}
private func export() {
exportError = .diskFull
}
private func openSettings() {
// Abrir preferencias de la app
}
private func openStorageSettings() {
// Guiar al usuario hacia gestión de almacenamiento
}
}
Este patrón es especialmente cómodo cuando el error no solo necesita un mensaje, sino también acciones distintas según el caso. Un error de permisos puede ofrecer abrir ajustes. Un error de almacenamiento puede guiar hacia otra pantalla. Un formato no compatible quizá solo necesite un botón de aceptar.
Por qué esto reduce bugs reales
La mejora más evidente es que escribimos menos código. Pero la más importante es que reducimos las inconsistencias y los estados no deseados.
Cuando una presentación depende de dos propiedades distintas, siempre hay que mantenerlas sincronizadas. Eso suele funcionar al principio, pero se vuelve frágil cuando la pantalla crece y la lógia se complica. Aparecen cargas asíncronas, acciones destructivas, navegación, cancelaciones, cambios de selección y cierres automáticos.
Con un único opcional, el contrato es mucho más fuerte. El valor no solo dice que hay que presentar algo, también contiene la información necesaria para construir esa presentación.
Esto encaja con una regla muy útil en SwiftUI: cuando dos estados siempre cambian juntos, probablemente no deberían ser dos estados.
Cuándo seguir usando isPresented
Estas nuevas APIs no hacen que isPresented desaparezca. Sigue siendo perfectamente válido cuando la presentación no necesita datos asociados.
Por ejemplo, una alerta genérica de confirmación para restaurar los ajustes por defecto podría seguir usando un booleano si no hay ningún item contextual:
@State private var showingResetWarning = false
Si la única pregunta es “¿se muestra o no se muestra?”, un booleano puede ser suficiente.
Pero en cuanto la presentación necesite saber qué elemento se está modificando, eliminando, duplicando o mostrando, el modelo basado en item suele ser más expresivo y más seguro.
Un detalle importante: el item debe ser identificable
Como ocurre con sheet(item:), el valor opcional debe poder identificarse para que el motor de SwiftUI funcione. En la práctica, esto significa que normalmente usaremos tipos que conformen a Identifiable.
struct PendingAction: Identifiable {
let id = UUID()
let title: String
}
Conviene elegir bien esa identidad. Si el item representa un modelo persistente, lo normal es usar su identificador real. Si representa un evento puntual de interfaz, como un aviso temporal, puede tener sentido generar un UUID propio al vuelo.
Lo importante es que la identidad describa correctamente cuándo SwiftUI debe considerar que está ante la misma presentación y cuándo está ante una nueva.
Compatibilidad con versiones anteriores
Estas son APIS son nuevas de iOS 27, así que una app que mantenga soporte para iOS 26 o versiones anteriores tendrá que proteger su uso con comprobación de disponibilidad o mantener el patrón anterior en esas implementaciones.
if #available(iOS 27, *) {
content
.alert("Libro ya añadido", item: $duplicateBook) { duplicate in
Button("Aceptar", role: .cancel) { }
} message: { duplicate in
Text("\(duplicate.title) ya existe en tu biblioteca.")
}
} else {
content
.alert("Libro ya añadido", isPresented: $showingDuplicateBookAlert) {
Button("Aceptar", role: .cancel) { }
}
}
La rama antigua puede necesitar conservar el booleano, pero el rumbo es claro: cuando el mínimo de despliegue suba, ese estado duplicado podrá desaparecer.
Una pequeña API que mejora el diseño de estado
Lo interesante de estas nuevas presentaciones basadas en item es que no cambian lo que una app puede hacer. Ya podíamos mostrar alertas, diálogos de confirmación y errores antes de iOS 27. Lo que cambia es cómo modelamos la intención.
Antes era habitual decir: “quiero mostrar una alerta y, además, tengo este dato seleccionado”. Ahora podemos decir: “tengo este dato pendiente de resolver, por eso SwiftUI debe presentar algo”.
Esa diferencia es muy SwiftUI. Menos coordinación manual, menos estados imposibles y una relación más directa entre los datos y la interfaz.