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
        }
    }
}
12

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"

iOS 18 hit testing functionality differs from iOS 17
 
 
Q