Alertas y diálogos de confirmación basados en datos en SwiftUI con iOS 27
Arturo Rivas Arias
SwiftUI siempre ha defendido que la interfaz debe ser una consecuencia del estado. Si existe un dato, se muestra una vista; si el dato desaparece, también lo hace su representación. Sin embargo, hasta ahora las alertas y los diálogos de confirmación no terminaban de encajar en ese modelo. En muchos casos había que combinar un Binding<Bool> con otro valor que almacenase el elemento sobre el que debía actuar la presentación.
Con iOS 27, SwiftUI incorpora nuevas variantes de los modificadores alert y confirmationDialog que reciben directamente un Binding a un valor opcional. El resultado no cambia el aspecto visual de estos componentes, pero sí permite describir su estado de una forma más segura: cuando el valor contiene datos, la presentación aparece; cuando vuelve a ser nil, desaparece.
El problema de combinar un booleano y un dato opcional
Imaginemos una aplicación de música que permite eliminar una descarga local. Antes de iOS 27, una implementación habitual necesitaba dos propiedades de estado:
struct DownloadedTrack: Identifiable {
let id: UUID
let title: String
}
struct DownloadsView: View {
@State private var trackToRemove: DownloadedTrack?
@State private var isShowingRemovalDialog = false
let tracks: [DownloadedTrack]
var body: some View {
List(tracks) { track in
Button(track.title) {
trackToRemove = track
isShowingRemovalDialog = true
}
}
.confirmationDialog(
"Eliminar descarga",
isPresented: $isShowingRemovalDialog,
titleVisibility: .visible,
presenting: trackToRemove
) { track in
Button("Eliminar", role: .destructive) {
removeDownload(track)
}
} message: { track in
Text("\(track.title) dejará de estar disponible sin conexión.")
}
}
private func removeDownload(_ track: DownloadedTrack) {
// Eliminar el fichero descargado.
}
}
El modificador presenting: ya permitía utilizar un dato para construir el contenido, pero la presentación continuaba dependiendo del booleano. Eso dejaba dos fuentes de verdad para representar un único concepto y hacía posibles estados incoherentes: el booleano podía ser true mientras el elemento era nil, o podía existir un elemento seleccionado sin que el diálogo estuviese visible.
No es solo una cuestión de escribir más líneas. Cuando la selección y la presentación se actualizan en momentos distintos, un cambio posterior puede dejar información obsoleta, mostrar un diálogo sin contexto o hacer que una acción afecte al elemento equivocado. Cuanto más compleja sea la pantalla, más difícil resulta mantener sincronizadas ambas propiedades.
Una única fuente de verdad con item
Las nuevas variantes sustituyen esa combinación por un Binding<T?>. El mismo valor determina si el diálogo debe mostrarse y proporciona los datos necesarios para generar sus acciones y su mensaje:
struct DownloadsView: View {
@State private var trackToRemove: DownloadedTrack?
let tracks: [DownloadedTrack]
var body: some View {
List(tracks) { track in
Button(track.title) {
trackToRemove = track
}
}
.confirmationDialog(
"Eliminar descarga",
item: $trackToRemove,
titleVisibility: .visible
) { track in
Button("Eliminar", role: .destructive) {
removeDownload(track)
}
Button("Cancelar", role: .cancel) { }
} message: { track in
Text("\(track.title) dejará de estar disponible sin conexión.")
}
}
private func removeDownload(_ track: DownloadedTrack) {
// Eliminar el fichero descargado.
}
}
Asignar una canción a trackToRemove presenta el diálogo. SwiftUI desempaqueta el valor y lo entrega a los bloques actions y message, por lo que no es necesario utilizar if let dentro de ellos. Cuando el usuario ejecuta una acción o descarta la presentación, el framework vuelve a establecer el Binding a nil.
Además, a diferencia de sheet(item:), el tipo almacenado no necesita adoptar Identifiable. La API solo exige un valor opcional, de modo que también puede utilizarse con estructuras sencillas, enumeraciones o tipos procedentes de una capa de dominio que no deberían incorporar una identidad únicamente por necesidades de la interfaz.
Las nuevas sobrecargas principales son estas:
confirmationDialog(_:item:titleVisibility:actions:)
confirmationDialog(_:item:titleVisibility:actions:message:)
alert(_:item:actions:)
alert(_:item:actions:message:)
alert(error:actions:)
alert(error:actions:message:)
Alertas construidas a partir de un elemento
El mismo patrón está disponible para alert. Supongamos que una aplicación de rutas detecta un conflicto al añadir una parada a un itinerario. La información del conflicto puede convertirse directamente en el estado de presentación:
struct RouteConflict {
let placeName: String
let existingDay: Int
let requestedDay: Int
}
struct ItineraryView: View {
@State private var conflict: RouteConflict?
var body: some View {
RouteEditor { place, day in
if let currentDay = scheduledDay(for: place) {
conflict = RouteConflict(
placeName: place.name,
existingDay: currentDay,
requestedDay: day
)
} else {
add(place, to: day)
}
}
.alert("La parada ya está planificada", item: $conflict) { conflict in
Button("Mover al día \(conflict.requestedDay)") {
moveStop(conflict)
}
Button("Mantener el día \(conflict.existingDay)", role: .cancel) { }
} message: { conflict in
Text(
"\(conflict.placeName) ya forma parte del día " +
"\(conflict.existingDay)."
)
}
}
private func scheduledDay(for place: Place) -> Int? {
// Consultar el itinerario.
nil
}
private func add(_ place: Place, to day: Int) { }
private func moveStop(_ conflict: RouteConflict) { }
}
La alerta ya no pregunta por separado si debe aparecer y qué conflicto debe mostrar. Es imposible presentar esta interfaz sin disponer de un RouteConflict, porque ambas cosas son el mismo estado.
Apple indica que el dato no debería cambiar mientras la alerta está presentada y que cualquier sustitución posterior se ignora para el contenido que ya está en pantalla. Por eso conviene almacenar un valor que funcione como una instantánea inmutable de la operación pendiente, en lugar de depender de varias propiedades externas que puedan cambiar mientras el usuario decide.
Presentar errores sin crear un modelo intermedio
iOS 27 también incorpora variantes especializadas que reciben un Binding a un error opcional. Si el tipo adopta LocalizedError, SwiftUI utiliza errorDescription como título de la alerta. La variante que incluye mensaje entrega el error a sus dos cierres, lo que permite construir acciones y explicaciones específicas:
enum ExportError: LocalizedError {
case noAvailableSpace(required: Int)
case destinationUnavailable
var errorDescription: String? {
switch self {
case .noAvailableSpace:
"No hay espacio suficiente"
case .destinationUnavailable:
"No se puede acceder al destino"
}
}
var recoverySuggestion: String? {
switch self {
case let .noAvailableSpace(required):
"Libera al menos \(required) MB e inténtalo de nuevo."
case .destinationUnavailable:
"Comprueba que la unidad sigue conectada."
}
}
}
struct ExportView: View {
@State private var exportError: ExportError?
var body: some View {
Button("Exportar proyecto") {
Task {
do {
try await exportProject()
} catch let error as ExportError {
exportError = error
} catch {
exportError = .destinationUnavailable
}
}
}
.alert(error: $exportError) { error in
if case .destinationUnavailable = error {
Button("Volver a intentar") {
retryExport()
}
}
Button("Cancelar", role: .cancel) { }
} message: { error in
Text(error.recoverySuggestion ?? "Inténtalo de nuevo más tarde.")
}
}
private func exportProject() async throws { }
private func retryExport() { }
}
Existe una variante sin message. En ese caso, el cierre de acciones no recibe el error porque el título generado a partir de errorDescription es toda la información contextual disponible:
.alert(error: $exportError) {
Button("Aceptar") { }
}
Este detalle evita una confusión fácil de cometer: alert(error:actions:) recibe un cierre () -> View, mientras que alert(error:actions:message:) entrega el error tanto a actions como a message.
Un enum para varias presentaciones excluyentes
El hecho de que item no exija Identifiable hace especialmente útil representar varias alertas mutuamente excluyentes mediante una enumeración. En lugar de acumular booleanos como isShowingResetAlert, isShowingSignOutAlert y isShowingRestoreAlert, la vista puede mantener una única operación pendiente:
enum PendingAccountAlert {
case resetPreferences
case restoreBackup(Date)
}
struct AccountView: View {
@State private var pendingAlert: PendingAccountAlert?
var body: some View {
Form {
Button("Restablecer preferencias", role: .destructive) {
pendingAlert = .resetPreferences
}
Button("Restaurar última copia") {
pendingAlert = .restoreBackup(.now)
}
}
.alert("Confirmar operación", item: $pendingAlert) { alert in
switch alert {
case .resetPreferences:
Button("Restablecer", role: .destructive) {
resetPreferences()
}
case let .restoreBackup(date):
Button("Restaurar copia") {
restoreBackup(from: date)
}
}
Button("Cancelar", role: .cancel) { }
} message: { alert in
switch alert {
case .resetPreferences:
Text("Se recuperarán todos los ajustes iniciales.")
case let .restoreBackup(date):
Text("Los datos actuales se sustituirán por la copia de \(date.formatted()).")
}
}
}
private func resetPreferences() { }
private func restoreBackup(from date: Date) { }
}
Esta técnica impide que dos alertas incompatibles estén activas al mismo tiempo. También concentra en un solo tipo el conjunto cerrado de operaciones que la pantalla puede presentar, algo especialmente valioso cuando el flujo crece o se traslada a un modelo @Observable.
Compatibilidad con versiones anteriores
Para compilar estas variantes hace falta Xcode 27 y el SDK correspondiente. Pero ojo, está disponible desde iOS 15 y equivalentes ya que Apple ha usado la característica de Swift @backdeployed para ofrecer la API en sistemas anteriores.
Aún así, en el código más antiguo no es obligatorio recuperar el doble estado. Puede derivarse un Binding<Bool> a partir del opcional y seguir manteniendo una única fuente de verdad:
private var isPresentingRemoval: Binding<Bool> {
Binding(
get: { trackToRemove != nil },
set: { isPresented in
if !isPresented {
trackToRemove = nil
}
}
)
}
Ese Binding puede utilizarse con la variante anterior de confirmationDialog:
.confirmationDialog(
"Eliminar descarga",
isPresented: isPresentingRemoval,
presenting: trackToRemove
) { track in
Button("Eliminar", role: .destructive) {
removeDownload(track)
}
} message: { track in
Text("\(track.title) dejará de estar disponible sin conexión.")
}
Así se conserva el modelo basado en datos incluso en la ruta de compatibilidad. La diferencia es que SwiftUI necesita recibir un booleano calculado porque las versiones anteriores del framework todavía no entienden el opcional como estado de presentación.
Elegir entre alert y confirmationDialog
La llegada de item no modifica el propósito de cada componente. Un alert sigue siendo una interrupción adecuada para comunicar información importante que requiere atención o para presentar un error con un conjunto pequeño de respuestas. Un confirmationDialog encaja mejor cuando el usuario ha iniciado una acción y debe confirmarla o elegir entre varias opciones relacionadas.
Los roles de los botones también siguen siendo relevantes. role: .destructive comunica una operación potencialmente irreversible y permite que el sistema aplique el tratamiento visual apropiado. role: .cancel identifica la salida segura y deja que cada plataforma coloque las acciones según sus convenciones. El orden escrito en el código no debe utilizarse como mecanismo para indicar importancia, porque SwiftUI puede reorganizar los botones.
En los diálogos de confirmación, titleVisibility continúa teniendo un valor predeterminado .automatic. Si el título aporta el contexto necesario para comprender una operación destructiva, establecerlo como .visible evita depender de la decisión automática de cada plataforma y tamaño de interfaz.
Un cambio pequeño con impacto arquitectónico
Las nuevas APIs no permiten crear ninguna interfaz que fuese imposible en versiones anteriores. Su valor está en eliminar estados redundantes y hacer que las presentaciones sigan el mismo principio declarativo que sheet(item:), popover(item:) o los destinos de navegación basados en datos.
Un opcional describe con precisión los dos estados posibles: nil significa que no hay nada que presentar y un valor concreto contiene todo el contexto de la alerta o el diálogo. Esa representación reduce código erróneo, evita combinaciones no válidas y facilita mover el estado desde una vista hasta un modelo observable sin perder claridad.
Mientras iOS 27 permanezca en fase beta, conviene comprobar estas firmas con cada nueva versión de Xcode y probar su comportamiento en los distintos sistemas. La dirección de la API, sin embargo, es clara: en SwiftUI, una presentación temporal también debe ser una consecuencia directa de los datos.