使用 hitTest: withEvent 捕获超视图框架外的子视图:

我的问题: 我有一个基本上占据整个应用程序框架的超视图 EditView,和一个只占据底部约20% 的子视图 MenuView,然后 MenuView包含它自己的子视图 ButtonView,它实际上位于 MenuView的界限之外(类似于这样的: ButtonView.frame.origin.y = -100)。

(注意: EditView还有其他子视图,这些子视图不属于 MenuView的视图层次结构,但可能会影响答案。)

您可能已经知道这个问题: 当 ButtonViewMenuView的范围内时(或者更具体地说,当我的触摸在 MenuView的范围内时) ,ButtonView响应触摸事件。当我的触摸超出 MenuView的界限(但仍然在 ButtonView的界限内) ,没有触摸事件接收到的 ButtonView

例如:

  • (E)是 EditView,所有视图的父级
  • (M)是 MenuView,是 EditView 的子视图
  • (B)是 ButtonView,是 MenuView 的子视图

图:

+------------------------------+
|E                             |
|                              |
|                              |
|                              |
|                              |
|+-----+                       |
||B    |                       |
|+-----+                       |
|+----------------------------+|
||M                           ||
||                            ||
|+----------------------------+|
+------------------------------+

因为(B)在(M)的帧之外,(B)区域中的一个点击永远不会被发送到(M)——事实上,(M)在这种情况下从不分析触摸,而触摸被发送到层次结构中的下一个对象。

目标: 我知道重写 hitTest:withEvent:可以解决这个问题,但是我不知道具体怎么解决。在我的例子中,hitTest:withEvent:是否应该在 EditView(我的“主”超视图)中被覆盖?或者它应该在 MenuView中被覆盖吗? MenuView是不接收触摸的按钮的直接视图?还是我想错了?

如果这需要一个冗长的解释,一个好的在线资源将是有帮助的-除了苹果的 UIView 文档,这并没有让我清楚。

谢谢!

36619 次浏览

What I would do is have both the ButtonView and MenuView exist at the same level in the view hierarchy by placing them both in a container whose frame completely fits both of them. This way the interactive region of the clipped item will not be ignored because of it's superview's boundaries.

Ok, I did some digging and testing, here's how hitTest:withEvent works - at least at a high level. Image this scenario:

  • (E) is EditView, the parent of all views
  • (M) is MenuView, a subview of EditView
  • (B) is ButtonView, a subview of MenuView

Diagram:

+------------------------------+
|E                             |
|                              |
|                              |
|                              |
|                              |
|+-----+                       |
||B    |                       |
|+-----+                       |
|+----------------------------+|
||M                           ||
||                            ||
|+----------------------------+|
+------------------------------+

Because (B) is outside (M)'s frame, a tap in the (B) region will never be sent to (M) - in fact, (M) never analyzes the touch in this case, and the touch is sent to the next object in the hierarchy.

However, if you implement hitTest:withEvent: in (M), taps anywhere in in the application will be sent to (M) (or it least it knows about them). You can write code to handle the touch in that case and return the object that should receive the touch.

More specifically: the goal of hitTest:withEvent: is to return the object that should receive the hit. So, in (M) you might write code like this:

// need this to capture button taps since they are outside of self.frame
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
for (UIView *subview in self.subviews) {
if (CGRectContainsPoint(subview.frame, point)) {
return subview;
}
}


// use this to pass the 'touch' onward in case no subviews trigger the touch
return [super hitTest:point withEvent:event];
}

I am still very new to this method and this problem, so if there are more efficient or correct ways to write the code, please comment.

I hope that helps anyone else who hits this question later. :)

I have modified the accepted answer's code to be more generic - it handles the cases where the view does clip subviews to its bounds, may be hidden, and more importantly : if the subviews are complex view hierarchies, the correct subview will be returned.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {


if (self.clipsToBounds) {
return nil;
}


if (self.hidden) {
return nil;
}


if (self.alpha == 0) {
return nil;
}


for (UIView *subview in self.subviews.reverseObjectEnumerator) {
CGPoint subPoint = [subview convertPoint:point fromView:self];
UIView *result = [subview hitTest:subPoint withEvent:event];


if (result) {
return result;
}
}


return nil;
}

SWIFT 3

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


if clipsToBounds || isHidden || alpha == 0 {
return nil
}


for subview in subviews.reversed() {
let subPoint = subview.convert(point, from: self)
if let result = subview.hitTest(subPoint, with: event) {
return result
}
}


return nil
}

I hope this helps anyone trying to use this solution for more complex use cases.

In Swift 5

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard !clipsToBounds && !isHidden && alpha > 0 else { return nil }
for member in subviews.reversed() {
let subPoint = member.convert(point, from: self)
guard let result = member.hitTest(subPoint, with: event) else { continue }
return result
}
return nil
}

Place below lines of code into your view hierarchy:

- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event
{
UIView* hitView = [super hitTest:point withEvent:event];
if (hitView != nil)
{
[self.superview bringSubviewToFront:self];
}
return hitView;
}


- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event
{
CGRect rect = self.bounds;
BOOL isInside = CGRectContainsPoint(rect, point);
if(!isInside)
{
for (UIView *view in self.subviews)
{
isInside = CGRectContainsPoint(view.frame, point);
if(isInside)
break;
}
}
return isInside;
}

For the more clarification, it was explained in my blog: "goaheadwithiphonetech" regarding "Custom callout : Button is not clickable issue".

I hope that helps you...!!!

If anyone needs it, here is the swift alternative

override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
if !self.clipsToBounds && !self.hidden && self.alpha > 0 {
for subview in self.subviews.reverse() {
let subPoint = subview.convertPoint(point, fromView:self);


if let result = subview.hitTest(subPoint, withEvent:event) {
return result;
}
}
}


return nil
}

If you have many other subviews inside your parent view then probably most of other interactive views would not work if you use above solutions, in that case you can use something like this(In Swift 3.2):

class BoundingSubviewsViewExtension: UIView {


@IBOutlet var targetView: UIView!


override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Convert the point to the target view's coordinate system.
// The target view isn't necessarily the immediate subview
let pointForTargetView: CGPoint? = targetView?.convert(point, from: self)
if (targetView?.bounds.contains(pointForTargetView!))! {
// The target view may have its view hierarchy,
// so call its hitTest method to return the right hit-test view
return targetView?.hitTest(pointForTargetView ?? CGPoint.zero, with: event)
}
return super.hitTest(point, with: event)
}
}

I took time to understand how this works exactly because the above solutions didn't work for me as I have many nested subviews. I'll try to explain simply hitTest so everyone can adapt his code depending on the situation.

Imagine this situation : A view called GrandParent has a subview entierly in its bounds called Parent. This Parent has a subview called Child that has bounds outside Parent's bounds :

-------------------------------------- -> Grand parent
|              ------- -> Child      |
|              |     |               |
|          ----|-----|--- -> Parent  |
|          |   |   ^ |   |           |
|          |   |     |   |           |
|          ----|-----|---            |
|              | +   |               |
|     *        |-----|               |
|                                    |
--------------------------------------

* + and ^ are 3 different user touches. + touch is not recognized on Child Whenever you touch grand parent anywhere in its bound, grand parent will call all its direct subviews .hitTest, no matter where you touched in grand parent. So here, for the 3 touches, grand parent will call parent.hitTest().

hitTest is supposed to return the farthest descendant that contain the given point (the point is given relative to self bounds, in our exemple, Grand Parent call Parent.hitTest() with a point relative to Parent bounds) .

For * touch, parent.hitTest returns nil, and it's perfect. For ^ touch, parent.hitTest returns the Child, because it's in its bounds (default implementation of hitTest).

But for + touch, parent.hitTest returns nil by default, as the touch is not whithin parent's bound. So we need to have our custom implementation of hitTest, in order to basically convert the point relative to Parent's bound to a point relative to Child bounds. Then call hitTest on the child to see if the touch is whithin child bounds (child is supposed to have the default implementation of hitTest, returning the farthest descendant from it whithin its bounds, that is : itself).

If you have complex nested subviews and if the above solution doesn't work very well for you, this explanation may be usefull to make your custom implementation, fitting your view hierarchy.