A WhatsApp-style top banner for iOS using UIWindow


A top banner looks simple until you try to make it reliable across tabs, navigation stacks, SwiftUI hosting, and modal presentations. The approach we’ll consider here solves that problem by addressing it at a higher level: the banner is owned by the UIWindow, and the window pushes the whole app content down using safe-area insets.

If you’ve used WhatsApp, you’ve seen this pattern in the wild: when a call is active, there’s a persistent banner at the top that stays visible while you navigate around.

Another use case was demonstrated in the Instagram app. When the connection drops, an “Offline” banner appears, shifting the UI downward to ensure that tappable content remains visible.

Let’s consider a reusable “system-style” banner layer for your app.

The public surface: minimal API, window-level behavior

At the top, the extension exposes a tiny public API:

public extension UIWindow {
    var isBannerPresented: Bool {
        isBannerVisible
    }
    
    func presentTopBanner(
        config: ((inout TopBannerConfigBuilder) -> Void)? = nil
    ) { ... }
    
    func dismissTopBanner() {
        isBannerVisible = false
    }
}

A few details matter here:

  • isBannerPresented shows banner visibility.
  • presentTopBanner(config:) accepts a builder closure. You can override appearance per call without creating new types or passing long parameter lists.
  • The banner is not tied to a view controller or SwiftUI view. It’s owned by the window, so it survives navigation changes naturally.

A typical use looks like this:

window.presentTopBanner { config in
    config
        .title("Network Connection Lost")
        .backgroundColor(.red)
        .textColor(.white)
}

And hiding it:

window.dismissTopBanner()

Per-window state storage: associated objects

The next important piece is state management. UIKit doesn’t give UIWindow stored properties in extensions, so the implementation uses Objective-C associated objects.

There’s a enum of keys:

@MainActor
private enum AssociatedKeys {
    static var bannerView: UInt8 = 0
    static var bannerTopConstraint: UInt8 = 0
    static var bannerHeightConstraint: UInt8 = 0
    static var originalTopInset: UInt8 = 0
    static var isBannerPresented: UInt8 = 0
    static var currentAppearance: UInt8 = 0
    static var orientationObserver: UInt8 = 0
}

Each window gets its own independent storage:

  • bannerView: the actual banner view
  • bannerTopConstraint and bannerHeightConstraint: constraints needed for animation and resizing
  • originalTopInset: the safe-area top inset before the banner appeared
  • isBannerPresented: the boolean source of truth
  • currentAppearance: the current style values
  • orientationObserver: notification token so you can remove it correctly

This makes multi-scene apps behave correctly: each scene has its own window and its own banner state.

The appearance model: a complete “style snapshot”

The file defines an appearance model inside UIWindow:

struct TopBannerAppearance {
    let title: String
    let backgroundColor: UIColor
    let textColor: UIColor
    let height: CGFloat
    let titleTopInset: CGFloat
    let font: UIFont

    @MainActor
    static let `default` = TopBannerAppearance(
        title: "",
        backgroundColor: .systemGreen,
        textColor: .white,
        height: 80,
        titleTopInset: 20,
        font: .systemFont(ofSize: 17, weight: .semibold)
    )
}

In practice, that’s what enables transitions like “Offline → Back online” without rebuilding the banner view.

The builder: per-call overrides without losing defaults

To avoid passing six parameters every time, the builder stores optional overrides:

public final class TopBannerConfigBuilder {
    private var title: String?
    private var backgroundColor: UIColor?
    private var textColor: UIColor?
    private var height: CGFloat?
    private var titleTopInset: CGFloat?
    private var font: UIFont?

    fileprivate func build() -> TopBannerAppearance {
        TopBannerAppearance(
            title: title ?? TopBannerAppearance.default.title,
            backgroundColor: backgroundColor ?? TopBannerAppearance.default.backgroundColor,
            textColor: textColor ?? TopBannerAppearance.default.textColor,
            height: height ?? TopBannerAppearance.default.height,
            titleTopInset: titleTopInset ?? TopBannerAppearance.default.titleTopInset,
            font: font ?? TopBannerAppearance.default.font
        )
    }
}

Then presentTopBanner(config:) merges overrides into a concrete appearance and stores it on the window:

if let config {
    var builder = TopBannerConfigBuilder()
    config(&builder)
    self.currentBannerAppearance = builder.build()
}
presentBanner()
isBannerVisible = true

Two things to notice:

  • The appearance is stored per window (currentBannerAppearance).
  • The banner can be presented with different styles over time, and the window remembers the latest style.

The core switch: isBannerVisible

The private property isBannerVisible is the control point:

var isBannerVisible: Bool {
    get {
        (objc_getAssociatedObject(self, &AssociatedKeys.isBannerPresented) as? Bool) ?? false
    }
    set {
        let current = (objc_getAssociatedObject(self, &AssociatedKeys.isBannerPresented) as? Bool) ?? false
        guard current != newValue else { return }
        objc_setAssociatedObject(self, &AssociatedKeys.isBannerPresented, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        DispatchQueue.main.async { [weak self] in
            self?.setBannerVisible(newValue)
        }
    }
}

Creating and positioning the banner

The banner view is created lazily:

func makeBannerIfNeeded() {
    if self.bannerView == nil {
        let banner = BannerView()
        banner.translatesAutoresizingMaskIntoConstraints = false
        self.bannerView = banner
    }
}

The important line is translatesAutoresizingMaskIntoConstraints = false since the sizing happens via constraints.

When the banner is first presented, it gets pinned to the window:

let leading = bannerView.leadingAnchor.constraint(equalTo: leadingAnchor)
let trailing = bannerView.trailingAnchor.constraint(equalTo: trailingAnchor)
let heightConstraint = bannerView.heightAnchor.constraint(equalToConstant: effective.height)

let topConstraint = bannerView.topAnchor.constraint(equalTo: topAnchor, constant: -effective.height)
  • The banner starts above the visible area (-height).
  • Showing it means animating the top constraint to 0.

The “push content down” part

The banner shouldn’t cover content. That’s why the window adjusts the root controller’s safe area:

if originalTopSafeAreaInset == nil {
    originalTopSafeAreaInset = rootViewController?.additionalSafeAreaInsets.top ?? 0
}
let targetTopInset = (originalTopSafeAreaInset ?? 0) + effective.height

Then inside the animation:

UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut], animations: {
    self.layoutIfNeeded()
    self.rootViewController?.additionalSafeAreaInsets.top = targetTopInset
    self.rootViewController?.view.layoutIfNeeded()
}, completion: { _ in
    self.hardSync()
})
  • The banner slides in.
  • The entire app content shifts down as if the top safe area got larger.

The UI moves, nothing gets covered, taps still behave.

Updating the banner while it is visible: cross-fade instead of rebuild

configureBanner() applies the current appearance to the view. If the banner is already in the hierarchy, it uses a cross dissolve:

UIView.transition(with: banner, duration: 0.2, options: [.transitionCrossDissolve, .allowAnimatedContent]) {
    banner.configure(...)
}

This matters for real use cases:

  • Network drops: “No Internet”
  • Connection restored: “Back Online”

You can update the banner in place and keep motion smooth.

Handling landscape: compact banner rules live in one place

The file adds a simple effectiveAppearance(from:):

func effectiveAppearance(from appearance: TopBannerAppearance) -> TopBannerAppearance {
    if isLandscape {
        return TopBannerAppearance(
            title: appearance.title,
            backgroundColor: appearance.backgroundColor,
            textColor: appearance.textColor,
            height: 18,
            titleTopInset: 0,
            font: .systemFont(ofSize: 14, weight: .semibold)
        )
    } else {
        return appearance
    }
}

And it registers an orientation observer the first time the banner is shown:

orientationObserver = NotificationCenter.default.addObserver(
    forName: UIDevice.orientationDidChangeNotification,
    object: nil,
    queue: .main
) { [weak self] _ in ... }

Inside the callback, it reconfigures the banner, updates height constraint, and adjusts safe area again if the banner is visible.

The observer exists only while needed, and it gets removed when the banner hides.

Removing the banner

Hiding is the same animation in reverse:

  • move top constraint back to -height
  • restore additionalSafeAreaInsets.top to the stored original
  • remove the banner from hierarchy
  • clear associated state and remove the orientation observer
topConstraint.constant = -effective.height
rootViewController?.additionalSafeAreaInsets.top = base

After completion:

banner.removeFromSuperview()
self.bannerTopConstraint = nil
self.originalTopSafeAreaInset = nil

if let token = self.orientationObserver {
    NotificationCenter.default.removeObserver(token)
    self.orientationObserver = nil
}

Without it, you end up with stuck safe areas or leaked observers.

The “hard sync” hack: dealing with scroll views and safe area updates

The hardSync() method nudges the root view’s frame height by a tiny delta and forces layout:

var frame = rootView.frame
frame.size.height += 0.01
rootView.frame = frame
frame.size.height -= 0.01
rootView.frame = frame
rootView.setNeedsLayout()
rootView.layoutIfNeeded()

This exists for a specific purpose: to handle safe-area changes in UIScrollView scroll layouts. After dismissing a banner, we can use this trick to restore its layout. Maybe this is a bug of the current iOS version — I have noticed the same behavior with the .searchable modifier as well. So, just in case.

Use case 1: a call banner that follows navigation like WhatsApp

A call banner is a perfect match for a window-scoped approach:

  • It should stay visible when the user moves between chats, settings, and other screens.
  • It should not cover navigation bars or scrollable content.
  • It should feel like part of the system UI.

You’d typically show it when the call connects, and hide it when the call ends:

window.presentTopBanner { config in
    config
        .title("Call in progress")
        .backgroundColor(.systemGreen)
        .textColor(.white)
}

When the call ends:

window.dismissTopBanner()

Because the banner is owned by the window, you don’t need to “re-present” it when the visible controller changes. That’s the difference you feel immediately in a real app.

Use case 2: an offline banner that shifts the entire UI

The other classic is an “offline” indicator. Same requirements:

  • visible across the whole app
  • persistent until connectivity is back
  • never covers buttons or navigation

This style typically uses a warning color:

window.presentTopBanner { config in
    config
        .title("No internet connection")
        .backgroundColor(.systemRed)
        .textColor(.white)
}

Later, you can update the existing banner to a “Back Online” message using the same API and let it fade:

window.presentTopBanner { config in
    config
        .title("Connection Restored")
        .backgroundColor(.systemGreen)
        .textColor(.white)
}

The key is that you don’t need a special “update” method. Presenting again updates currentBannerAppearance, and configureBanner() handles the in-place transition.

Network monitoring that drives a window-level banner

Network monitoring is a good match for that architecture: you want a single “offline” message that follows the user everywhere, like the connection banner you see in WhatsApp.

The core idea is simple:

  • Monitor reachability with NWPathMonitor
  • Expose a boolean (hasNetworkConnection) to SwiftUI
  • Extract the current UIWindow from SwiftUI
  • Show or update the banner on changes

Attach the modifier to your root view, so the app gets one monitor and one banner behavior for the whole UI.

The SwiftUI entry point

You expose a single modifier that can be applied to any SwiftUI view, usually ContentView:

public extension View {
    func networkMonitoring(
        hideDelay: TimeInterval = 2.0,
        noConnectionConfig: WindowBannerConfig,
        restoredConfig: WindowBannerConfig
    ) -> some View {
        modifier(
            NetworkMonitoringView(
                hideDelay: hideDelay,
                noConnectionConfig: noConnectionConfig,
                restoredConfig: restoredConfig
            )
        )
    }
}

This API does two important things:

  • Keeps banner styling out of the monitor logic
  • Makes offline/restored states explicit and easy to tweak

A typical call on the root view:

ContentView()
    .networkMonitoring(
        noConnectionConfig: .init(
            title: "No Internet Connection",
            backgroundColor: .systemRed
        ),
        restoredConfig: .init(
            title: "Back Online",
            backgroundColor: .systemTeal
        )
    )

Extracting UIWindow from SwiftUI

SwiftUI does not hand you a UIWindow directly, so the common trick is a tiny UIViewRepresentable that reports when it is attached to a window.

struct WindowExtractor: UIViewRepresentable {
    var onChange: (UIWindow?) -> Void

    func makeUIView(context: Context) -> UIView {
        ExtractingView(onChange: onChange)
    }

    func updateUIView(_ uiView: UIView, context: Context) {}

    private final class ExtractingView: UIView {
        let onChange: (UIWindow?) -> Void

        init(onChange: @escaping (UIWindow?) -> Void) {
            self.onChange = onChange
            super.init(frame: .zero)
            isUserInteractionEnabled = false
            backgroundColor = .clear
        }

        required init?(coder: NSCoder) { fatalError() }

        override func didMoveToWindow() {
            super.didMoveToWindow()
            onChange(window)
        }
    }
}

Then you wrap it in a convenience modifier:

private extension View {
    func onChangeWindow(_ action: @escaping (UIWindow?) -> Void) -> some View {
        background(WindowExtractor(onChange: action))
    }
}

This gives the rest of the modifier a stable UIWindow? reference.

The view modifier that ties everything together

The modifier holds three pieces of state:

  • the extracted window
  • a monitor object
  • a task used for delayed hiding
private struct NetworkMonitoringViewModifier: ViewModifier {
    let hideDelay: TimeInterval
    let noConnectionConfig: WindowBannerConfig
    let restoredConfig: WindowBannerConfig

    @State private var window: UIWindow?
    @StateObject private var monitor = NetworkMonitor()
    @State private var hideTask: Task<Void, Never>?

    func body(content: Content) -> some View {
        content
            .onChangeWindow { window in
                self.window = window
                apply(for: monitor.hasNetworkConnection)
            }
            .onChange(of: monitor.hasNetworkConnection) { isConnected in
                apply(for: isConnected)
            }
    }
}

Two triggers call apply(for:):

  • when the window becomes available
  • when connectivity changes

That covers the startup case where the monitor knows the status before SwiftUI has a window reference.

Mapping connectivity to banner behavior

The apply(for:) method defines the UX rules.

Offline: show a persistent banner

If the device goes offline, show the offline banner and cancel any pending hide:

private func apply(for isConnected: Bool) {
    guard let window else { return }

    hideTask?.cancel()
    hideTask = nil

    if !window.isBannerPresented && !isConnected {
        window.presentTopBanner { config in
            config.title(noConnectionConfig.title)
             .backgroundColor(noConnectionConfig.backgroundColor)
             .textColor(noConnectionConfig.textColor)
             .height(noConnectionConfig.height)
             .titleTopInset(noConnectionConfig.titleTopInset)
             .font(noConnectionConfig.font)
        }
        return
    }

    // restored handling below...
}

This mirrors the “No connection” banner behavior you see in messaging apps: it stays until the problem is gone.

Restored: show a confirmation banner, then hide

When the connection returns, show a short “Back online” confirmation and hide it after a delay.

One practical rule is to show the restored banner only if the offline banner was on screen. That prevents a “Back online” banner appearing on app launch when you were never offline.

if window.isBannerPresented && isConnected {
    window.presentTopBanner { config in
        config.title(restoredConfig.title)
         .backgroundColor(restoredConfig.backgroundColor)
         .textColor(restoredConfig.textColor)
         .height(restoredConfig.height)
         .titleTopInset(restoredConfig.titleTopInset)
         .font(restoredConfig.font)
    }

    hideTask = Task { @MainActor in
        try? await Task.sleep(nanoseconds: UInt64(hideDelay * 1_000_000_000))
        window.dismissTopBanner()
    }
} else {
    window.dismissTopBanner()
}

This yields a clean flow:

  • Offline → persistent red banner
  • Online → teal confirmation banner for hideDelay seconds
  • Banner disappears and safe area returns to normal

The NetworkMonitor object

NWPathMonitor runs on a background queue and reports status changes. You publish those changes back to the main actor for SwiftUI.

@MainActor
final class NetworkMonitor: ObservableObject {
    @Published private(set) var hasNetworkConnection: Bool = true

    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            Task { @MainActor in
                self?.hasNetworkConnection = (path.status == .satisfied)
            }
        }
        monitor.start(queue: queue)
    }
}

Where to attach it

Put the modifier on the root view, not on individual screens:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .networkMonitoring(
                    noConnectionConfig: ...,
                    restoredConfig: ...
                )
        }
    }
}

This avoids multiple NWPathMonitor instances and keeps the banner behavior consistent across the entire UI.

Conclusion

By moving the banner to UIWindow, it becomes part of the system layer instead of another per-screen overlay. Navigation stops breaking it. Safe areas handle layout shifts correctly. SwiftUI and UIKit behave the same way.

Once that foundation is in place, features like global network status stop feeling special. They become simple state mapped to a window-level UI element — just like in apps such as WhatsApp and Instagram.

You can find the complete code on my GitHub.