一个 iOS 11 自定义导航栏按钮偏移问题的优雅解决方案

阅读原文

背景

在 iOS 11 下,UINavigationBar 中左右两侧的自定义按钮,会出现位置受限的问题,我们可以通过在创建 UIBarButtonItem 时设置 custom view 的布局,但是又会出现部分区域不能接收到点击事件。

解决思路

1.创建 UIBarButtonItem 时,设置 UIBarButtonItem 的 custom view,因为 custom view 的位置和大小会被系统限制住,所以可以把这个 custom view 作为一个容器,在其上添加一个 button。

2.因为在 custom view 上添加的 button 有可能在超出 custom view 的 bounds 范围,所以为了保证 button 能够被响应,我们需要将 custom view 上接收到的点击事件传给这个 button。

@implementation IXOutsideTouchView

// allow touches outside view
// https://github.com/Automattic/simplenote-ios/blob/b43ffb63ae188fe263bf7419e44b7075ea7ddf22/Simplenote/Classes/SPOutsideTouchView.h
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    for(UIView *aSubview in self.subviews) {
        UIView *view = [aSubview hitTest:[self convertPoint:point toView:aSubview] withEvent:event];
        if(view) return view;
    }
    return [super hitTest:point withEvent:event];
}

@end

3.在 iOS 11 下,系统的导航栏有一个叫做 _UINavigationBarContentView 的子控件,会把导航栏上的点击事件拦截掉,所以我们需要从 UINavigationBar 的 view 层级中找到我们的 custom view,并在 UINavigationBar 的 hitTest:withEvent: 中将点击事件传给这个 custom view,这样我们的 button 就能接收点击事件了。

NS_INLINE UIView *IXFindIXOutsideTouchViewInView(UIView *view) {
    for (UIView *subview in view.subviews) {

        if ([subview isKindOfClass:[IXOutsideTouchView class]]) {
            return subview;
        } else {
            UIView *theView = IXFindIXOutsideTouchViewInView(subview);
            if (theView) {
                return theView;
            }
        }
    }
    
    return nil;
}

@implementation IXNavigationBar


- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    // 针对 iOS 11 下按钮点击范围被限制的问题作了修改
    if (@available(iOS 11,*)) {
        
        // 递归遍历所有子 view,直到找到 IXOutsideTouchView,并且该 view 还能响应
        // 1. 一个一个问 subview,是否是 IXOutsideTouchView
        // 2. 如果是,就直接返回,如果不是,就继续问 subview 的 subview,递归询问
        // 3. 如果一直没找到,就什么都不做,继续往下执行
        // 4. 如果最终找到了,就调用 hitTest:withEvent: 方法,询问是否有可响应的 view
        
        UIView *view = IXFindIXOutsideTouchViewInView(self);
        if (view) {
            UIView *finalView = [view hitTest:[self convertPoint:point toView:view] withEvent:event];
            if (finalView) {
                return finalView;
            }
            
        }
    }

    
    return [super hitTest:point withEvent:event];
}

@end


更优雅的封装

按照上面的几个步骤,就已经可以实现我们想要达到的目的了,但是 navigation bar 需要知道自定义 view,耦合度比较高,而且还必须要自定义 UINavigationBar 的子类。

所以,我们可以通过 runtime 的 Method Swizzling 技术结合 category 来实现上面的第三步:

typedef UIView *(^IXViewHitTestBlock)(UIView *view);


/**
 在 view 层级中找到指定 class 的 container view 的响应接受者

 @param customViewClasses 自定义 class
 @param containerView 容器 view
 @param hitTestBlock 是否接收响应事件
 @return 如果找到就返回一个 view,没找到则返回 nil。
 */
NS_INLINE UIView *IXFindTouchEventReceiverForCustomViewInView(NSArray <Class> *customViewClasses, UIView *containerView, IXViewHitTestBlock hitTestBlock) {
   
    for (UIView *subview in containerView.subviews) {
        
        if ([customViewClasses containsObject:subview.class] && hitTestBlock(subview)) { // 是自定义 view,并且能接收响应
            return hitTestBlock(subview);
        } else {
            // 如果不符合条件,就从 subview 开始找
            UIView *theView = IXFindTouchEventReceiverForCustomViewInView(customViewClasses, subview, hitTestBlock);
            if (theView) {
                return theView;
            }
        }
    }
    
    return nil;
}

//////////////////////////////////////////////////////////////////////////////////////////

@implementation UINavigationBar (IXTouch)

static NSMutableArray <Class> *m_registeredCustomTouchViewClasses = nil;

+ (void)load {
    [self ix_exchangeInstanceMethod1:@selector(hitTest:withEvent:) method2:@selector(ix_hitTest:withEvent:)];
}

+ (void)ix_registerCustomTouchViewClass:(Class)viewClass {
    
    if (!m_registeredCustomTouchViewClasses) {
        m_registeredCustomTouchViewClasses = [NSMutableArray array];
    }
    
    [m_registeredCustomTouchViewClasses addObject:viewClass];
    
}

// 触摸事件是按照这样的顺序传递的: UIApplication -> UIWindow -> root view -> subview -> subview... 直到找到合适的 view
// https://www.jianshu.com/p/2e074db792ba
- (UIView *)ix_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    // 针对 iOS 11 下按钮点击范围被限制的问题作了修改
    if (@available(iOS 11,*)) {
        
        // 递归遍历所有子 view,直到找到 IXOutsideTouchView,并且该 view 还能响应
        // 1. 一个一个问 subview,是否是 IXOutsideTouchView
        // 2. 如果是,就直接返回,如果不是,就继续问 subview 的 subview,递归询问
        // 3. 如果一直没找到,就什么都不做,继续往下执行
        // 4. 如果最终找到了,就调用 hitTest:withEvent: 方法,询问是否有可响应的 view
        
        UIView *view = IXFindTouchEventReceiverForCustomViewInView(m_registeredCustomTouchViewClasses, self, ^(UIView *aView){
            return [aView hitTest:[self convertPoint:point toView:aView] withEvent:event];
        });
        
        if (view) return view;
    }
    
    return [self ix_hitTest:point withEvent:event];
}

@end

当需要为自定义导航栏按钮拦截点击事件时,只需要注册这个 view 的 class 就行了。而且,如果你在导航上使用了多个不同类的 custom view,会按照注册先后顺序进行询问,只有最先注册的而且能做出响应的(响应范围合法)才会接收到点击事件。

将上面几个步骤合起来,再用 UIBarButtonItem 的 category 进行封装,就是这样的效果:

+ (instancetype)leftItemWithImage:(UIImage *)image imageEdgeInsets:(UIEdgeInsets)insets target:(id)target action:(SEL)action {
    
    // 这个按钮才是真正要响应点击事件的控件
    UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(-kImageBarButtonSidePadding, 0, kNavigationBarHeight, kNavigationBarHeight)];
    [button setImage:image forState:UIControlStateNormal];
    button.imageEdgeInsets = insets;
    [button addTarget:target action:action forControlEvents:UIControlEventTouchUpInside];
    
    // 包装 button 的容器 view,这个 view 的位置和大小被限制死了,所以还需要把触摸事件传给 button
    IXOutsideTouchView *containerView = [[IXOutsideTouchView alloc] initWithFrame:CGRectMake(0, 0, kNavigationBarHeight, kNavigationBarHeight)];
    [containerView addSubview:button];
    
    // iOS 11 下的适配,将 UINavigationBar 上的触摸事件传到最上面的自定义控件,防止被系统的 _UINavigationBarContentView 拦截掉
    [UINavigationBar ix_registerCustomTouchViewClass:[IXOutsideTouchView class]];
    
    UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithCustomView:containerView];
    
    return item;
}

详细的实现见 源代码

使用效果


self.navigationItem.leftBarButtonItem = [UIBarButtonItem leftItemWithImage:[UIImage imageNamed:@"navigationbar_back_black"]
                                                           imageEdgeInsets:UIEdgeInsetsZero
                                                                    target:self
                                                                    action:@selector(pop)];

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容