Programación en Swift y SwiftUI para iOS Developers

Cómo mostrar una notificación toast o banner en SwiftUI

En el ecosistema de diseño de interfaces móviles, la retroalimentación es el rey. Cuando un usuario realiza una acción —guardar un archivo, enviar un mensaje o borrar un elemento— espera una confirmación inmediata.

Aquí es donde entra el Toast (o Toast Banner). A diferencia de una Alert, que interrumpe el flujo y exige un clic, o una Sheet, que cambia el contexto, el Toast es efímero, no bloqueante y puramente informativo. Es ese pequeño rectángulo redondeado que aparece, dice “Guardado con éxito”, y se desvanece.

Aunque Android ha tenido esto de forma nativa desde siempre, en iOS y SwiftUI debemos construirlo nosotros mismos. En este tutorial de 2000 palabras, no solo haremos que aparezca un texto; construiremos una arquitectura de notificaciones robusta, animada, accesible y reutilizable.


1. Anatomía de un Toast Perfecto

Antes de escribir código, debemos definir qué hace que un Toast se sienta “nativo” en iOS, aunque no lo sea.

  1. Posición: Generalmente en la parte inferior (flotando sobre el TabBar) o en la parte superior (estilo Isla Dinámica).
  2. Apariencia: Fondo difuminado (Blur Material) o color sólido con alto contraste, esquinas redondeadas y sombra sutil.
  3. Contenido: Un icono (SF Symbol) y un mensaje corto.
  4. Comportamiento: Aparece con una animación suave, permanece 2-4 segundos y desaparece automáticamente.

Paso 1: Diseñando la Vista (ToastView)

Empezaremos creando el componente visual. No te preocupes por la lógica de aparición todavía, solo diseñemos la “cápsula”.

import SwiftUI

struct ToastView: View {
    // Propiedades configurables
    var style: ToastStyle
    var message: String
    var onCancelTapped: (() -> Void)?
    
    var body: some View {
        HStack(alignment: .center, spacing: 12) {
            // 1. Icono basado en el estilo
            Image(systemName: style.iconFileName)
                .font(.system(size: 20)) // Tamaño adecuado
                .foregroundColor(style.themeColor)
            
            // 2. Mensaje principal
            Text(message)
                .font(.subheadline) // Tipografía legible pero no invasiva
                .foregroundColor(Color.primary)
                .multilineTextAlignment(.leading)
            
            Spacer(minLength: 10)
            
            // 3. Botón de cierre opcional (UX)
            if let onCancelTapped = onCancelTapped {
                Button(action: onCancelTapped) {
                    Image(systemName: "xmark")
                        .font(.system(size: 14))
                        .foregroundColor(.secondary)
                }
            }
        }
        .padding(.vertical, 12)
        .padding(.horizontal, 16)
        .background(.thinMaterial) // Efecto translúcido nativo de iOS
        .clipShape(Capsule()) // Forma redondeada
        .shadow(color: Color.black.opacity(0.15), radius: 5, x: 0, y: 2) // Profundidad
        .padding(.horizontal, 20) // Margen de seguridad lateral
    }
}

Definiendo los Estilos (ToastStyle)

Para hacer el componente reutilizable, no debemos “hardcodear” colores o iconos. Usaremos un enum para definir los tipos de mensajes.

enum ToastStyle {
    case error
    case warning
    case success
    case info
    
    var themeColor: Color {
        switch self {
        case .error: return Color.red
        case .warning: return Color.orange
        case .success: return Color.green
        case .info: return Color.blue
        }
    }
    
    var iconFileName: String {
        switch self {
        case .error: return "xmark.circle.fill"
        case .warning: return "exclamationmark.triangle.fill"
        case .success: return "checkmark.circle.fill"
        case .info: return "info.circle.fill"
        }
    }
}

2. La Magia de los Modificadores (ViewModifier)

El error del principiante es poner el ToastView dentro de un ZStack en cada una de las pantallas de la app. Esto ensucia el código y lo hace difícil de mantener.

La forma “SwiftUI” de hacer esto es creando un ViewModifier. Esto nos permitirá usar nuestro Toast tan fácilmente como usamos un .sheet o un .alert.

Creando el ToastModifier

Este modificador se encargará de:

  1. Superponer el Toast sobre el contenido actual.
  2. Gestionar la animación de entrada y salida.
  3. Posicionarlo correctamente.
struct ToastModifier: ViewModifier {
    @Binding var isPresented: Bool
    let style: ToastStyle
    let message: String
    let duration: TimeInterval
    
    // Estado interno para la animación de trabajo
    @State private var workItem: DispatchWorkItem?
    
    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .overlay(
                ZStack {
                    if isPresented {
                        VStack {
                            Spacer() // Empuja el toast hacia abajo
                            
                            ToastView(
                                style: style, 
                                message: message,
                                onCancelTapped: {
                                    dismissToast()
                                }
                            )
                            .padding(.bottom, 50) // Espacio para el TabBar o Home Indicator
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                            .onAppear {
                                // Iniciar temporizador de auto-ocultado
                                scheduleDismissal()
                            }
                        }
                        .zIndex(1) // Asegura que esté siempre encima
                    }
                }
                .animation(.spring(response: 0.5, dampingFraction: 0.7), value: isPresented)
            )
            // Importante: Si el toast cambia, reiniciamos el temporizador
            .onChange(of: isPresented) { presented in
                if presented {
                    scheduleDismissal()
                }
            }
    }
    
    private func scheduleDismissal() {
        // Cancelamos cualquier tarea pendiente para evitar conflictos
        workItem?.cancel()
        
        let task = DispatchWorkItem {
            withAnimation {
                isPresented = false
            }
        }
        
        workItem = task
        // Ejecutamos después de 'duration' segundos
        DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: task)
    }
    
    private func dismissToast() {
        withAnimation {
            isPresented = false
        }
        workItem?.cancel()
    }
}

La Extensión de View

Para que el uso sea elegante, extendemos View.

extension View {
    func toast(
        isPresented: Binding<Bool>,
        message: String,
        style: ToastStyle = .info,
        duration: TimeInterval = 3.0
    ) -> some View {
        self.modifier(ToastModifier(
            isPresented: isPresented,
            style: style,
            message: message,
            duration: duration
        ))
    }
}

3. Implementación Básica

Ahora que tenemos las piezas, veamos cómo se usa en una vista real.

struct ContentView: View {
    @State private var showToast = false
    @State private var toastType: ToastStyle = .success

    var body: some View {
        VStack(spacing: 20) {
            Button("Mostrar Éxito") {
                toastType = .success
                showToast = true
            }
            
            Button("Mostrar Error") {
                toastType = .error
                showToast = true
            }
        }
        .toast(
            isPresented: $showToast,
            message: toastType == .success ? "Datos guardados" : "Error de conexión",
            style: toastType
        )
    }
}

4. Arquitectura Avanzada: Centralización

El enfoque anterior tiene un problema: requiere una variable @State en cada vista donde quieras mostrar un Toast. En una aplicación grande, esto es tedioso. Lo ideal es poder llamar al toast desde cualquier lugar, incluso desde un ViewModel, sin atarlo a la vista local.

Para lograr esto, usaremos el patrón de Environment Object o un Singleton Observable.

El ToastManager

Crearemos una clase que gestione el estado del Toast para toda la aplicación.

class ToastManager: ObservableObject {
    // Singleton para acceso fácil (opcional, pero útil)
    static let shared = ToastManager()
    
    @Published var isPresented: Bool = false
    @Published var message: String = ""
    @Published var style: ToastStyle = .info
    
    func show(message: String, style: ToastStyle = .info) {
        // Primero aseguramos que se resetee si ya hay uno
        withAnimation {
            self.isPresented = false
        }
        
        // Pequeño delay para permitir la animación de salida si hubo uno previo
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.message = message
            self.style = style
            withAnimation {
                self.isPresented = true
            }
        }
    }
}

Inyección en la Raíz (App)

Modificamos el punto de entrada de la aplicación para inyectar este manager y colocar el Toast Overlay una sola vez en el nivel más alto.

@main
struct MiApp: App {
    @StateObject var toastManager = ToastManager.shared
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                // Inyectamos el overlay AQUI, en la raíz
                .overlay(alignment: .bottom) {
                    if toastManager.isPresented {
                        ToastView(
                            style: toastManager.style,
                            message: toastManager.message
                        )
                        .padding(.bottom, 50)
                        .transition(.move(edge: .bottom).combined(with: .opacity))
                        .onAppear {
                            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                                withAnimation {
                                    toastManager.isPresented = false
                                }
                            }
                        }
                    }
                }
                .environmentObject(toastManager) // Disponible para todos
        }
    }
}

Uso desde cualquier ViewModel

Ahora, cualquier parte de tu lógica puede disparar un toast sin saber nada de la vista.

class SettingsViewModel: ObservableObject {
    func deleteAccount() {
        // Lógica de borrado...
        
        // Notificar al usuario
        ToastManager.shared.show(
            message: "Cuenta eliminada correctamente",
            style: .success
        )
    }
}

5. Pulido y UX: Llevándolo al Nivel Profesional

Un tutorial básico termina arriba. Un tutorial profesional se preocupa por los detalles.

A. Feedback Háptico (iOS 17+)

Un Toast visual es bueno, pero uno que se “siente” es mejor. SwiftUI introdujo .sensoryFeedback.

// En tu ToastModifier o vista raíz
.sensoryFeedback(trigger: isPresented) { oldValue, newValue in
    if newValue {
        switch style {
        case .error: return .error
        case .success: return .success
        case .warning: return .warning
        case .info: return .selection
        }
    }
    return nil
}

Esto hará que el teléfono vibre sutilmente con el patrón correcto según el tipo de mensaje.

B. Gestos de Interacción (Drag to Dismiss)

A veces el usuario quiere quitar el mensaje inmediatamente porque le tapa un botón. Añadir un gesto de arrastre es vital.

ToastView(...)
    .gesture(
        DragGesture(minimumDistance: 20, coordinateSpace: .local)
            .onEnded { value in
                // Si el usuario desliza hacia abajo (valor positivo en Y)
                if value.translation.height > 0 {
                    dismiss()
                }
            }
    )

C . Evitar el Teclado

Uno de los problemas clásicos de las “Bottom Toasts” es que el teclado las tapa. Para solucionar esto, necesitamos observar la altura del teclado (KeyboardAvoidance).

Sin embargo, una solución de diseño más elegante es: Si el teclado está abierto, muestra el Toast arriba.

Podemos detectar el teclado y cambiar la alineación:

.overlay(alignment: isKeyboardVisible ? .top : .bottom) { ... }

6. Accesibilidad: No olvides a VoiceOver

Si muestras un Toast y VoiceOver no lo anuncia, tu app no es accesible. Como el Toast es una vista superpuesta que desaparece, los lectores de pantalla a menudo la ignoran si no se les fuerza.

Debemos usar UIAccessibility.post(notification: .announcement).

Modifiquemos nuestro ToastManager o el onAppear del modificador:

.onAppear {
    // Avisar a VoiceOver que hay un nuevo mensaje importante
    UIAccessibility.post(notification: .announcement, argument: message)
    
    // Iniciar temporizador...
}

Además, asegúrate de que el ToastView tenga los traits correctos:

ToastView(...)
    .accessibilityElement(children: .combine) // Combina icono y texto
    .accessibilityLabel("\(style.rawValue), \(message)") // "Error, Fallo de conexión"
    .accessibilityAddTraits(.isStaticText)

7. Limitaciones y Consideraciones Finales

¿Por qué no usar una librería de terceros?

Existen librerías excelentes como AlertToast o ToastUI. Sin embargo, para necesidades básicas, importar una dependencia externa añade peso y riesgo a tu proyecto. Como has visto, podemos construir un sistema robusto en menos de 100 líneas de código. Esto te da control total sobre las animaciones y el diseño.

Conflictos con Sheet y FullScreenCover

Este es el “Jefe Final” de los Toasts en SwiftUI. Si presentas un Toast en la vista raíz (ContentView) y luego abres una .sheet o .fullScreenCoverla hoja tapará el Toast, porque las hojas modales en iOS son nuevas jerarquías de ventanas.

Solución: Si tu app usa muchas modales, tienes dos opciones:

  1. ZStack Local: Usar el ViewModifier dentro del contenido de la hoja también.
  2. UIWindow Overlay (Avanzado): Crear un PassThroughWindow en SceneDelegate (o su equivalente en SwiftUI moderno) para inyectar el Toast en una ventana superior a la de la aplicación. Esto garantiza que el Toast flote sobre todo, incluidas las alertas y hojas modales. (Esto requiere código UIKit bridging y es tema para otro tutorial avanzado).

Resumen de Implementación

Para mostrar una Toast Notification en tu proyecto mañana mismo, sigue estos pasos:

  1. Copia el struct ToastView para definir el diseño.
  2. Copia el enum ToastStyle para la gestión de temas.
  3. Decide tu arquitectura:
    • Si es una app pequeña: Usa el ViewModifier y la extensión .toast().
    • Si es una app mediana/grande: Crea el ToastManager y coloca el overlay en tu App.swift.
  4. No olvides añadir la línea de accesibilidad para VoiceOver.

Dominar estos componentes “invisibles” es lo que separa a una aplicación que simplemente “funciona” de una que se siente pulida, receptiva y profesional.

Si tienes cualquier duda sobre este artículo, contacta conmigo y estaré encantado de ayudarte 🙂. Puedes contactar conmigo en mi perfil de X o en mi perfil de Instagram

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Cómo ocultar el teclado al hacer scroll en SwiftUI

Next Article

Los mejores asistentes de IA para Xcode

Related Posts