Swift and SwiftUI tutorials for Swift Developers

How to show a toast notification or banner in SwiftUI

In the mobile interface design ecosystem, feedback is king. When a user performs an action—saving a file, sending a message, or deleting an item—they expect immediate confirmation.

This is where the Toast (or Toast Banner) comes in. Unlike an Alert, which interrupts the flow and demands a click, or a Sheet, which changes the context, the Toast is ephemeral, non-blocking, and purely informative. It’s that small rounded rectangle that appears, says “Saved successfully,” and fades away.

Although Android has had this natively forever, in iOS and SwiftUI, we must build it ourselves. In this tutorial about display a toast notification or banner in SwiftUI, we won’t just make text appear; we will build a robust, animated, accessible, and reusable notification architecture.


1. Anatomy of a Perfect Toast

Before writing code, we must define what makes a Toast feel “native” on iOS, even though it isn’t.

  1. Position: Generally at the bottom (floating above the TabBar) or at the top (Dynamic Island style).
  2. Appearance: Blurred background (Blur Material) or solid color with high contrast, rounded corners, and subtle shadow.
  3. Content: An icon (SF Symbol) and a short message.
  4. Behavior: Appears with a smooth animation, stays for 2-4 seconds, and disappears automatically.

Step 1: Designing the View (ToastView)

We’ll start by creating the visual component. Don’t worry about the appearance logic yet, let’s just design the “capsule”.

import SwiftUI

struct ToastView: View {
    // Configurable properties
    var style: ToastStyle
    var message: String
    var onCancelTapped: (() -> Void)?
    
    var body: some View {
        HStack(alignment: .center, spacing: 12) {
            // 1. Icon based on style
            Image(systemName: style.iconFileName)
                .font(.system(size: 20)) // Appropriate size
                .foregroundColor(style.themeColor)
            
            // 2. Main message
            Text(message)
                .font(.subheadline) // Legible but not invasive typography
                .foregroundColor(Color.primary)
                .multilineTextAlignment(.leading)
            
            Spacer(minLength: 10)
            
            // 3. Optional close button (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) // Native iOS translucent effect
        .clipShape(Capsule()) // Rounded shape
        .shadow(color: Color.black.opacity(0.15), radius: 5, x: 0, y: 2) // Depth
        .padding(.horizontal, 20) // Side safety margin
    }
}

Defining Styles (ToastStyle)

To make the component reusable, we shouldn’t “hardcode” colors or icons. We will use an enum to define the message types.

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. The Magic of Modifiers (ViewModifier)

The beginner’s mistake is putting the ToastView inside a ZStack on every single screen of the app. This clutters the code and makes it hard to maintain.

The “SwiftUI way” to do this is by creating a ViewModifier. This allows us to use our Toast as easily as we use a .sheet or an .alert.

Creating the ToastModifier

This modifier will handle:

  1. Overlaying the Toast on top of the current content.
  2. Managing the entrance and exit animations.
  3. Positioning it correctly.
struct ToastModifier: ViewModifier {
    @Binding var isPresented: Bool
    let style: ToastStyle
    let message: String
    let duration: TimeInterval
    
    // Internal state for the work item
    @State private var workItem: DispatchWorkItem?
    
    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .overlay(
                ZStack {
                    if isPresented {
                        VStack {
                            Spacer() // Pushes the toast down
                            
                            ToastView(
                                style: style, 
                                message: message,
                                onCancelTapped: {
                                    dismissToast()
                                }
                            )
                            .padding(.bottom, 50) // Space for TabBar or Home Indicator
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                            .onAppear {
                                // Start auto-hide timer
                                scheduleDismissal()
                            }
                        }
                        .zIndex(1) // Ensures it's always on top
                    }
                }
                .animation(.spring(response: 0.5, dampingFraction: 0.7), value: isPresented)
            )
            // Important: If the toast changes, restart the timer
            .onChange(of: isPresented) { presented in
                if presented {
                    scheduleDismissal()
                }
            }
    }
    
    private func scheduleDismissal() {
        // Cancel any pending task to avoid conflicts
        workItem?.cancel()
        
        let task = DispatchWorkItem {
            withAnimation {
                isPresented = false
            }
        }
        
        workItem = task
        // Execute after 'duration' seconds
        DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: task)
    }
    
    private func dismissToast() {
        withAnimation {
            isPresented = false
        }
        workItem?.cancel()
    }
}

The View Extension

To make usage elegant, we extend 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. Basic Implementation

Now that we have the pieces, let’s see how it’s used in a real view.

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

    var body: some View {
        VStack(spacing: 20) {
            Button("Show Success") {
                toastType = .success
                showToast = true
            }
            
            Button("Show Error") {
                toastType = .error
                showToast = true
            }
        }
        .toast(
            isPresented: $showToast,
            message: toastType == .success ? "Data saved" : "Connection error",
            style: toastType
        )
    }
}

4. Advanced Architecture: Centralization

The approach above has a problem: it requires a @State variable in every view where you want to show a Toast. In a large application, this is tedious. Ideally, you want to trigger the toast from anywhere, even from a ViewModel, without tying it to the local view.

To achieve this, we will use the Environment Object pattern or an Observable Singleton.

The ToastManager

We will create a class that manages the Toast state for the entire application.

class ToastManager: ObservableObject {
    // Singleton for easy access (optional, but useful)
    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) {
        // First ensure reset if one is already showing
        withAnimation {
            self.isPresented = false
        }
        
        // Small delay to allow exit animation if there was a previous one
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.message = message
            self.style = style
            withAnimation {
                self.isPresented = true
            }
        }
    }
}

Injection at the Root (App)

We modify the application entry point to inject this manager and place the Toast Overlay just once at the highest level.

@main
struct MyApp: App {
    @StateObject var toastManager = ToastManager.shared
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                // Inject the overlay HERE, at the root
                .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) // Available to everyone
        }
    }
}

Usage from any ViewModel

Now, any part of your logic can trigger a toast without knowing anything about the view.

class SettingsViewModel: ObservableObject {
    func deleteAccount() {
        // Deletion logic...
        
        // Notify the user
        ToastManager.shared.show(
            message: "Account deleted successfully",
            style: .success
        )
    }
}

5. Polish and UX: Taking it to the Professional Level

A basic tutorial ends above. A professional tutorial cares about the details.

A. Haptic Feedback (iOS 17+)

A visual Toast is good, but one that is “felt” is better. SwiftUI introduced .sensoryFeedback.

// In your ToastModifier or root view
.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
}

This will cause the phone to vibrate subtly with the correct pattern based on the message type.

B. Interaction Gestures (Drag to Dismiss)

Sometimes the user wants to remove the message immediately because it’s covering a button. Adding a drag gesture is vital.

ToastView(...)
    .gesture(
        DragGesture(minimumDistance: 20, coordinateSpace: .local)
            .onEnded { value in
                // If user swipes down (positive Y value)
                if value.translation.height > 0 {
                    dismiss()
                }
            }
    )

C. Keyboard Avoidance

One of the classic “Bottom Toasts” problems is that the keyboard covers them. To solve this, we need to observe the keyboard height (KeyboardAvoidance).

However, a more elegant design solution is: If the keyboard is open, show the Toast at the top.

We can detect the keyboard and change alignment:

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

6. Accessibility: Don’t Forget VoiceOver

If you show a Toast and VoiceOver doesn’t announce it, your app is not accessible. Since the Toast is an overlay view that disappears, screen readers often ignore it if not forced.

We must use UIAccessibility.post(notification: .announcement).

Let’s modify our ToastManager or the modifier’s onAppear:

.onAppear {
    // Notify VoiceOver that there is an important new message
    UIAccessibility.post(notification: .announcement, argument: message)
    
    // Start timer...
}

Additionally, ensure the ToastView has the correct traits:

ToastView(...)
    .accessibilityElement(children: .combine) // Combines icon and text
    .accessibilityLabel("\(style.rawValue), \(message)") // "Error, Connection Failed"
    .accessibilityAddTraits(.isStaticText)

7. Limitations and Final Considerations

Why not use a third-party library?

Excellent libraries exist like AlertToast or ToastUI. However, for basic needs, importing an external dependency adds weight and risk to your project. As you’ve seen, we can build a robust system in less than 100 lines of code. This gives you full control over animations and design.

Conflicts with Sheet and FullScreenCover

This is the “Final Boss” of Toasts in SwiftUI. If you present a Toast in the root view (ContentView) and then open a .sheetor .fullScreenCoverthe sheet will cover the Toast, because modal sheets in iOS are new window hierarchies.

Solution: If your app uses many modals, you have two options:

  1. Local ZStack: Use the ViewModifier inside the sheet’s content as well.
  2. UIWindow Overlay (Advanced): Create a PassThroughWindow in SceneDelegate (or its modern SwiftUI equivalent) to inject the Toast into a window superior to the application’s. This guarantees the Toast floats above everything, including alerts and modal sheets. (This requires UIKit bridging code and is a topic for another advanced tutorial).

Implementation Summary

To show a Toast Notification in your project tomorrow, follow these steps:

  1. Copy the struct ToastView to define the design.
  2. Copy the enum ToastStyle for theme management.
  3. Decide your architecture:
    • For a small app: Use the ViewModifier and the .toast() extension.
    • For a medium/large app: Create the ToastManager and place the overlay in your App.swift.
  4. Don’t forget to add the accessibility line for VoiceOver.

Mastering these “invisible” components is what separates an application that simply “works” from one that feels polished, responsive, and professional.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

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

Previous Article

How to dismiss and hide the keyboard on scroll in SwiftUI

Related Posts