PassThroughWindow in iOS26: An Overlay Window That Doesn’t Steal Your Gestures


Greetings, traveler!

Overlay windows are handy when you need UI that sits above everything else. Think about global toasts, network banners, call-status bars, floating debug panels, or a context menu you want to present independently of your main view hierarchy.

The downside is obvious: a UIWindow placed above your app can easily become a gesture vacuum. Even if the overlay is visually “empty” around your toast, the system may still route touches to that top window first. The result is a broken app: scroll views stop scrolling, buttons beneath the overlay stop reacting, and the UI feels “blocked” for no good reason.

A PassThroughWindow solves that. It lets your overlay be visible and interactive where it matters (the toast itself), but it passes everything else down to the windows underneath.

This is especially useful when you show toasts in a separate window: the toast needs taps (dismiss, action button), but the rest of the screen must remain fully usable.

The Core Idea: Return nil to Let Touches Fall Through

Hit-testing in UIKit is based on a simple contract:

  • If a window returns a UIView from hitTest(_:with:), that window will handle the event.
  • If it returns nil, UIKit will keep searching for a responder in windows below.

That means a pass-through window is mostly about one thing: when the tap lands on your “background” view, return nil so UIKit continues hit-testing the underlying app window.

The background is the root view of the window’s rootViewController. When the root view is hit, the window opts out.

else if hitView == rootView {
    return nil
}

But iOS versions aren’t consistent here, and modern visual effects (like Liquid Glass) complicate things. However, we can handle both.

Building the Window: A Minimal Subclass

The component is a UIWindow subclass:

import UIKit

final class PassThroughWindow: UIWindow {
    private var handledEvents = Set<UIEvent>()

    override final func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        ...
    }
}

handledEvents is the key to surviving behavior changes in newer iOS releases.

Step 1: Exit Early if the Window Isn’t Ready

The first guard is defensive: if there’s no root controller (or no root view), there is nothing meaningful to hit-test.

guard let rootViewController, let rootView = rootViewController.view else { return nil }

Returning nil here makes the window transparent to interactions when it’s misconfigured, which is a much better failure mode than swallowing touches.

Step 2: Handle the “No Event” Case

UIKit sometimes calls hitTest with a nil event. In that case, the safest option is to fall back to the base implementation.

guard let event else {
    return super.hitTest(point, with: nil)
}

Step 3: Ask UIKit First

We can delegate to UIKit’s built-in hit-testing:

guard let hitView = super.hitTest(point, with: event) else {
    handledEvents.removeAll()
    return nil
}

If UIKit returns nil, there is no interactive view in this window at that point. Clearing handledEvents is a defensive cleanup step: the code’s double-hit assumptions are based on observations, so it tries not to let the cache grow stale.

Step 4: Deal with Double Hit-Testing (iOS 18+ Reality)

If UIWindow returns a view on the first hit-test, iOS may perform a second hit-test for the same UIEvent. If either one fails, the event can be rejected for that window.

So the code tracks whether this UIEvent has already been processed:

if handledEvents.contains(event) {
    handledEvents.removeAll()
    return hitView
}

On the second hit-test, the window always returns the UIKit result (hitView) and doesn’t apply the pass-through filtering. That keeps the two hit-tests consistent.

This matters most for iOS 18 when your root controller is a UIHostingController: the second hit-test can return the root view rather than the real subview you hit initially. A naive pass-through implementation would treat that second call as “background” and return nil, which breaks the entire event.

The fix is simple: the second call always respects UIKit.

Step 5: Pass-Through Logic for iOS 17 and Earlier

Once we’ve handled the “already seen” case, the classic logic kicks in.

If the hit view is the root view, the tap should pass through to the underlying windows:

else if hitView == rootView {
    return nil
}

If the hit view is not the root view, it means the tap landed on a subview (like your toast’s content). In that case, return it so the overlay handles the tap.

else {
    return hitView
}

That’s exactly what you want for a toast window: the toast is interactive, but the rest is transparent.

Step 6: iOS 18 Special Handling for the First Call

For iOS 18, the code adds one more step: it marks the event as “seen” so the second call will be handled by the “always return UIKit” rule.

else if #available(iOS 18, *) {
    handledEvents.insert(event)
    return hitView
}

The subtle point: this happens only when hitView != rootView, meaning you actually hit an interactive subview.

If you hit the root view, the method returns nil immediately and does not cache the event.

Step 7: iOS 26+ and Liquid Glass

iOS 26 introduces more complexity when Liquid Glass effects are involved. Liquid Glass detection is implemented by inspecting the layer hit result and checking its name:

let name = rootView.layer.hitTest(point)?.name

Then:

  • If name == nil, the code behaves normally and returns hitView.
  • If the name starts with "@", it treats this as an indication of Liquid Glass layers and switches to a more explicit hit resolution path.
if name?.starts(with: "@") == true { ... }

In that case, you don’t want Liquid Glass itself to become a hit target. You want the touch to go to a real interactive subview, or pass through.

Finding the Real Interactive View: deepestHitTestView

This helper recursively walks subviews, front to back, and returns the deepest view that contains the point.

private func deepestHitTestView(in root: UIView, at point: CGPoint, with event: UIEvent?) -> UIView? {
    guard !root.isHidden, root.alpha > 0.01, root.isUserInteractionEnabled else { return nil }

    for subview in root.subviews.reversed() {
        let pointInSubview = subview.convert(point, from: root)
        if let hit = deepestHitTestView(in: subview, at: pointInSubview, with: event) {
            return hit
        }
    }

    return root.point(inside: point, with: event) ? root : nil
}

A few details make this work well:

  • It skips hidden, transparent, or non-interactive views early.
  • It iterates in reverse so visually topmost views win.
  • It converts coordinates correctly as it descends.

And then the key rule:

  • If the deepest hit is the rootView itself, treat it as background and return nil.
  • If it’s a subview, return it and let the overlay handle the tap.
if let realHit = deepestHitTestView(in: rootView, at: point, with: event) {
    if realHit === rootView {
        return nil
    } else {
        return realHit
    }
} else {
    return nil
}

That restores the intended semantics even when fancy layer effects interfere with hit-testing.

Conclusion

A dedicated overlay UIWindow is a powerful tool for global UI, but it’s easy to break interaction if you treat it like a normal window.

PassThroughWindow gives you the best of both worlds:

  • Your toast (or overlay UI) can live “above everything”
  • The rest of the app remains fully interactive
  • iOS 18+ double-hit testing and iOS 26 Liquid Glass quirks are handled explicitly

If you’ve ever shipped an overlay and then discovered that scrolling randomly stopped working, this is the kind of component that pays for itself immediately.

Source Code

GitHub: https://github.com/Livsy90/PassThroughWindow/tree/main