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:
isBannerPresentedshows 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 viewbannerTopConstraintandbannerHeightConstraint: constraints needed for animation and resizingoriginalTopInset: the safe-area top inset before the banner appearedisBannerPresented: the boolean source of truthcurrentAppearance: the current style valuesorientationObserver: 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 = trueTwo 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.heightThen 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.topto the stored original - remove the banner from hierarchy
- clear associated state and remove the orientation observer
topConstraint.constant = -effective.height
rootViewController?.additionalSafeAreaInsets.top = baseAfter 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
UIWindowfrom 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
hideDelayseconds - 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.
