iOS 对UINavigationBar的一次研究

掘金地址

一、前言

swift版本: 4.0

Xcode版本: 9.2 (9C40b)

讨论的iOS版本: iOS9-iOS11

随着 iOS 的不断进化, UINavigationBar 越来越复杂,造成的结果就是开发中有些问题不好解决。并且很多时候伴随着 Status BariPhoneX 的影响,这就让问题更加复杂化了,下面就来看看具体的问题。

二、查看NavigationBar的层级

参考:iOS遍历打印所有子视图

先来看看 UINavigationBar的视图层级, 方面后面我们对其进行操作。下面分两种方式来查看:

  • Debug View Hierarchy Xcode 自带的查看视图层级功能)。
  • 运行在模拟器中并使用代码打印。

定义一个打印视图层级的函数, 在 viewDidLoad() 中调用,此时的 UINavigationBar 没有添加其他控件:

struct SJLog {
    public static func logSubView(by superView: UIView, in level: Int) {
        let subviews = superView.subviews
        if subviews.isEmpty { return }
        
        for subView in subviews {
            var blank = ""
            for _ in 1..<level {
                blank += " "
            }
            
            if let className = object_getClass(subView) {
                print( blank + "\(level): " + "\(className)")
            }
            self.logSubView(by: subView, in: level + 1)
        }
    }
}
  • iOS9:
image
1: _UINavigationBarBackground
 2: _UIBackdropView
  3: _UIBackdropEffectView
  3: UIView
 2: UIImageView
1: _UINavigationBarBackIndicatorView
  • iOS10
image
1: _UIBarBackground
 2: UIImageView
 2: UIVisualEffectView
  3: _UIVisualEffectBackdropView
  3: _UIVisualEffectFilterView
1: _UINavigationBarBackIndicatorView

  • iOS11


    image
1: _UIBarBackground
 2: UIImageView
 2: UIVisualEffectView
  3: _UIVisualEffectBackdropView
  3: _UIVisualEffectSubview
1: _UINavigationBarLargeTitleView
 2: UILabel
1: _UINavigationBarContentView
1: _UINavigationBarModernPromptView
 2: UILabel

这里只是展示了每个大版本的第一个版本,像是 9.1&10.1... 这种小版本没有详尽研究。可以看到 9-11 的版本迭代中,UINaviationBar 都产生了变化,特别是 iOS11 采用了自动布局,这也给我们带来了不少坑。

三、UIBarButtonItem 相关问题

3.1 边距问题

先修改一下打印视图层级方法中的代码:

//  print( blank + "\(level): " + "\(className)")
print( blank + "\(level): " + "\(subView.self)")

然后左边自定义添加一个 UIBarButtonItem ,将打印代码移动到 viewDidAppear() 中:

iOS11:

image

image

上图只是展示了 iOS11,尽管视图的层级机构有了变化,但系系统默认leftBarButtonItem&rightBarButtonItem等 边距经模拟器测试 Plus机型20, 其余机型为 16,而系统自带返回 BackItem 是贴着屏幕边上的,iOS11 中它们都是 UINavigationBarContentView 的子视图。

iOS11之前,可以通过调整 fixItem 来调整边距:

let fixItem = UIBarButtonItem(barButtonSystemItem: .fixedSpace,
target: nil, action: nil)

fixItem.width = -16
let backItem = UIBarButtonItem(image: UIImage(named: "navigaionbar_back_green"),
target: self,
action: #selector(pushAction))

navigationItem.leftBarButtonItems = [fixItem, backItem]

然而 iOS11 中,因为采用了自动布局的缘故,.fixedSpace 不再起作用,这需要我们另找办法。

之前我都是通过调整 UIButtonimageEdgeInsetstitleEdgeInsets 位置偏移来勉强达到效果,不过这种方法有一个问题,如图:

image

左边边距依然没有消失,而图片的位置给用户一种错觉,认为图片的位置是按钮中心,当用户点击到左边边距区域,就超出了按钮的点击范围。并且这里只有一个 Item, 多个 Item 时误触的情况就更多了。

参考: iOS11 导航栏按钮位置问题的解决------新

通过这位道友的文章内容给出灵感,既然 iOS11 使用了自动布局,那么有可能是使用了 layoutMargins。这个属性是用来设置内边距的,如果子视图自动布局时设置的参考不是父子图边线而是这个内边距,那么它将起作用。

于是修改打印视图层级的代码:

//  print( blank + "\(level): " + "\(className)")
//  print( blank + "\(level): " + "\(subView.self)")
print( blank + "\(level): " + "\(className)" + " \(subView.layoutMargins)")

打印结果:

image

可以看到 UINavigationBarContentViewlayoutMargins 属性中边距刚好就是 16 (Plus 机型是20)。

但是问题又来了,想要修改的是 UINavigationBar 的属性,我尝试了继承 UINavigationController 然后在其中修改,发现并没有效果。因为 UINavigationBar 中的 layoutSubviews() 方法会先执行。这就不得不考虑 Runtime 这个黑魔法了,坑点继续。

swift3.1 中, 猫神文章(Swift Tips SWIZZLE)中的如下写法被苹果干掉了:

extension UIButton {
    override public class func initialize() {
        if self != UIButton.self {
            return
        }
        UIButton.xxx_swizzleSendAction()
    }
}

幸好道高一尺,魔高一丈,这个回答中给出了新的处理方法:

Swift 3.1 deprecates initialize(). How can I achieve the same thing?

我们可以重写 UIApplication 中的 next ,然后将 swizzle 操作放在这里,因为他会在 applicationDidFinishLaunching 之前运行,不过我觉得这个方法不好,但目前我知道的只能这样处理。

UIApplication+Swizzle.swift:

extension UIApplication {
    private static let classSwizzedMethodRunOnce: Void = {
        if #available(iOS 11.0, *) {
            UINavigationBar.swizzedMethod()
        }
    }()
    
    open override var next: UIResponder? {
        UIApplication.classSwizzedMethodRunOnce
        return super.next
    }
}

这里的 static let 保证了只会运行一次。

UINavigationBar+FixSpace.swift:

@available(iOS 11.0, *)
extension UINavigationBar {
    static func swizzedMethod()  {
        swizzleMethod(
        UINavigationBar.self,
        originalSelector: #selector(UINavigationBar.layoutSubviews),
        swizzleSelector: #selector(UINavigationBar.swizzle_layoutSubviews))
    }
    
    @objc func swizzle_layoutSubviews() {
        swizzle_layoutSubviews()

        layoutMargins = .zero
        for view in subviews {
            if NSStringFromClass(view.classForCoder).contains("ContentView") {
                view.layoutMargins = UIEdgeInsetsMake(0, 0, 0, 0)
            }
        }
    }
}

然后运行,大功告成:

image

3.2 设置 leftBarButtonItem 后系统自带滑动返回消失

这个问题可以使用继承或 Runtime 解决, Runtime 方式这里有一个韩国的开发者的实现方式:

SwipeBack OC代码

这个问题原因是因为我们自定义的 leftBarButtonItem 替代了系统自带的 BackItem, 导致导航控制器的返回手势被取消,所以我们只要手动设置就好了。

class SwipeBackBaseViewController: UINavigationController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // 1.
        self.interactivePopGestureRecognizer?.delegate = self
        self.delegate = self
    }
    
    override func pushViewController(_ viewController: UIViewController, animated: Bool) {
        if animated {
           self.interactivePopGestureRecognizer?.isEnabled = false
        }
        super.pushViewController(viewController, animated: animated)
    }

}

extension SwipeBackBaseViewController: UIGestureRecognizerDelegate {
    
    public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        // 2.
        if let touchButton = touch.view as? UIButton {
            touchButton.isHighlighted = true
        }
        return true
    }
    
    
    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if self.viewControllers.count <= 1 {
            return false
        }
        
        let location = gestureRecognizer.location(in: self.navigationBar)
        if let touchButton = self.navigationBar.hitTest(location, with: nil) as? UIButton,
            touchButton.isDescendant(of: self.navigationBar) {
            touchButton.isHighlighted = false
        }
        
        return true
    }
    
}

extension SwipeBackBaseViewController: UINavigationControllerDelegate {
    // 3.
    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        interactivePopGestureRecognizer?.isEnabled = true
    }
}

解释一下上面的 1&2&3

    1. 直接设置 interactivePopGestureRecognizer 就能开启手势, 设置自己的代理为了解决后面的一个 bug
    1. 是上面那个韩国开发者框架 issues 中的一个 bug,应该是解决用按钮自定义 UIBarButtonItem 的一个问题,我没有详细尝试这个,感兴趣的可以试一试。
    1. 承接1中的 bug ,如果不调整 interactivePopGestureRecognizer?.isEnabled, 多次反复 Push&Pop 后会出现一个很难重现的 bug -> 手势会乱掉,不过还是被我重现了(:,感兴趣的可以尝试一下 。所以这里在跳转前关掉手势,跳转完成后开启手势来修复这个 bug。不过那个韩国开发者的框架中只是用了如下:
- (void)swizzled_pushViewController:(UIViewController *)viewController animated:(BOOL)animated
{
    [self swizzled_pushViewController:viewController animated:animated];
    self.interactivePopGestureRecognizer.enabled = NO;
}

我不知道是否修正了这个 bug,我尝试重现了一下,没有复现。

四、UINavigationBar 的平滑过渡问题

4.1 解释问题

引起这个问题的原因主要有两点:

  • 滑动返回手势的动画
  • 所有导航控制器的子视图共用同一个 NavigationBar 的设计

造成的问题:

  • UINavigationBar 前后底色不一样、背景图片不一样、透明和不透明,如何在滑动时友好的平滑过渡?

我们先来看看系统的效果:

image

可以看到 NavigationBar 的背景是有毛玻璃效果的,并且过渡时上面的内容自带动画效果。

然后再来看主流 APP 的实现方式,这里的 gif 有些许录制误差,大家可以自己打开 App 查看:

QQ个人主页返回消息界面:

image

打断滑动动画时,出现了一个 Bar,并马上消失,很像自己添加的一个 Bar

支付宝子界面到首页:

image

放弃了平滑过渡,直接使用系统提供的效果。

知乎新年版:

image

很突兀的出现,很突兀的消失。

下面来看做得最好的微信(这里有点跑帧,大家可以自己打开微信查看):

image

几乎和系统的效果一模一样。

4.2 一个bug

当有 UITabBarController 时,
并且实现了控制器的 hidesBottomBarWhenPushedtruenavigationBar.isTranslucent = true ,会出现一个 bug

image

这是因为我们的容器控制器和 window 没有设置背景色,于是就透明到了最底下的黑色背景。因此就算设置了容器控制器和 window 的背景色,只要透明下去的颜色不能保持一致,就依然会出现这个 bug

4.3 系统提供的方案

上面支付宝那个过渡动画就是系统提供的方案,只需要设置:

override func viewWillAppear(_ animated: Bool) {
    self.navigationController?.setNavigationBarHidden(true, animated: true)
}
    
override func viewWillDisappear(_ animated: Bool) {
    self.navigationController?.setNavigationBarHidden(false, animated: true)
}

就能达到效果。然后再自定义一个 View 代替原先的 NavigationBar,如果想要毛玻璃效果,可以自定义一个 UIVisualEffectView, NavigationBar 本身的实现也是这么干的。
一篇 UIVisualEffectView 相关的英文文章,文章的 Demo 非常巴适:

UIVisualEffectView Tutorial: Getting Started

4.4 直接替换方案

这种方案不隐藏 UINavigationBar ,而是让它变得完全透明,再自定义一个视图来提供背景的变化。

self.navigationController?.navigationBar.setBackgroundImage(UIImage(), 
for: .default)
self.navigationController?.navigationBar.shadowImage = UIImage()

通过上面的代码让 NavigationBar 变得透明了,而且隐藏了分割线。

替换时要注意 iPhone X 的导航栏高度

4.5 自定义视图做底色

我想了想还是使用了继承的方式,下面是代码:

class BaseCustomNavigationBarViewController: UIViewController {
    lazy var navigationBar: UIView = self.lazyNavigationBar()
    private lazy var effectView: UIVisualEffectView = self.lazyEffectView()
    override func viewDidLoad() {
        super.viewDidLoad()
        if isHiddenNavigationBar() {
            navigationBar.alpha = 0
        }
        if isTranslucent() {
            navigationBar.backgroundColor = UIColor.clear
            navigationBar.addSubview(effectView)
            NSLayoutConstraint.activate([
                effectView.heightAnchor.constraint(equalTo: navigationBar.heightAnchor),
                effectView.widthAnchor.constraint(equalTo: navigationBar.widthAnchor),
                ])
        }
    }
    func isTranslucent() -> Bool {
        return true
    }
    func isHiddenNavigationBar() -> Bool {
        return false
    }
    override func viewDidLayoutSubviews() {
        self.view.insertSubview(navigationBar, at: 0)
    }
}
extension BaseCustomNavigationBarViewController {
    private func lazyNavigationBar() -> UIView {
        // 这里的高度根据实际机型动态调整,例如iPhone X和iOS11更新的大标题等等
        let temp = UIView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 64))
        temp.backgroundColor = UIColor.white
        return temp
    }
    private func lazyEffectView() -> UIVisualEffectView {
        let effect = UIBlurEffect(style: .extraLight)
        let temp = UIVisualEffectView(effect: effect)
        temp.translatesAutoresizingMaskIntoConstraints = false
        return temp
    }
}

对于一些需要使用图片的需求,只需要给 navigationBar 添加一个 UIImageView 视图就行了,其他情况等等不再赘述,并且这种方式也变相解决了 4.2 中的 bug

4.5 直接鼓捣 UINavigationBar

这种方式也是很好的,不过使用了太多系统没有直接开放的东西,和系统的耦合性比较大。直接看这篇博客吧。

导航栏的平滑显示和隐藏 - 个人页的自我修养(1)

4.6 UIStatusBar 内容颜色的过渡切换

细心的同学会发现上面支付宝中的 StatusBar 颜色是过渡的,不是突然变色的。我第一想法是利用 KVC 一个个获取上面的内容然后进行颜色动画,不过再一想就将其排除了。然后在在这个问题下找到了答案。

how to animate status bar style change since iOS 9

调用过程中我发现必须使用 4.3 中的系统方案才能达到随着手势变化的效果,无奈,其余情况暂时只有自己判断在控制器生命周期中的哪个方法中调整吧。

var viewAppeared = true

override var preferredStatusBarStyle: UIStatusBarStyle {
    return viewAppeared ? .lightContent : .default
}


override func viewWillAppear(_ animated: Bool) {
    viewAppeared = true
    
    UIView.animate(withDuration: 0.8) {
        self.setNeedsStatusBarAppearanceUpdate()
    }
}

override func viewWillDisappear(_ animated: Bool) {
    viewAppeared = false
}

五、后记

关于这方面的坑点暂时只研究了这些,如果读者项目中还有其他坑点,欢迎在评论中大家一起讨论。

其余参考文章:

透明与半透明 NavigationBar 切换的三种方案

App界面适配iOS11(包括iPhoneX的奇葩尺寸)