3

I’m working on an iOS app that is mostly UIKit, but I’m adding new screens using SwiftUI. I want to show a toast/banner message above everything—including the navigation bar—when a user performs an action on a SwiftUI screen. In UIKit, I would add a subview to the UIWindow or the topmost UIViewController’s view to achieve this, and I could control whether the underlying view is interactable.

In SwiftUI, I tried using a custom ViewModifier with .fullScreenCover to present my toast, but this always blocks interaction with the underlying view (like a modal), and sometimes doesn’t overlay the navigation bar as expected.

Here’s a simplified version of my code:

// ToastView: A simple toast UI component
struct ToastView: View {
    let title: String
    var body: some View {
        Text(title)
            .padding()
            .background(Color.blue)
            .clipShape(Capsule())
    }
}

// ToastPresenter: ViewModifier to present ToastView using fullScreenCover
// The intention is to show a toast over the entire screen, including the navigation bar,
// but keep the underlying view interactable while the toast is visible.
struct ToastPresenter: ViewModifier {
    @Binding var isPresented: Bool
    
    func body(content: Content) -> some View {
        content
            // PROBLEM: fullScreenCover presents above the entire view hierarchy,
            // but in SwiftUI NavigationStack, it appears above the presented view,
            // not above the navigation bar. Also, the underlying view is always blocked.
            .fullScreenCover(isPresented: $isPresented) {
                ZStack(alignment: .top) {
                    Color.clear // Transparent background
                    ToastView(title: "Hello World")
                }
                // The toast does overlay the navigation bar as expected but underlying
                // views are not interactable. This is the main issue.
            }
    }
}

// Extension to easily apply the ToastPresenter modifier
extension View {
    func message(isPresented: Binding<Bool>) ->  some View {
        self.modifier(ToastPresenter(isPresented: isPresented))
    }
}

// My app is on UIkit, and I am adding new screens in SwiftUI and want to show toast on it. 
// Consider this view as UIKit ViewController and the SwiftUI view as a new screen in the app.
struct ContentView: View {
    
    var body: some View {
        NavigationStack {
            NavigationLink("Inner Screen") {
                ScreenOne()
            }
        }
        .padding()
    }
}

// ScreenOne: Example screen to trigger the toast
struct ScreenOne: View {
    
    @State private var isPresented: Bool = false
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Button {
                isPresented = true // Show the toast
            } label: {
                Text("Show the message")
            }
        }
        .message(isPresented: $isPresented)
    }
}

// Preview for SwiftUI canvas
#Preview {
    ContentView()
}

What I want:

  • Show a toast/banner above the navigation bar (like a global overlay).
  • Optionally allow the underlying view to remain interactable (non-blocking), or block it if needed.
  • Mimic the UIKit pattern of adding a subview to the window, but in SwiftUI.

What I’ve tried:

  • .fullScreenCover (blocks interaction, acts like a modal, not what I want)
  • .sheet (same issue)
  • ZStack overlays (don’t cover the navigation bar if placed inside NavigationStack)

Question: How can I present a toast/banner in SwiftUI above the navigation bar, similar to adding a subview to UIWindow in UIKit, and control whether the underlying view is interactable?

Any best practices or workarounds for this scenario in a mixed UIKit/SwiftUI app would be appreciated!

4
  • There are a ton of packages out there that do this, the approaches the same than with UIKit, they just have SwiftUI interfacing set up. Commented May 22 at 11:31
  • Your question is also containing the right approach. Did you try to wrap a view that represents a toast message into a UIHostingController into a Window? Commented May 22 at 11:38
  • Adding a subview to the UIWindow should still work with SwiftUI. Commented May 22 at 12:38
  • Yes but that blocks the underlying view. @Sweeper Commented May 23 at 11:15

2 Answers 2

2

One way to implement this using pure SwiftUI is to show the toast message as an overlay over the NavigationStack. The only issue is to find a mechanism for setting the message.

  • A state variable could be used in the parent view. This can be passed as a binding to all child views that might need to present a message.
  • Instead of a state variable, a model could be set as an environment object. This could then be accessed and updated by child views.
  • Alternatively, a preference value can be used to pass a value back up to the parent view. However, this will be cleared as soon as the child view disappears. This might or might not be desirable.

The type of the variable would depend on how similar or different the messages will be to each other:

  • If the toast messages might have varying forms or styles then an enum with an associated value (= payload) might be suitable.
  • For the simple case of a text message, the value can just be an optional string.

Here is the updated example to show it working for the case of a simple text message being passed as a preference value:

private struct ToastMessageKey: PreferenceKey {
    static let defaultValue: String? = nil
    static func reduce(value: inout String?, nextValue: () -> String?) {
        value = nextValue() ?? value
    }
}

extension View {
    func message(text: String?) -> some View {
        preference(key: ToastMessageKey.self, value: text)
    }
}

struct ToastView: View {
    let title: String
    var body: some View {
        Text(title)
            .padding()
            .background(.blue, in: .capsule)
    }
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            NavigationLink("Inner Screen") {
                ScreenOne()
            }
        }
        .padding()
        .overlayPreferenceValue(ToastMessageKey.self, alignment: .top) { text in
            if let text {
                ToastView(title: text)
                    .transition(.opacity.animation(.easeInOut))
            }
        }
    }
}

struct ScreenOne: View {
    @State private var text: String?

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Button {
                text = "Hello World"
            } label: {
                Text("Show the message")
            }
        }
        .message(text: text)
    }
}

Animation

Sign up to request clarification or add additional context in comments.

1 Comment

Thanks for the solution. The problem with my case as I mentioned is that My entire app is in UIKit and I am starting with building new screens in SwiftUI so I will not have NavigationStack or parent view access.
0

A much less elegant, but working solution with UIHostingController and UIWindow


import SwiftUI

final class ToastService {
    static let shared = ToastService()
    private var window: UIWindow?

    func show(title: String, duration: TimeInterval = 2.0) {
        DispatchQueue.main.async {
            let toastView = UIHostingController(rootView: ToastView(title: title))
            toastView.view.backgroundColor = .clear

            guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
            let window = UIWindow(windowScene: scene)
            window.frame = UIScreen.main.bounds
            window.windowLevel = .alert + 1
            window.rootViewController = toastView
            window.makeKeyAndVisible()

            self.window = window

            DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
                window.isHidden = true
                self.window = nil
            }
        }
    }
}

Usage

struct ScreenOne: View {

    @State private var isPresented: Bool = false

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Button {
                ToastService.shared.show(title: "My Message!")
            } label: {
                Text("Show the message")
            }
        }
        .message(isPresented: $isPresented)
    }
}

struct ToastView: View {
    let title: String

    var body: some View {
        VStack {
            Text(title)
                .padding()
                .background(Color.blue)
                .clipShape(Capsule())
            Spacer() 
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

enter image description here

Solution on Github

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.