iOS 對UINavigationBar的一次研究

掘金地址

一、前言

swift版本: 4.0

Xcode版本: 9.2 (9C40b)

討論的iOS版本: iOS9-iOS11

隨著 iOS 的不斷進化, UINavigationBar 越來越復(fù)雜,造成的結(jié)果就是開發(fā)中有些問題不好解決。并且很多時候伴隨著 Status BariPhoneX 的影響,這就讓問題更加復(fù)雜化了,下面就來看看具體的問題。

二、查看NavigationBar的層級

參考:iOS遍歷打印所有子視圖

先來看看 UINavigationBar的視圖層級, 方面后面我們對其進行操作。下面分兩種方式來查看:

  • Debug View Hierarchy Xcode 自帶的查看視圖層級功能)。
  • 運行在模擬器中并使用代碼打印。

定義一個打印視圖層級的函數(shù), 在 viewDidLoad() 中調(diào)用,此時的 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 都產(chǎn)生了變化,特別是 iOS11 采用了自動布局,這也給我們帶來了不少坑。

三、UIBarButtonItem 相關(guān)問題

3.1 邊距問題

先修改一下打印視圖層級方法中的代碼:

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

然后左邊自定義添加一個 UIBarButtonItem ,將打印代碼移動到 viewDidAppear() 中:

iOS11:

image

image

上圖只是展示了 iOS11,盡管視圖的層級機構(gòu)有了變化,但系系統(tǒng)默認(rèn)leftBarButtonItem&rightBarButtonItem等 邊距經(jīng)模擬器測試 Plus機型20, 其余機型為 16,而系統(tǒng)自帶返回 BackItem 是貼著屏幕邊上的,iOS11 中它們都是 UINavigationBarContentView 的子視圖。

iOS11之前,可以通過調(diào)整 fixItem 來調(diào)整邊距:

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 不再起作用,這需要我們另找辦法。

之前我都是通過調(diào)整 UIButtonimageEdgeInsetstitleEdgeInsets 位置偏移來勉強達(dá)到效果,不過這種方法有一個問題,如圖:

image

左邊邊距依然沒有消失,而圖片的位置給用戶一種錯覺,認(rèn)為圖片的位置是按鈕中心,當(dāng)用戶點擊到左邊邊距區(qū)域,就超出了按鈕的點擊范圍。并且這里只有一個 Item, 多個 Item 時誤觸的情況就更多了。

參考: iOS11 導(dǎo)航欄按鈕位置問題的解決------新

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

于是修改打印視圖層級的代碼:

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

打印結(jié)果:

image

可以看到 UINavigationBarContentViewlayoutMargins 屬性中邊距剛好就是 16 (Plus 機型是20)。

但是問題又來了,想要修改的是 UINavigationBar 的屬性,我嘗試了繼承 UINavigationController 然后在其中修改,發(fā)現(xiàn)并沒有效果。因為 UINavigationBar 中的 layoutSubviews() 方法會先執(zhí)行。這就不得不考慮 Runtime 這個黑魔法了,坑點繼續(xù)。

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 設(shè)置 leftBarButtonItem 后系統(tǒng)自帶滑動返回消失

這個問題可以使用繼承或 Runtime 解決, Runtime 方式這里有一個韓國的開發(fā)者的實現(xiàn)方式:

SwipeBack OC代碼

這個問題原因是因為我們自定義的 leftBarButtonItem 替代了系統(tǒng)自帶的 BackItem, 導(dǎo)致導(dǎo)航控制器的返回手勢被取消,所以我們只要手動設(shè)置就好了。

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. 直接設(shè)置 interactivePopGestureRecognizer 就能開啟手勢, 設(shè)置自己的代理為了解決后面的一個 bug。
    1. 是上面那個韓國開發(fā)者框架 issues 中的一個 bug,應(yīng)該是解決用按鈕自定義 UIBarButtonItem 的一個問題,我沒有詳細(xì)嘗試這個,感興趣的可以試一試。
    1. 承接1中的 bug ,如果不調(diào)整 interactivePopGestureRecognizer?.isEnabled, 多次反復(fù) Push&Pop 后會出現(xiàn)一個很難重現(xiàn)的 bug -> 手勢會亂掉,不過還是被我重現(xiàn)了(:,感興趣的可以嘗試一下 。所以這里在跳轉(zhuǎn)前關(guān)掉手勢,跳轉(zhuǎn)完成后開啟手勢來修復(fù)這個 bug。不過那個韓國開發(fā)者的框架中只是用了如下:
- (void)swizzled_pushViewController:(UIViewController *)viewController animated:(BOOL)animated
{
    [self swizzled_pushViewController:viewController animated:animated];
    self.interactivePopGestureRecognizer.enabled = NO;
}

我不知道是否修正了這個 bug,我嘗試重現(xiàn)了一下,沒有復(fù)現(xiàn)。

四、UINavigationBar 的平滑過渡問題

4.1 解釋問題

引起這個問題的原因主要有兩點:

  • 滑動返回手勢的動畫
  • 所有導(dǎo)航控制器的子視圖共用同一個 NavigationBar 的設(shè)計

造成的問題:

  • UINavigationBar 前后底色不一樣、背景圖片不一樣、透明和不透明,如何在滑動時友好的平滑過渡?

我們先來看看系統(tǒng)的效果:

image

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

然后再來看主流 APP 的實現(xiàn)方式,這里的 gif 有些許錄制誤差,大家可以自己打開 App 查看:

QQ個人主頁返回消息界面:

image

打斷滑動動畫時,出現(xiàn)了一個 Bar,并馬上消失,很像自己添加的一個 Bar。

支付寶子界面到首頁:

image

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

知乎新年版:

image

很突兀的出現(xiàn),很突兀的消失。

下面來看做得最好的微信(這里有點跑幀,大家可以自己打開微信查看):

image

幾乎和系統(tǒng)的效果一模一樣。

4.2 一個bug

當(dāng)有 UITabBarController 時,
并且實現(xiàn)了控制器的 hidesBottomBarWhenPushedtruenavigationBar.isTranslucent = true ,會出現(xiàn)一個 bug

image

這是因為我們的容器控制器和 window 沒有設(shè)置背景色,于是就透明到了最底下的黑色背景。因此就算設(shè)置了容器控制器和 window 的背景色,只要透明下去的顏色不能保持一致,就依然會出現(xiàn)這個 bug 。

4.3 系統(tǒng)提供的方案

上面支付寶那個過渡動畫就是系統(tǒng)提供的方案,只需要設(shè)置:

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

就能達(dá)到效果。然后再自定義一個 View 代替原先的 NavigationBar,如果想要毛玻璃效果,可以自定義一個 UIVisualEffectView, NavigationBar 本身的實現(xiàn)也是這么干的。
一篇 UIVisualEffectView 相關(guān)的英文文章,文章的 Demo 非常巴適:

UIVisualEffectView Tutorial: Getting Started

4.4 直接替換方案

這種方案不隱藏 UINavigationBar ,而是讓它變得完全透明,再自定義一個視圖來提供背景的變化。

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

通過上面的代碼讓 NavigationBar 變得透明了,而且隱藏了分割線。

替換時要注意 iPhone X 的導(dǎo)航欄高度

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 {
        // 這里的高度根據(jù)實際機型動態(tài)調(diào)整,例如iPhone X和iOS11更新的大標(biāo)題等等
        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

這種方式也是很好的,不過使用了太多系統(tǒng)沒有直接開放的東西,和系統(tǒng)的耦合性比較大。直接看這篇博客吧。

導(dǎo)航欄的平滑顯示和隱藏 - 個人頁的自我修養(yǎng)(1)

4.6 UIStatusBar 內(nèi)容顏色的過渡切換

細(xì)心的同學(xué)會發(fā)現(xiàn)上面支付寶中的 StatusBar 顏色是過渡的,不是突然變色的。我第一想法是利用 KVC 一個個獲取上面的內(nèi)容然后進行顏色動畫,不過再一想就將其排除了。然后在在這個問題下找到了答案。

how to animate status bar style change since iOS 9

調(diào)用過程中我發(fā)現(xiàn)必須使用 4.3 中的系統(tǒng)方案才能達(dá)到隨著手勢變化的效果,無奈,其余情況暫時只有自己判斷在控制器生命周期中的哪個方法中調(diào)整吧。

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
}

五、后記

關(guān)于這方面的坑點暫時只研究了這些,如果讀者項目中還有其他坑點,歡迎在評論中大家一起討論。

其余參考文章:

透明與半透明 NavigationBar 切換的三種方案

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容