iOS 18 hit testing functionality differs from iOS 17

I created a Radar for this FB14766095, but thought I would add it here for extra visibility, or if anyone else had any thoughts on the issue.

Basic Information

Please provide a descriptive title for your feedback: iOS 18 hit testing functionality differs from iOS 17

What type of feedback are you reporting? Incorrect/Unexpected Behavior

Description:

Please describe the issue and what steps we can take to reproduce it:

We have an issue in iOS 18 Beta 6 where hit testing functionality differs from the expected functionality in iOS 17.5.1 and previous versions of iOS.

iOS 17: When a sheet is presented, the hit-testing logic considers subviews of the root view, meaning the rootView itself is rarely the hit view.

iOS 18: When a sheet is presented, the hit-testing logic changes, sometimes considering the rootView itself as the hit view.

Code:

import SwiftUI

struct ContentView: View {
    
    @State var isPresentingView: Bool = false
    
    var body: some View {
        
        VStack {
            
            Text("View One")
            
            Button {
                
                isPresentingView.toggle()
            } label: {
                Text("Present View Two")
            }
        }
        .padding()
        .sheet(isPresented: $isPresentingView) {
            ContentViewTwo()
        }
    }
}

#Preview {
    ContentView()
}

struct ContentViewTwo: View {
    
    @State var isPresentingView: Bool = false
    
    var body: some View {
        
        VStack {
            
            Text("View Two")
        }
        .padding()
    }
}

extension UIWindow {
    
    public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        
        /// Get view from superclass.
        guard let hitView = super.hitTest(point, with: event) else { return nil }
        
        print("RPTEST rootViewController = ", rootViewController.hashValue)
        print("RPTEST rootViewController?.view = ", rootViewController?.view.hashValue)
        print("RPTEST hitView = ", hitView.hashValue)
        
        if let rootView = rootViewController?.view {
            print("RPTEST rootViewController's view memory address: \(Unmanaged.passUnretained(rootView).toOpaque())")
            print("RPTEST hitView memory address: \(Unmanaged.passUnretained(hitView).toOpaque())")
            print("RPTEST Are they equal? \(rootView == hitView)")
        }
        
        /// If the returned view is the `UIHostingController`'s view, ignore.
        print("MTEST: hitTest rootViewController?.view == hitView", rootViewController?.view == hitView)
        print("MTEST: -")
        
        return hitView
    }
}

Looking at the print statements from the provided sample project:


iOS 17 presenting a sheet from a button tap on the ContentView():

RPTEST rootViewController's view memory address: 0x0000000120009200 RPTEST hitView memory address: 0x000000011fd25000 RPTEST Are they equal? false MTEST: hitTest rootViewController?.view == hitView false

RPTEST rootViewController's view memory address: 0x0000000120009200 RPTEST hitView memory address: 0x000000011fd25000 RPTEST Are they equal? false MTEST: hitTest rootViewController?.view == hitView false

iOS 17 dismiss from presented view:

RPTEST rootViewController's view memory address: 0x0000000120009200 RPTEST hitView memory address: 0x000000011fe04080 RPTEST Are they equal? false MTEST: hitTest rootViewController?.view == hitView false

RPTEST rootViewController's view memory address: 0x0000000120009200 RPTEST hitView memory address: 0x000000011fe04080 RPTEST Are they equal? false MTEST: hitTest rootViewController?.view == hitView false

iOS 18 presenting a sheet from a button tap on the ContentView():

RPTEST rootViewController's view memory address: 0x000000010333e3c0 RPTEST hitView memory address: 0x0000000103342080 RPTEST Are they equal? false MTEST: hitTest rootViewController?.view == hitView false

RPTEST rootViewController's view memory address: 0x000000010333e3c0 RPTEST hitView memory address: 0x000000010333e3c0 RPTEST Are they equal? true MTEST: hitTest rootViewController?.view == hitView true

You can see here ☝️ that in iOS 18 the views have the same memory address on the second call and are evaluated to be the same. This differs from iOS 17.

iOS 18 dismiss

RPTEST rootViewController's view memory address: 0x000000010333e3c0 RPTEST hitView memory address: 0x0000000103e80000 RPTEST Are they equal? false MTEST: hitTest rootViewController?.view == hitView false

RPTEST rootViewController's view memory address: 0x000000010333e3c0 RPTEST hitView memory address: 0x0000000103e80000 RPTEST Are they equal? false MTEST: hitTest rootViewController?.view == hitView false

The question I want to ask:

Is this an intended change, meaning the current functionality in iOS 18 is expected?

Or is this a bug and it's something that needs to be fixed?

As a user, I would expect that the hit testing functionality would remain the same from iOS 17 to iOS 18.

Thank you for your time.

Answered by DTS Engineer in 800621022

Hello @B4DG3R,

What functionality in your app depends on the rootViewController's view and the hit views being different?

Best regards,

Greg

Accepted Answer

Hello @B4DG3R,

What functionality in your app depends on the rootViewController's view and the hit views being different?

Best regards,

Greg

@B4DG3R hello, were you able to find some workaround? In my project, I set up 2 windows: one contains all the views organized in tabview, but without tabbar, and another has tabbar only. Window containing tabbar is "pass through window", which is implemented with overriding hitTest func.

This set up was chosen to show bottom sheet between tabbar and view, and it works in iOS 17, but not in iOS 18, since hitTest produces different results...

Also experiencing this issue. Using a UIWindow overlay seems to have been the generally used method to present content over a sheet, but this is now broken in iOS 18.

Several of Apple's apps use this pattern (Find My & Shortcuts) so there must be some other way it's done.

Nonetheless, this issue is present and doesn't allow proper hit testing on a UIWindow overlay.

Found a solution.

On iOS 18 rootViewController.view greedily captures taps, even when it's hierarchy contains no interactive views.
If it's hierarchy does contain interactive elements, it returns itself when calling .hitTest. This is problematic because it's frame likely fills the whole screen, causing everything behind it to become non-interactive.
To fix this, we have to perform hit testing on it's subviews.
Looping through it's subviews while performing .hitTest won't work though, as hitTest doesn't return the depth at which it found a hit.
As we are interested in the hit at the deepest depth, we have to reimplement it.
Once we have obtained the deepest hit, just overriding .hitTest and returning the deepest view doesn't work, as gesture recognizers are registered on rootViewController.view, not the hit view. We therefor still return the default hit test result, but only if the tap was detected within the bounds of the deepest view.

private final class PassthroughWindow: UIWindow {
    private static func _hitTest(
        _ point: CGPoint,
        with event: UIEvent?,
        view: UIView,
        depth: Int = 0
    ) -> Optional<(view: UIView, depth: Int)> {
        var deepest: Optional<(view: UIView, depth: Int)> = .none
        
        /// views are ordered back-to-front
        for subview in view.subviews.reversed() {
            let converted = view.convert(point, to: subview)
            
            guard subview.isUserInteractionEnabled,
                  !subview.isHidden,
                  subview.alpha > 0,
                  subview.point(inside: converted, with: event)
            else {
                continue
            }
            
            let result = if let hit = Self._hitTest(
                converted,
                with: event,
                view: subview,
                depth: depth + 1
            ) {
                hit
            } else  {
                (view: subview, depth: depth)
            }
            
            if case .none = deepest {
                deepest = result
            } else if let current = deepest, result.depth > current.depth {
                deepest = result
            }
        }
        
        return deepest
    }
    
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        /// on iOS 18 `rootViewController.view` greedily captures taps, even when it's hierarchy contains no interactive views
        /// if it's hierarchy _does_ contain interactive elements, it returns *itself* when calling `.hitTest`
        /// this is problematic because it's frame likely fills the whole screen, causing everything behind it to become non-interactive
        /// to fix this, we have to perform hit testing on it's _subviews_
        /// looping through it's subviews while performing `.hitTest` won't work though, as `hitTest` doesn't return the depth at which it found a hit
        /// as we are interested in the hit at the deepest depth, we have to reimplement it
        /// once we have obtained the deepest hit, just overriding `.hitTest` and returning the deepest view doesn't work, as  gesture recognizers are registered on `rootViewController.view`, not the hit view
        /// we therefor still return the default hit test result, but only if the tap was detected within the bounds of the _deepest view_
        if #available(iOS 18, *) {
            guard let view = rootViewController?.view else {
                return false
            }
            
            let hit = Self._hitTest(
                point,
                with: event,
                /// happens when e.g. `UIAlertController` is presented
                /// not advisable when added subviews are potentially non-interactive, as `rootViewController?.view` itself is part of `self.subviews`, and therefor participates in hit testing
                view: subviews.count > 1 ? self : view
            )
            
            return hit != nil
        } else {
            return super.point(inside: point, with: event)
        }
    }
    
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if #available(iOS 18, *) {
            return super.hitTest(point, with: event)
        } else {
            guard let hit = super.hitTest(point, with: event) else {
                return .none
            }
            return rootViewController?.view == hit ? .none : hit
        }
    }
}
15

Also experiencing this issue. Using a UIWindow overlay seems to have been the generally used method to present content over a sheet, but this is now broken in iOS 18.

[...]

Nonetheless, this issue is present and doesn't allow proper hit testing on a UIWindow overlay.

Writing to second this. The framework's interpretation of the hit test results appears to have changed so as to preclude UIWindow layering pattern.

Setup:

  • A key window with a .normal level
  • An overlay UIWindow subclass with a .alert level

On iOS 17 a UIWindow hit test returning nil—in response to the hit bubbling all of the way up to the widow's root view—led to the system sending continuing hit testing on the lower UI window.

  • No key window change observed.
  • A second call to hitTest is made

On iOS 18 the second call to hitTest the overlay window is also made, but this time a call to super's hitTest or the root view's hitTest does not return the root view. Additionally the keyWindow state appears to change across the two windows unless canBecomeKey is overridden to return false.

I'm experiencing a similar problem. In my SwiftUI app, I used rootViewController?.view == hitView ? nil : hitView to create pass-through windows layered on top.

However, iOS 18 has disrupted this functionality. Now, I receive two hit-tests: one correctly targeting the specific view (such as a button) returning a non-nil value, followed by an immediate second hit-test on the root view (which is unexpected, and seems like a bug).

I filed FB14456585 to report a similar issue, and included explanation of how I'm using it in my app (for an internal-only floating/draggable debug button). "Regression: Secondary UIWindow hitTest(_:with:) behavior changed in iOS 18, making it hard to interact with contained elements"

This code worked for me.

The benefit here is that it uses the default behavior of the hitTest on iOS. It checks the alpha of the point, and also other conditions like whether the hit view is interactive and whether the rootViewController.view has subviews.

The alpha point check was inspired by the swiftui-window-overlay by sunghyun-k.

enum PassThroughWindowType {
    case ignoreAllTouches
    case allowInteractiveTouches
}

class PassThroughWindow: UIWindow {

    let type: PassThroughWindowType

    init(windowScene: UIWindowScene, type: PassThroughWindowType) {
        self.type = type
        super.init(windowScene: windowScene)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        guard type == .allowInteractiveTouches else { return nil }
        
        if #available(iOS 18, *) {
            // In iOS >= 18 versions, prevent rootViewController.view by check the alpha Of point
     
            guard let rootView = rootViewController?.view,
                  let hitView = super.hitTest(point, with: event) else { return nil }
            
            // Avoid hit testing if there are no subviews or if view is non-interactive
            guard !rootView.subviews.isEmpty, hitView.isUserInteractionEnabled, !hitView.isHidden, hitView.alpha > 0 else { return nil }
            
            // if hitView == rootViewController.view && the alpha at the point < 0.01 .. then prevent rootViewController.view from capturing the hit
            if hitView == rootView, alphaOfPoint(in: rootView, at: point) < 0.01 {
                return nil
            }
            
            return hitView
        } else {
            // In older iOS versions, prevent rootViewController.view from capturing the hit
            guard let hitView = super.hitTest(point, with: event) else { return nil }
            return rootViewController?.view == hitView ? nil : hitView
        }
    }

  
    private func alphaOfPoint(in view: UIView, at point: CGPoint) -> CGFloat {
        var pixelData: [UInt8] = [0, 0, 0, 0]

        guard let context = CGContext(
            data: &pixelData,
            width: 1,
            height: 1,
            bitsPerComponent: 8,
            bytesPerRow: 4,
            space: CGColorSpaceCreateDeviceRGB(),
            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
        ) else {
            return .zero
        }

        context.translateBy(x: -point.x, y: -point.y)
        view.layer.render(in: context)

        return CGFloat(pixelData[3]) / 255.0
    }
}

Same issue here. Our tappable snackbars are unable to be tapped/interacted with on iOS 18 now due to this change.

This code works the same as the replies in this thread. It is a simplified version.

import UIKit

class ContentResponderWindow: UIWindow {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if #available(iOS 18, *) {
            let view = super.hitTest(point, with: event)
            guard let view, _hitTest(point, from: view) != rootViewController?.view else { return nil }
            return view
        } else {
            let view = super.hitTest(point, with: event)
            guard view != rootViewController?.view else { return nil }
            return view
        }
    }
    
    private func _hitTest(_ point: CGPoint, from view: UIView) -> UIView? {
        let converted = convert(point, to: view)
        
        guard view.bounds.contains(converted)
            && view.isUserInteractionEnabled
            && !view.isHidden
            && view.alpha > 0
        else { return nil }
        
        return view.subviews.reversed()
            .reduce(Optional<UIView>.none) { result, view in
                result ?? _hitTest(point, from: view)
            } ?? view
    }
}

I also found a specific issue. It only occurs when the rootViewController is UIHostingController. It does not occur under the UIKit system.

I just ran into this issue and this works pretty well for me:

final class PassthroughWindow: UIWindow {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        guard let hitView = super.hitTest(point, with: event),
              let rootView = rootViewController?.view
        else {
            return nil
        }
        
        if #available(iOS 18, *) {
            for subview in rootView.subviews.reversed() {
                let convertedPoint = subview.convert(point, from: rootView)
                if subview.hitTest(convertedPoint, with: event) != nil {
                    return hitView
                }
            }
            return nil
        } else {
            return hitView == rootView ? nil : hitView
        }
    }
}

I have a feeling the other responses have been overthinking this issue and I wanted to put in my two cents. We have a subclass of UIWindow that is used to present a number of different overlays in our application among them a dev tools menu that needs to float above all content. We experienced the same issues as previously mentioned where our existing implementation was broken such that all taps passed through our view and none were captured.

In considering the solutions posted I found that the original post actually had a key to a solution. In this post it was clear to see that the UIWindow was receiving two calls to hitTest and only the 2nd such call was different than expected. I found that when using our custom subclass if I didn't override hitTest taps on interactive content were handled as expected so the 2nd hitTest was clearly not a problem for the system. As such I implemented the following solution that just modifies our pass through logic to only occur on a first call to hitTest while the 2nd call is left to the default handling.

I have left my extensive comments in place for further clarity.

public final class PassThroughWindow: UIWindow {
    // Given the fact that hitTest being called twice for a single event is only based on observations we use a set of UIEvents to track handling rather than a more primitive flag
    private var encounteredEvents = Set<UIEvent>()

    // Based on observations, we have found that if an initial hitTest on UIWindow returns a view, then a 2nd hitTest is triggered
    // For hit testing to succeed both calls must return a view, if either test fails then this window will not handle the event
    // Prior to iOS 18 the views returned by super.hitTest on both calls were the same. However, under iOS 18 if the rootViewController of the window is a UIHostingController the 2nd hit test can return the rootViewController.view instead of the view returned in the first call.
    // This behavior breaks the original passthrough implementation that was working in earlier iOS versions since the 2nd hitTest would return nil, thus invalidating the 1st test
    // The solution to this difference in behavior is to return the value provided by super.hitTest on the 2nd test regardless of whether or not it is the rootViewController.view
    override public final func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        // If we don't have a root controller or it does not have a view we are done and can exit
        guard let rootViewController, let rootView = rootViewController.view else { return nil }

        guard let event else {
            assertionFailure("hit testing without an event is not supported at this time")
            return super.hitTest(point, with: nil)
        }

        // We next check the base implementation for a hitView, if none is found we are done
        guard let hitView = super.hitTest(point, with: event) else {
            // defensive clearing of encountered events
            encounteredEvents.removeAll()
            return nil
        }
        if encounteredEvents.contains(event) {
            // We've already processed a hitTest for this event so we will allow it to proceed using the base implementation
            // We defensively clear all events from the cache since the assumptions about 2 calls for every event are only based on observations
            encounteredEvents.removeAll()
            return hitView
        } else if hitView == rootView {
            // The hitView is the rootView so we want to return nil and the system can pass through the event to other potential responders
            // iOS 18: This is the first hitTest being processed for this event
            // iOS 17: This is any hitTest being processed for the event, if assumptions about system behavior are correct this would be a first call as well and a 2nd call would never be fired
            return nil
        } else if #available(iOS 18, *) {
            // Since the discrepancy between 1st and 2nd hitTests only exists in iOS 18 and we are basing our knowledge about 2 calls for each event on observations we will limit the scope of our special handling
            // We have now encountered this event once and want the 2nd encounter to always return the view provided by the base implementation so we mark the event as encountered
            encounteredEvents.insert(event)
            return hitView
        } else {
            return hitView
        }
    }
}
iOS 18 hit testing functionality differs from iOS 17
 
 
Q