Skip to content

Commit 6010bc7

Browse files
authored
Refactor MessageHostingView Hit Testing to Improve SwiftUI Touch Handling (#582)
* Refactor `MessageHostingView` hit testing to improve SwiftUI touch handling on iOS 18+ through layer-based detection. * Moved doc comments to top of hitTest function
1 parent 213fedc commit 6010bc7

File tree

1 file changed

+20
-11
lines changed

1 file changed

+20
-11
lines changed

SwiftMessages/MessageHostingView.swift

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,21 +51,30 @@ public class MessageHostingView<Content>: UIView, Identifiable where Content: Vi
5151
fatalError("init(coder:) has not been implemented")
5252
}
5353

54+
/// Override hit testing so that only SwiftUI-rendered content inside `MessageHostingView` can receive touches.
55+
///
56+
/// Background:
57+
/// - `MessageHostingView` does not tightly wrap its SwiftUI content, potentially leaving surrounding regions that should not be tappable. There have
58+
/// been some complications with detecting touches on the SwiftUI content over the years that have led to the current approach:
59+
/// - On iOS 18, UIKit performs a second hit test that resolves to the `UIHostingController`'s view instead of the actual SwiftUI element.
60+
/// - On iOS 26, the `UIHostingController`'s view no longer contains any subviews, but its `CALayer` *layer* hierarchy still reflects the SwiftUI content.
61+
///
62+
/// All of these issues can be solved by hit testing the layer hierarchy instead of the view hierarchy:
63+
/// - Call `super.hitTest(point, with: event)` to obtain a candidate view `view`. If our heuristic determines that SwiftUI content was tapped,
64+
/// then we return `view` to accept the touch. Otherwise, return `nil` to pass the touch through.
65+
/// - If the candidate is `MessageHostingView` return `nil`.
66+
/// - If the candidate is directly parented to `MessageHostingView`, this is the `UIHostingController` view containing the SwiftUI content.
67+
/// To determine if SwiftUI content was touched, we iterate over hosting controller's sublayers and return the candidate if the touch intersects a sublayer.
68+
/// Otherwise, return `nil`.
69+
/// - For any other case, we return the candidate because we don't know what's going on.
5470
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5571
guard let view = super.hitTest(point, with: event) else { return nil }
56-
// Touches should pass through unless they land on a view that is rendering a SwiftUI element.
5772
if view == self { return nil }
58-
// In iOS 18 beta, the hit testing behavior changed in a weird way: when a SwiftUI element is tapped,
59-
// the first hit test returns the view that renders the SwiftUI element. However, a second identical hit
60-
// test is performed(!) and on the second test, the `UIHostingController`'s view is returned. We want touches
61-
// to pass through that view. In iOS 17, we would just return `nil` in that case. However, in iOS 18, the
62-
// second hit test is actuall essential to touches being delivered to the SwiftUI elements. The new approach
63-
// is to iterate overall all of the subviews, which are all presumably rendering SwiftUI elements, and
64-
// only return `nil` if the point is not inside any of these subviews.
73+
6574
if view.superview == self {
66-
for subview in view.subviews {
67-
let subviewPoint = self.convert(point, to: subview)
68-
if subview.point(inside: subviewPoint, with: event) {
75+
for sublayer in view.layer.sublayers ?? [] {
76+
let sublayerPoint = self.layer.convert(point, to: sublayer)
77+
if sublayer.contains(sublayerPoint) {
6978
return view
7079
}
7180
}

0 commit comments

Comments
 (0)