一、前言
swift版本: 4.0
Xcode版本: 9.2 (9C40b)
討論的iOS版本: iOS9-iOS11
隨著 iOS 的不斷進化, UINavigationBar 越來越復(fù)雜,造成的結(jié)果就是開發(fā)中有些問題不好解決。并且很多時候伴隨著 Status Bar 和 iPhoneX 的影響,這就讓問題更加復(fù)雜化了,下面就來看看具體的問題。
二、查看NavigationBar的層級
參考:iOS遍歷打印所有子視圖
先來看看 UINavigationBar的視圖層級, 方面后面我們對其進行操作。下面分兩種方式來查看:
-
Debug View HierarchyXcode 自帶的查看視圖層級功能)。 - 運行在模擬器中并使用代碼打印。
定義一個打印視圖層級的函數(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:
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
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:
上圖只是展示了 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)整 UIButton 的 imageEdgeInsets 和 titleEdgeInsets 位置偏移來勉強達(dá)到效果,不過這種方法有一個問題,如圖:
左邊邊距依然沒有消失,而圖片的位置給用戶一種錯覺,認(rèn)為圖片的位置是按鈕中心,當(dāng)用戶點擊到左邊邊距區(qū)域,就超出了按鈕的點擊范圍。并且這里只有一個 Item, 多個 Item 時誤觸的情況就更多了。
通過這位道友的文章內(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é)果:
可以看到 UINavigationBarContentView 的 layoutMargins 屬性中邊距剛好就是 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)
}
}
}
}
然后運行,大功告成:
3.2 設(shè)置 leftBarButtonItem 后系統(tǒng)自帶滑動返回消失
這個問題可以使用繼承或 Runtime 解決, Runtime 方式這里有一個韓國的開發(fā)者的實現(xiàn)方式:
這個問題原因是因為我們自定義的 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
- 直接設(shè)置
interactivePopGestureRecognizer就能開啟手勢, 設(shè)置自己的代理為了解決后面的一個bug。
- 直接設(shè)置
- 是上面那個韓國開發(fā)者框架
issues中的一個bug,應(yīng)該是解決用按鈕自定義UIBarButtonItem的一個問題,我沒有詳細(xì)嘗試這個,感興趣的可以試一試。
- 是上面那個韓國開發(fā)者框架
- 承接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ā)者的框架中只是用了如下:
- 承接1中的
- (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)的效果:
可以看到 NavigationBar 的背景是有毛玻璃效果的,并且過渡時上面的內(nèi)容自帶動畫效果。
然后再來看主流 APP 的實現(xiàn)方式,這里的 gif 有些許錄制誤差,大家可以自己打開 App 查看:
QQ個人主頁返回消息界面:
打斷滑動動畫時,出現(xiàn)了一個
Bar,并馬上消失,很像自己添加的一個 Bar。
支付寶子界面到首頁:
放棄了平滑過渡,直接使用系統(tǒng)提供的效果。
知乎新年版:
很突兀的出現(xiàn),很突兀的消失。
下面來看做得最好的微信(這里有點跑幀,大家可以自己打開微信查看):
幾乎和系統(tǒng)的效果一模一樣。
4.2 一個bug
當(dāng)有 UITabBarController 時,
并且實現(xiàn)了控制器的 hidesBottomBarWhenPushed 為 true 和 navigationBar.isTranslucent = true ,會出現(xiàn)一個 bug :
這是因為我們的容器控制器和 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 非常巴適:
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)于這方面的坑點暫時只研究了這些,如果讀者項目中還有其他坑點,歡迎在評論中大家一起討論。
其余參考文章: