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.
- Posición: Generalmente en la parte inferior (flotando sobre el TabBar) o en la parte superior (estilo Isla Dinámica).
- Apariencia: Fondo difuminado (Blur Material) o color sólido con alto contraste, esquinas redondeadas y sombra sutil.
- Contenido: Un icono (SF Symbol) y un mensaje corto.
- 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:
- Superponer el Toast sobre el contenido actual.
- Gestionar la animación de entrada y salida.
- 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 .fullScreenCover, la 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:
- ZStack Local: Usar el
ViewModifierdentro del contenido de la hoja también. - UIWindow Overlay (Avanzado): Crear un
PassThroughWindowenSceneDelegate(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:
- Copia el
struct ToastViewpara definir el diseño. - Copia el
enum ToastStylepara la gestión de temas. - Decide tu arquitectura:
- Si es una app pequeña: Usa el
ViewModifiery la extensión.toast(). - Si es una app mediana/grande: Crea el
ToastManagery coloca el overlay en tuApp.swift.
- Si es una app pequeña: Usa el
- 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










