iOS 對UINavigationBar的一次研究

swordjoy發表於2018-02-23

一、前言

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:

iOS 對UINavigationBar的一次研究

1: _UINavigationBarBackground
 2: _UIBackdropView
  3: _UIBackdropEffectView
  3: UIView
 2: UIImageView
1: _UINavigationBarBackIndicatorView
複製程式碼
  • iOS10

iOS 對UINavigationBar的一次研究

1: _UIBarBackground
 2: UIImageView
 2: UIVisualEffectView
  3: _UIVisualEffectBackdropView
  3: _UIVisualEffectFilterView
1: _UINavigationBarBackIndicatorView

複製程式碼
  • iOS11
    iOS 對UINavigationBar的一次研究
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:

iOS 對UINavigationBar的一次研究
iOS 對UINavigationBar的一次研究

上圖只是展示了 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 位置偏移來勉強達到效果,不過這種方法有一個問題,如圖:

iOS 對UINavigationBar的一次研究

左邊邊距依然沒有消失,而圖片的位置給使用者一種錯覺,認為圖片的位置是按鈕中心,當使用者點選到左邊邊距區域,就超出了按鈕的點選範圍。並且這裡只有一個 Item, 多個 Item 時誤觸的情況就更多了。

參考: iOS11 導航欄按鈕位置問題的解決------新

通過這位道友的文章內容給出靈感,既然 iOS11 使用了自動佈局,那麼有可能是使用了 layoutMargins。這個屬性是用來設定內邊距的,如果子檢視自動佈局時設定的參考不是父子圖邊線而是這個內邊距,那麼它將起作用。

於是修改列印檢視層級的程式碼:

//  print( blank + "\(level): " + "\(className)")
//  print( blank + "\(level): " + "\(subView.self)")
print( blank + "\(level): " + "\(className)" + " \(subView.layoutMargins)")
複製程式碼

列印結果:

iOS 對UINavigationBar的一次研究

可以看到 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)
            }
        }
    }
}
複製程式碼

然後執行,大功告成:

iOS 對UINavigationBar的一次研究

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 前後底色不一樣、背景圖片不一樣、透明和不透明,如何在滑動時友好的平滑過渡?

我們先來看看系統的效果:

iOS 對UINavigationBar的一次研究

可以看到 NavigationBar 的背景是有毛玻璃效果的,並且過渡時上面的內容自帶動畫效果。

然後再來看主流 APP 的實現方式,這裡的 gif 有些許錄製誤差,大家可以自己開啟 App 檢視:

QQ個人主頁返回訊息介面:

iOS 對UINavigationBar的一次研究
打斷滑動動畫時,出現了一個 Bar,並馬上消失,很像自己新增的一個 Bar

支付寶子介面到首頁:

iOS 對UINavigationBar的一次研究

放棄了平滑過渡,直接使用系統提供的效果。

知乎新年版:

iOS 對UINavigationBar的一次研究

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

下面來看做得最好的微信(這裡有點跑幀,大家可以自己開啟微信檢視):

iOS 對UINavigationBar的一次研究
幾乎和系統的效果一模一樣。

4.2 一個bug

當有 UITabBarController 時, 並且實現了控制器的 hidesBottomBarWhenPushedtruenavigationBar.isTranslucent = true ,會出現一個 bug

iOS 對UINavigationBar的一次研究

這是因為我們的容器控制器和 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的奇葩尺寸)

相關文章