导航栏切换库 KMNavigationBarTransition

通过导航栏来进行视图切换是 App 最基础的功能,除去定制 Custom Transition 以外,大部分都通过 push 以及 pop 来进行视图的入栈,出栈。
通常使用系统默认的展示方式没有什么问题,但是上下级视图的导航栏颜色不一致时,交互体验就不太友好,比如:

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    UIImage *colorImage = [DemoUtil imageWithColor:[UIColor greenColor]];
    [self.navigationController.navigationBar setBackgroundImage:colorImage forBarMetrics:UIBarMetricsDefault];
    [self.navigationController.navigationBar setShadowImage:colorImage];
}

注意:
在 iOS 8.2 或者之前的版本,如果导航栏的 translucent 值为 true 时,用 barTintColor 去设置导航栏的背景样式,然后改变 barTintColor 的颜色,那么当边缘左滑返回手势取消的时候导航栏的背景色会闪烁。
为了避免这种情况发生,推荐用 setBackgroundImage:forBarMetrics: 来改变导航栏的背景样式。

效果如下:

原生的 NavigationBar 并没有提供接口来处理这种情况,于是各家 App 就会有自己的处理方式。
一般来说会隐藏掉 NavigationController 的 bar 并使用一个自定义的视图并来 fake 一个 navibar 出来。

KMNavigationBarTransition

KMNavigationBarTransition 是一个比较通用的管理上述导航栏切换效果的开源库,通过 Method Swizzling 来 Hook 掉一些节点方法来达到不需要使用者添加代码来控制切换效果的目的。

具体实现

先来看 push 的主流程这个库做了什么,这里主要 hook 的方法有:
UINavigationController 的 pushViewController:animated:

- (void)km_pushViewController:(UIViewController *)viewController animated:(BOOL)animated {
    UIViewController *disappearingViewController = self.viewControllers.lastObject;
    if (!disappearingViewController) {
        return [self km_pushViewController:viewController animated:animated];
    }
    if (!self.km_transitionContextToViewController || !disappearingViewController.km_transitionNavigationBar) {
        [disappearingViewController km_addTransitionNavigationBarIfNeeded];
    }
    if (animated) {
        self.km_transitionContextToViewController = viewController;
        if (disappearingViewController.km_transitionNavigationBar) {
            disappearingViewController.navigationController.km_backgroundViewHidden = YES;
        }
    }
    return [self km_pushViewController:viewController animated:animated];
}

第一步判断当前视图栈里是否有视图,如果没有则直接调用系统默认行为,比如在应用内显式调用 initWithRootViewController: 方法时,系统会调用 push 的方法来将 navi 的 rootVC 拉入视图栈,所以这里做了个判断。

然后来为当前视图添加一个假的 navigation bar,来看 km_addTransitionNavigationBarIfNeeded 的实现:

- (void)km_addTransitionNavigationBarIfNeeded {
    UINavigationBar *bar = [[UINavigationBar alloc] init];
    bar.km_isFakeBar = YES;
    bar.barStyle = self.navigationController.navigationBar.barStyle;
    if (bar.translucent != self.navigationController.navigationBar.translucent) {
        bar.translucent = self.navigationController.navigationBar.translucent;
    }
    bar.barTintColor = self.navigationController.navigationBar.barTintColor;
    [bar setBackgroundImage:[self.navigationController.navigationBar backgroundImageForBarMetrics:UIBarMetricsDefault] forBarMetrics:UIBarMetricsDefault];
    bar.shadowImage = self.navigationController.navigationBar.shadowImage;
    [self.km_transitionNavigationBar removeFromSuperview];
    self.km_transitionNavigationBar = bar;
    [self km_resizeTransitionNavigationBarFrame];
    if (!self.navigationController.navigationBarHidden && !self.navigationController.navigationBar.hidden) {
        [self.view addSubview:self.km_transitionNavigationBar];
    }
}

删除了一些判断,这个方法主要就是自己 alloc 了一个 UINavigationBar 并把它的样子设置的跟当前视图的 navigation bar 一致并添加到视图上。

Hook 后的 pushVC 最后一步通过 disappearingViewController.navigationController.km_backgroundViewHidden = YES 来将系统 navigation bar 上的背景视图隐藏掉来不会影响切换动画的效果。

这里通过 "_backgroundView" 的 key 获取到了 navi bar 上系统类 _UIBarBackground 的实例并将其隐藏。

UINavigationController 的 popViewControllerAnimated:

- (UIViewController *)km_popViewControllerAnimated:(BOOL)animated {
    if (self.viewControllers.count < 2) {
        return [self km_popViewControllerAnimated:animated];
    }
    UIViewController *disappearingViewController = self.viewControllers.lastObject;
    [disappearingViewController km_addTransitionNavigationBarIfNeeded];
    UIViewController *appearingViewController = self.viewControllers[self.viewControllers.count - 2];
    if (appearingViewController.km_transitionNavigationBar) {
        UINavigationBar *appearingNavigationBar = appearingViewController.km_transitionNavigationBar;
        self.navigationBar.barTintColor = appearingNavigationBar.barTintColor;
        [self.navigationBar setBackgroundImage:[appearingNavigationBar backgroundImageForBarMetrics:UIBarMetricsDefault] forBarMetrics:UIBarMetricsDefault];
        self.navigationBar.shadowImage = appearingNavigationBar.shadowImage;
    }
    if (animated) {
        disappearingViewController.navigationController.km_backgroundViewHidden = YES;
    }
    return [self km_popViewControllerAnimated:animated];
}

同 push 方法类似,给 pop 掉的 vc 添加 fake 的 navigation bar 然后将当前导航视图控制器的 navigation bar 的颜色,底部分割线状态还原,最后隐藏掉 _UIBarBackground 视图。
其他相关方法 popToViewController:animated:, popToRootViewControllerAnimated: 处理与该方法相似。

UIViewController 的 viewDidAppear:

- (void)km_viewDidAppear:(BOOL)animated {
    [self km_restoreScrollViewContentInsetAdjustmentBehaviorIfNeeded];
    UIViewController *transitionViewController = self.navigationController.km_transitionContextToViewController;
    if (self.km_transitionNavigationBar) {
        self.navigationController.navigationBar.barTintColor = self.km_transitionNavigationBar.barTintColor;
        [self.navigationController.navigationBar setBackgroundImage:[self.km_transitionNavigationBar backgroundImageForBarMetrics:UIBarMetricsDefault] forBarMetrics:UIBarMetricsDefault];
        [self.navigationController.navigationBar setShadowImage:self.km_transitionNavigationBar.shadowImage];
        if (!transitionViewController || [transitionViewController isEqual:self]) {
            [self.km_transitionNavigationBar removeFromSuperview];
            self.km_transitionNavigationBar = nil; 
        }
    }
    if ([transitionViewController isEqual:self]) {
        self.navigationController.km_transitionContextToViewController = nil;
    }
    self.navigationController.km_backgroundViewHidden = NO;
    [self km_viewDidAppear:animated];
}

除去 km_restoreScrollViewContentInsetAdjustmentBehaviorIfNeeded 方法,其他的都是重置状态,将当前视图的 navigation bar 颜色底部样子切成跟 fake 的一样,然后将 fake 的 navigation bar 清除掉,重新显示 navigation bar 的 background view。

一些问题

兼容性问题

由于系统对导航栏相关的开放程度较低,所以一旦需要定制,在原有 API 体系下修改起来经常要靠猜和尝试,比如 KMNavigationBarTransition 库中通过 valueForKey: 方法来获取到 UIKit 内部类 _UIBarBackground 的实例,如果苹果一旦修改内部实现或者部分行为,则可能又会出现兼容性问题,比如这次 iOS 11 对 backgroundView 的尺寸进行了调整,所以 KMNavigationBarTransition 也不得不针对 UINavigationBar hook 了其 layoutSubviews 的方法:

- (void)km_layoutSubviews {
    [self km_layoutSubviews];
    UIView *backgroundView = [self valueForKey:@"_backgroundView"];
    CGRect frame = backgroundView.frame;
    frame.size.height = self.frame.size.height + fabs(frame.origin.y);
    backgroundView.frame = frame;
}

另外,iOS 11 对 scrollview 增加了 contentInsetAdjustmentBehavior, 如下所示:

所以该库也对调整 scrollview 的 contentOffset 的方法做了兼容性调整。

主题的支持以及其他 bug

有的时候做选型时,重要考虑的指标就是看看这个库的 issue 情况,KMNavigationBarTransition 作者对 bug 问题一直在处理和维护,可以看到 open 状态的 issue 几乎较少,iOS 11 的问题出现后也很快进行了更新。
不过使用上还是有一个问题,就是对主题的支持,比如进入主题设置页面对样式进行了修改后,再 pop 的时候,上一个页面 fake 的 navigation bar 还会用之前的样式,然后还会在 viewDidAppear: 方法执行时将 navigationController 的 bar 也改成之前的样式。解决这个问题其实也相对简单,可以开放一个接口对 fake 的 navigation bar 也进行样式修改就 ok 了。

snippets

Method Swizzling

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        KMSwizzleMethod(objc_getClass("_UIBarBackground"),
                        @selector(setHidden:),
                        [self class],
                        @selector(km_setHidden:));
    });
}

Method Swizzling 已经很常见,不过还是要强调 hook 时候一定要加 dispatch_once 来保证单次执行,虽然印象中 load 方法只会在拉起应用时执行一次,但是这里也还是会有坑。iOS 11 中 StoreKitUI 中的 SKUIMetricsAppLaunchEvent load 时会调用 [super load] 导致 NSObject load 方法会调用两次,具体可以看下注意系统库的坑之load函数调用多次

Weak Associated Object
关联引用的内存管理没有 weak 引用,所以作者这里包了一层 KMWeakObjectContainer

@interface KMWeakObjectContainer : NSObject
@property (nonatomic, weak) id object;
@end

@implementation KMWeakObjectContainer
void km_objc_setAssociatedWeakObject(id container, void *key, id value)
{
    KMWeakObjectContainer *wrapper = [[KMWeakObjectContainer alloc] init];
    wrapper.object = value;
    objc_setAssociatedObject(container, key, wrapper, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
id km_objc_getAssociatedWeakObject(id container, void *key)
{
    return [(KMWeakObjectContainer *)objc_getAssociatedObject(container, key) object];
}
@end

后记

其实总体来讲这个库结构不算庞大复杂,只是 hook 了一些系统类的方法对样式进行了调整。但还是希望在项目中引用库不只是 pod 'xxxx' 这么简单,能更多的看一些其中的实现,能多一些思考,以至于不用每次改这些类似 status bar 样式,支持横竖屏这种容易忘的方法时,只是搜索一下直接粘贴这么简单。

-- EOF --
欢迎提出建议,个人联系方式详见关于

Comments
Write a Comment