iOS 全局修改UINavigationBar的返回按钮(适配 iOS 11)

5,576 阅读9分钟

前言

关于实现 App 全局统一样式的返回按钮,之前我们通过修改 [[UIBarButtonItem appearance] setBackButtonTitlePositionAdjustment:] 来改变返回按钮中文字的偏移量,来实现隐藏文字的效果。但是改完后不是很满意,给大家两张图感受一下

←iOS10 ←iOS11

很明显,iOS 11以下,即使隐藏了文字,但是如果原本文字过长依旧会出现标题被挤到右侧的情况,而iOS 11,文字会从偏移后的位置出现或消失,正常动画时间下会有种很突兀的感觉。

正好在这时,产品在新项目中提了个要求,返回按钮默认文字为“返回”,而不再是隐藏。那么如果我们能够实现修改默认返回按钮文字,只要设置文字为@" "也能达到隐藏的目的,一举两得!那么就来试试能不能修改默认值吧。

思路


首先来看看UIBarButtonItem的相关Api,有没有能修改默认文字的方法。嗯……没找到………… 再找找UINavigationBar的相关Api,嗯……还是没找到………… 再看看UINavigationItem呢,嗯……还是找不到……接下来只能找产品改掉这个无理需求了

好吧,既然我们没法直接用苹果的Api搞定,那就自己来实现吧。很显然,无法调用外部Api就需要我们重写一些方法了,分类+runtime黑魔法是个很不错的方式。(什么?你说用继承?继承当然也可以实现,但是这种一点都不高大上,还要重新调整之前项目中旧代码的方式很明显第一时间就被我摒弃了。)所以接下来我们需要寻找关键的方法,然后通过重写来实现效果。

写之前我们先来回忆一下UINavigationBar中的UINavigationItemUIBarButtonItem:

UINavigationBar中的返回按钮以及标题、右侧按钮等控件都是由UINavigationItem属性来决定的,因此如果要修改这些内容,我们需要修改对应的UINavigationItem

在一个UINavigationController中的视图控制器做切换的时候,UINavigationBar是顶部导航栏视图,它始终是唯一的。大家都知道UINavigationController通过栈的方式来管理视图控制器的,同样UINavigationBar也是通过栈的方式来管理不同的UINavigationItem

UINavigationItem中的左侧按钮有两种:backBarButtonItemleftBarButtonItem,虽然都能改变左侧按钮但是区别较大。

  1. backBarButtonItem属性决定的是下一个视图返回当前视图的按钮,即设置A视图的UINavigationItem.backBarButtonItem,需要进入下一个B视图时,显示在左侧,不需要实现相应的点击事件;
  2. leftBarButtonItem属性可以直接更改当前视图导航栏的左侧按钮,替代掉前一个视图的backBarButtonItem,需要自己实现点击事件,同时会关闭侧滑手势。

既然我们要修改默认返回按钮,那么直接修改backBarButtonItem就可以了。

这里请注意,我们修改的是‘默认’值,也就是缺省值。如果代码中单独设置了backBarButtonItem,我们不能去改变这个值。

代码


说了这么多,直接来看实现:

  1. 新建UINavigationItem的分类
#import <UIKit/UIKit.h>

@interface UINavigationItem (BackItem)
@end
  1. 导入runtime头文件,重写backBarButtonItem的getter方法
#import "UINavigationItem+BackItem.h"
#import <objc/runtime.h>

@implementation UINavigationItem (BackItem)

+(void)load{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        Method originalMethodImp = class_getInstanceMethod(self, @selector(backBarButtonItem));
        Method destMethodImp = class_getInstanceMethod(self, @selector(myCustomBackButton_backBarbuttonItem));
        method_exchangeImplementations(originalMethodImp, destMethodImp);
        
    });
}

static char kCustomBackButtonKey;

-(UIBarButtonItem *)myCustomBackButton_backBarbuttonItem{
    //先get原本的backBarButtonItem,存在就直接返回,没有的话再返回默认按钮
    UIBarButtonItem *item = [self myCustomBackButton_backBarbuttonItem];
    if (item) {
        return item;
    }
    item = objc_getAssociatedObject(self, &kCustomBackButtonKey);
    if (!item) {
        item = [[UIBarButtonItem alloc] initWithTitle:@"返回" style:UIBarButtonItemStylePlain target:nil action:NULL];
        objc_setAssociatedObject(self, &kCustomBackButtonKey, item, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return item;
}

- (void)dealloc
{
    objc_removeAssociatedObjects(self);
}

这样的话,只要backBarbuttonItem属性通过 getter 请求获取,不会返回nil,那么系统就不会自动将上一个页面的title变为当前页面的返回按钮,我们就能实现默认返回按钮,那就来跑起来试试。

好的,看起来是完美解决了,但是!!!!

这只是 iOS 10 下的运行结果,我们应该知道苹果在 iOS 11 对UINavigationBar进行了重构,视图层级等和以前的系统下已经不一样了,我们再在 iOS 11 下测验一下。

是的,它在 iOS 11 下没有作用……如果你在-(UIBarButtonItem *)myCustomBackButton_backBarbuttonItem方法中加NSLog,你会发现backBarbuttonItem的getter方法不会被调用了,也就是说苹果已经不靠 getter 的方式来创建返回按钮了。(情绪渐渐失控.jpg)

适配 iOS 11


好吧,有困难就要克服困难,分析下成功和失效原因就是了。我们通过修改backBarbuttonItem的getter方法,使得这个属性不会为空,只要苹果去取这个属性来配置UINavigationBar的返回按钮,必定会跳进我们给它下的套,拿到我们想让它拿到的值。事实也证明在 iOS 11 以下是没有问题的。

可是 iOS 11 之后苹果绕过了 getter ,也就是说苹果不再调用 getter 来获取返回按钮,那么它在什么时候设置的呢?

我们可以尝试无论怎么点击,getter方法都不会被调用,但是如果我们手动设置 A视图 的backBarbuttonItem,会发现,这样做是有效果的。我们可以由此推测,苹果在这个 setter 方法里就已经做了一些事情,来完成返回按钮的设置,这样就能解释为什么在后续的过程中不再需要backBarbuttonItem的 getter 方法。看来我们需要就这个 setter 方法做文章了。

那我们可以直接像重写backBarbuttonItem的 getter 方法那样重写 setter 方法吗?来,试一试。

  1. 新增加一个方法替换
  2. 将全局返回按钮的构造方法提出来单写一个方法
  3. 判断 setter 的时候参数为nil则赋值为默认按钮
#import "UINavigationItem+BackItem.h"
#import <objc/runtime.h>

@implementation UINavigationItem (BackItem)

+(void)load{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        Method originalMethodImp = class_getInstanceMethod(self, @selector(backBarButtonItem));
        Method destMethodImp = class_getInstanceMethod(self, @selector(myCustomBackButton_backBarbuttonItem));
        method_exchangeImplementations(originalMethodImp, destMethodImp);
        
        Method setterOriginalMethodImp = class_getInstanceMethod(self, @selector(setBackBarButtonItem:));
        Method setterDestMethodImp = class_getInstanceMethod(self, @selector(myCustomBackButton_backBarbuttonItem));
        method_exchangeImplementations(setterOriginalMethodImp, setterDestMethodImp);

        
    });
}

static char kCustomBackButtonKey;

-(UIBarButtonItem *)myCustomBackButton_backBarbuttonItem{
    //先get原本的backBarButtonItem,存在就直接返回,没有的话再返回默认按钮
    UIBarButtonItem *item = [self myCustomBackButton_backBarbuttonItem];
    if (item) {
        return item;
    }
    item = objc_getAssociatedObject(self, &kCustomBackButtonKey);
    if (!item) {
        item = [[self class] customGlobalBackBarButtonItem];
        objc_setAssociatedObject(self, &kCustomBackButtonKey, item, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return item;
}

- (void)myCustomBackButton_setBackBarButtonItem:(UIBarButtonItem *)backBarButtonItem {
    if (backBarButtonItem == nil) {
        UIBarButtonItem *item = objc_getAssociatedObject(self, &kCustomBackButtonKey);
        if (!item) {
            item = [[self class] customGlobalBackBarButtonItem];
            objc_setAssociatedObject(self, &kCustomBackButtonKey, item, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        }
        backBarButtonItem = item;
    }
    [self myCustomBackButton_setBackBarButtonItem:backBarButtonItem];
}

+ (UIBarButtonItem *)customGlobalBackBarButtonItem {
    UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithTitle:@"返回" style:UIBarButtonItemStylePlain target:nil action:NULL];
    return item;
}

- (void)dealloc
{
    objc_removeAssociatedObjects(self);
}

@end

完成了!!好的,跑起来看看!!!效果如下图:

(鉴于此处的图和上一张图过于雷同,就不贴出来了,忘记的同学向上滑)

好吧,看到上一句话已经懂了,没有效果。为什么呢?因为 setter 方法没有被调用! 也就是说,苹果并没有去主动调用 setter 方法,我们换了也没用。所以只能由我们来找一个合适的时机去调用这个 setter 方法了。

记得回头删除掉刚才置换 setter 方法的无效代码

寻找合适调用时机


这里需要回顾之前关于“如何通过设置backBarbuttonItem来改变返回按钮”的部分了,我们设置了 A视图 的NavigationItem.backBarbuttonItem属性,那么它就会在下一个页面push进来的时候生效。也就是说这个属性只需要在要push进来新的控制器的时候再赋值给当前控制器对应的NavigationItem就可以了。所以我们需要找到并重写一个方法,这个方法需要满足三点:

  1. push新控制器的时候调用;
  2. 由系统主动调用,否则无法触发我们置换过后的方法;
  3. 方法中我们可以获得当前控制器的NavigationItem

然后我们找找有没有相关的 Api。

首先NavigationItem是没有这样的方法的,因为它无法知道UINavigationController会不会push新的控制器。所以只能去其他类里面找。

接下来,我们可以在UINavigationBar.h中看到这样一个方法

- (void)pushNavigationItem:(UINavigationItem *)item animated:(BOOL)animated;

看起来类似于UINavigationControllerpush方法,UINavigationBar也会push一个新的NavigationItem。那么我们就来试试这个方法:

  1. 新建UINavigationBar的分类
#import <UIKit/UIKit.h>

@interface UINavigationBar (BackItem_iOS11)

@end
  1. 导入runtime,重写- (void)pushNavigationItem:(UINavigationItem *)item animated:(BOOL)animated这个方法。
#import "UINavigationBar+BackItem_iOS11.h"
#import <objc/runtime.h>

@implementation UINavigationBar (BackItem_iOS11)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        Method originalMethodImp = class_getInstanceMethod(self, @selector(pushNavigationItem:animated:));
        Method destMethodImp = class_getInstanceMethod(self, @selector(iOS11CustomPushNavigationItem:animated:));
        method_exchangeImplementations(originalMethodImp, destMethodImp);
        
    });
}

- (void)iOS11CustomPushNavigationItem:(UINavigationItem *)item animated:(BOOL)animated {
    NSLog(@"%@------%@-------%@",item,self.topItem,self.backItem);
    
    [self iOS11CustomPushNavigationItem:item animated:animated];
}

@end

这里增加打印,顺便看看方法中能拿到的三个UINavigationItem值是否包含我们需要的那个。 接下来点击next按钮看看:

<<UINavigationItem: 0x7f8291c0c950>: title:'测试 BBBBBBBBB'>------<<UINavigationItem: 0x7f8291c093f0>: title:'测试 AAAAAAAA'>-------(null)

可以看到,打印成功的出来了,同时参数item就是即将push进来新的视图控制器所对应的UINavigationItem,而UINavigationBartopItem就是我们需要的当前视图控制器的UINavigationItem。那么来动手改变这个item试试。不过在这之前,我们需要能够拿到我们写在UINavigationItem+BackItem类中的默认返回按钮构造方法,保证两个类从同一个方法中构造backBarButtonItem。很简单,将+ (UIBarButtonItem *)customGlobalBackBarButtonItemUINavigationItem+BackItem.h中声明一下就好了。

直接来看代码吧:

#import "UINavigationBar+BackItem_iOS11.h"
#import "UINavigationItem+BackItem.h"
#import <objc/runtime.h>

@implementation UINavigationBar (BackItem_iOS11)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        if (@available(iOS 11, *)) {    // iOS 11 以下无效
            Method originalMethodImp = class_getInstanceMethod(self, @selector(pushNavigationItem:animated:));
            Method destMethodImp = class_getInstanceMethod(self, @selector(iOS11CustomPushNavigationItem:animated:));
            method_exchangeImplementations(originalMethodImp, destMethodImp);
        }
    });
}

- (void)iOS11CustomPushNavigationItem:(UINavigationItem *)item animated:(BOOL)animated {
    if (!self.topItem.backBarButtonItem) {
        UIBarButtonItem *itemBarButton = [UINavigationItem customGlobalBackBarButtonItem];
        [self.topItem setBackBarButtonItem:itemBarButton];
    }
    
    [self iOS11CustomPushNavigationItem:item animated:animated];
}

@end

为了缩短篇幅不再啰嗦,就直接说结果了。UINavigationBar+BackItem_iOS11可以在 iOS 11 完成默认返回按钮,但是之前也说了,iOS 11 和以下的版本对UINavigationBar的实现是不一样的,所以这个方法在 iOS 11 以下不生效。

完成

因为两个方案都不能兼容所有系统,但是效果互补,因此两个分类同时加入项目就能完成自定义默认返回按钮的效果了。

重新贴一下最终的UINavigationItem+BackItem代码

#import <UIKit/UIKit.h>

@interface UINavigationItem (BackItem)
+ (UIBarButtonItem *)customGlobalBackBarButtonItem;
@end




#import "UINavigationItem+BackItem.h"
#import <objc/runtime.h>

@implementation UINavigationItem (BackItem)

+(void)load{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        if (@available(iOS 11.0, *)) {
            // iOS 11 无效
        } else {
            Method originalMethodImp = class_getInstanceMethod(self, @selector(backBarButtonItem));
            Method destMethodImp = class_getInstanceMethod(self, @selector(myCustomBackButton_backBarbuttonItem));
            method_exchangeImplementations(originalMethodImp, destMethodImp);
        }
    });
}

static char kCustomBackButtonKey;

-(UIBarButtonItem *)myCustomBackButton_backBarbuttonItem{
    UIBarButtonItem *item = [self myCustomBackButton_backBarbuttonItem];
    if (item) {
        return item;
    }
    item = objc_getAssociatedObject(self, &kCustomBackButtonKey);
    if (!item) {
        item = [[self class] customGlobalBackBarButtonItem];
        objc_setAssociatedObject(self, &kCustomBackButtonKey, item, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return item;
}

+ (UIBarButtonItem *)customGlobalBackBarButtonItem {
    UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithTitle:@"返回" style:UIBarButtonItemStylePlain target:nil action:NULL];
    return item;
}

- (void)dealloc
{
    objc_removeAssociatedObjects(self);
}

@end

结束

最后的GitHub地址在这里 NavigationBackItemDemo


记得同时添加UINavigationItem+BackItemUINavigationBar+BackItem_iOS11两个分类