0x01 前言

问题由一个项目需求引起。设计MM给的图大概像这样:

snapshot

如上图所示,列表的内容由服务器传回,且用户可编辑,显然这样的界面应该用 UICollectionView 来搭建。实现关闭按钮则需要在 UICollectionViewCell 的右上角添加一个 UIButton,并且要将 Cell 的 clipsToBounds 属性设置为 NO 以避免按钮被切掉一部分。

界面搭建好了,但默认状态下 UIButton 的点击响应范围跟它的显示区域一样小,导致这个按钮很难被点到,因此首先要解决的是这个按钮的热区扩展问题。

0x02 UIButton 的热区扩展

网络上介绍 UIButton 响应区域扩展的文章有很多,但大多数都是继承 UIButton 然后重写 pointInside:withEvent: 方法。实际上这种子类继承的方法在很多场景下是不合适的,因为这意味着每次用到这个功能都要将系统 UIButton 换成自己的子类。一个更符合 Objective-C Style 的方法是增加一个 UIButton 的 Category,然后在不影响控件默认的行为的情况下在 Category 里做文章。

新建一个 UIButton+ExpandHitArea 分类,像这样在头文件中添加一个 hitTestEdgeInsets 属性,用来配置热区扩展(根据语意实际上是缩小)的范围:

1
2
3
4
5
@interface UIButton (ExpandHitArea)
@property (nonatomic) UIEdgeInsets hitTestEdgeInsets;
@end

在 .m 文件中,我们将 pointInside:withEvent: 替换成修改过的方法:先将 hitTestEdgeInsets 的值与 UIEdgeInsetsZero 相比较,如果不等,则调用我们自己的扩展热区的逻辑;如果相等,则调用系统默认的 pointInside:withEvent: 方法,就像什么事也没发生过一样。最终代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@implementation UIButton (ExpandHitArea)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
YTSwizzleMethod([self class], @selector(pointInside:withEvent:), @selector(yt_pointInside:withEvent:));
});
}
- (UIEdgeInsets)hitTestEdgeInsets {
NSValue* value = objc_getAssociatedObject(self, _cmd);
UIEdgeInsets insets = UIEdgeInsetsZero;
[value getValue:&insets];
return insets;
}
- (void)setHitTestEdgeInsets:(UIEdgeInsets)hitTestEdgeInsets {
NSValue* value = [NSValue value:&hitTestEdgeInsets withObjCType:@encode(UIEdgeInsets)];
objc_setAssociatedObject(self, @selector(hitTestEdgeInsets), value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (BOOL)yt_pointInside:(CGPoint)point withEvent:(UIEvent*)event {
UIEdgeInsets insets = self.hitTestEdgeInsets;
if (UIEdgeInsetsEqualToEdgeInsets(insets, UIEdgeInsetsZero)) {
return [self yt_pointInside:point withEvent:event];
} else {
CGRect hitBounds = UIEdgeInsetsInsetRect(self.bounds, insets);
return CGRectContainsPoint(hitBounds, point);
}
}
@end

其中在 load 方法里的 YTSwizzleMethodMethod Swizzling 的具体实现,因为这段代码经常被用到所以我把它抽了出来,以下是函数的内容:

1
2
3
4
5
6
7
8
9
10
11
12
void YTSwizzleMethod(Class cls, SEL originalSelector, SEL swizzledSelector) {
Method originalMethod = class_getInstanceMethod(cls, originalSelector);
Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector);
BOOL didAddMethod = class_addMethod(cls, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(cls, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}

在工程中引入这个 Category 之后,我们对任意一个 UIButton 设置它的 hitTestEdgeInsets 属性,都可以将它的点击响应区域扩大或缩小。在本文的例子里,扩展 12 pt 差不多是个合适的值:

1
_closeButton.hitTestEdgeInsets = UIEdgeInsetsMake(-12, -12, -12, -12);

0x03 UICollectionViewCell 的响应区域修正

snapshot

如图所示,按照上一段的步骤配置好以后,按钮的响应区域理应变成图中整块高亮的部分,但实际运行后真正的响应区域只有左下角的 A 部分。这是因为 UIKit 在检测点击响应区域时,首先询问的是父控件的 pointInside:withEvent: 方法,如果返回 NO,那么 UIKit 就认为点击区域在整个控件范围之外,不会继续遍历子控件,因此 Cell 的响应区域也需要跟随按钮一起扩展。除此之外我们还需要屏蔽掉 Cell 本身的事件响应,以防下一个 Cell 覆盖掉了上一个 Button 扩展后的热区 (图中 B 区域)。所以重写 UICollectionViewCell 的两个相关方法,内容如下:

1
2
3
4
5
6
7
8
9
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
CGPoint pointInButton = [self convertPoint:point toView:_closeButton];
return [_closeButton pointInside:pointInButton withEvent:event] ? _closeButton : nil;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGPoint pointInButton = [self convertPoint:point toView:_closeButton];
return [_closeButton pointInside:pointInButton withEvent:event];
}