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.
Post
Replies
Boosts
Views
Activity
When adding a Done button to the keyboard toolbar the toolbar presents and operates as expected on the first view. If you present multiple views using .sheet() once on top of the other, with each keyboard using the same toolbar and Done button, when you get to the third view that has been presented, the toolbar disappears.
I have tried adding a FocusState, FocusValue, NavigationStack and NavigationView, I have tried making sure the field state is cleared and contained within every view but nothing seems to work.
Tested on Version 15.4
iPhone 15 pro sim
import UIKit
import SwiftUI
struct ContentView: View {
@Environment(\.dismiss) var dismiss
@State var isPresenting: Bool = false
@State var text: String = ""
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 32) {
TextField("Enter text", text: $text)
.padding(.top, 20)
.padding(.horizontal, 20)
Button {
isPresenting.toggle()
} label: {
Text("Present View")
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
Spacer()
}
.toolbar {
toolbarContent
}
.sheet(isPresented: $isPresenting) {
SecondView()
}
}
}
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItemGroup(placement: .keyboard) {
KeyboardAccessoryDone(title: "Done Content")
}
}
}
struct SecondView: View {
@Environment(\.dismiss) var dismiss
@State var isPresenting: Bool = false
@State var text: String = ""
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 32) {
TextField("Enter text", text: $text)
.padding(.top, 20)
.padding(.horizontal, 20)
Button {
isPresenting.toggle()
} label: {
Text("Present View")
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
Spacer()
}
.toolbar {
toolbarContent
}
.sheet(isPresented: $isPresenting) {
ThirdView()
}
}
}
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
CancelButton {
dismiss()
}
}
ToolbarItemGroup(placement: .keyboard) {
KeyboardAccessoryDone(title: "Done Second")
}
}
}
struct ThirdView: View {
@Environment(\.dismiss) var dismiss
@State var isPresenting: Bool = false
@State var text: String = ""
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 32) {
TextField("Enter text", text: $text)
.padding(.top, 20)
.padding(.horizontal, 20)
Button {
isPresenting.toggle()
} label: {
Text("Present View")
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
Spacer()
}
.toolbar {
toolbarContent
}
.sheet(isPresented: $isPresenting) {
FourthView()
}
}
}
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
CancelButton {
dismiss()
}
}
ToolbarItemGroup(placement: .keyboard) {
KeyboardAccessoryDone(title: "Done Third")
}
}
}
struct FourthView: View {
@Environment(\.dismiss) var dismiss
@State var isPresenting: Bool = false
@State var text: String = ""
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 32) {
TextField("Enter text", text: $text)
.padding(.top, 20)
.padding(.horizontal, 20)
Button {
isPresenting.toggle()
} label: {
Text("Present View")
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
Spacer()
}
.toolbar {
toolbarContent
}
}
}
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
CancelButton {
dismiss()
}
}
ToolbarItemGroup(placement: .keyboard) {
KeyboardAccessoryDone(title: "Done Forth")
}
}
}
public struct KeyboardAccessoryDone: View {
// MARK: - Properties
private let title: String
private let onPress: (() -> ())?
// MARK: - Init
public init(
title: String,
onPress: (() -> ())? = nil
) {
self.title = title
self.onPress = onPress
}
// MARK: - View
public var body: some View {
Spacer()
Button(title) {
if let onPress {
/// If the consumer of this view has chosen to inject a completion closure, then call it.
onPress()
} else {
/// If the consumer of this view has omitted to inject a completion closure, then close the keyboard by calling `endEditing`.
UIApplication.shared.endEditing()
}
}
}
}
public struct CancelButton: View {
private var onPress: (() -> Void)
public init(
onPress: @escaping () -> Void
) {
self.onPress = onPress
}
public var body: some View {
Button {
onPress()
} label: {
Text("Cancel")
}
.accessibilityIdentifier("cancelButton")
}
}
extension UIApplication {
func endEditing() {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}