一、前言
swift版本: 4.0
Xcode版本: 9.2 (9C40b)
討論的iOS版本: iOS9-iOS11
隨著 iOS
的不斷進化, UINavigationBar
越來越複雜,造成的結果就是開發中有些問題不好解決。並且很多時候伴隨著 Status Bar
和 iPhoneX
的影響,這就讓問題更加複雜化了,下面就來看看具體的問題。
二、檢視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:
1: _UINavigationBarBackground
2: _UIBackdropView
3: _UIBackdropEffectView
3: UIView
2: UIImageView
1: _UINavigationBarBackIndicatorView
複製程式碼
- iOS10
1: _UIBarBackground
2: UIImageView
2: UIVisualEffectView
3: _UIVisualEffectBackdropView
3: _UIVisualEffectFilterView
1: _UINavigationBarBackIndicatorView
複製程式碼
- iOS11
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:
上圖只是展示了 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
不再起作用,這需要我們另找辦法。
之前我都是通過調整 UIButton
的 imageEdgeInsets
和 titleEdgeInsets
位置偏移來勉強達到效果,不過這種方法有一個問題,如圖:
左邊邊距依然沒有消失,而圖片的位置給使用者一種錯覺,認為圖片的位置是按鈕中心,當使用者點選到左邊邊距區域,就超出了按鈕的點選範圍。並且這裡只有一個 Item
, 多個 Item
時誤觸的情況就更多了。
通過這位道友的文章內容給出靈感,既然 iOS11
使用了自動佈局,那麼有可能是使用了 layoutMargins
。這個屬性是用來設定內邊距的,如果子檢視自動佈局時設定的參考不是父子圖邊線而是這個內邊距,那麼它將起作用。
於是修改列印檢視層級的程式碼:
// print( blank + "\(level): " + "\(className)")
// print( blank + "\(level): " + "\(subView.self)")
print( blank + "\(level): " + "\(className)" + " \(subView.layoutMargins)")
複製程式碼
列印結果:
可以看到 UINavigationBarContentView
的 layoutMargins
屬性中邊距剛好就是 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)
}
}
}
}
複製程式碼
然後執行,大功告成:
3.2 設定 leftBarButtonItem 後系統自帶滑動返回消失
這個問題可以使用繼承或 Runtime
解決, Runtime
方式這裡有一個韓國的開發者的實現方式:
這個問題原因是因為我們自定義的 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
-
- 直接設定
interactivePopGestureRecognizer
就能開啟手勢, 設定自己的代理為了解決後面的一個bug
。
- 直接設定
-
- 是上面那個韓國開發者框架
issues
中的一個bug
,應該是解決用按鈕自定義UIBarButtonItem
的一個問題,我沒有詳細嘗試這個,感興趣的可以試一試。
- 是上面那個韓國開發者框架
-
- 承接1中的
bug
,如果不調整interactivePopGestureRecognizer?.isEnabled
, 多次反覆Push&Pop
後會出現一個很難重現的bug
-> 手勢會亂掉,不過還是被我重現了(:,感興趣的可以嘗試一下 。所以這裡在跳轉前關掉手勢,跳轉完成後開啟手勢來修復這個bug
。不過那個韓國開發者的框架中只是用瞭如下:
- 承接1中的
- (void)swizzled_pushViewController:(UIViewController *)viewController animated:(BOOL)animated
{
[self swizzled_pushViewController:viewController animated:animated];
self.interactivePopGestureRecognizer.enabled = NO;
}
複製程式碼
我不知道是否修正了這個 bug
,我嘗試重現了一下,沒有復現。
四、UINavigationBar 的平滑過渡問題
4.1 解釋問題
引起這個問題的原因主要有兩點:
- 滑動返回手勢的動畫
- 所有導航控制器的子檢視共用同一個
NavigationBar
的設計
造成的問題:
UINavigationBar
前後底色不一樣、背景圖片不一樣、透明和不透明,如何在滑動時友好的平滑過渡?
我們先來看看系統的效果:
可以看到 NavigationBar
的背景是有毛玻璃效果的,並且過渡時上面的內容自帶動畫效果。
然後再來看主流 APP
的實現方式,這裡的 gif
有些許錄製誤差,大家可以自己開啟 App
檢視:
QQ個人主頁返回訊息介面:
打斷滑動動畫時,出現了一個Bar
,並馬上消失,很像自己新增的一個 Bar
。
支付寶子介面到首頁:
放棄了平滑過渡,直接使用系統提供的效果。
知乎新年版:
很突兀的出現,很突兀的消失。
下面來看做得最好的微信(這裡有點跑幀,大家可以自己開啟微信檢視):
幾乎和系統的效果一模一樣。4.2 一個bug
當有 UITabBarController
時,
並且實現了控制器的 hidesBottomBarWhenPushed
為 true
和 navigationBar.isTranslucent = true
,會出現一個 bug
:
這是因為我們的容器控制器和 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
非常巴適:
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
這種方式也是很好的,不過使用了太多系統沒有直接開放的東西,和系統的耦合性比較大。直接看這篇部落格吧。
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
}
複製程式碼
五、後記
關於這方面的坑點暫時只研究了這些,如果讀者專案中還有其他坑點,歡迎在評論中大家一起討論。
其餘參考文章: