1. 程式人生 > >iOS系統中導航欄的轉場解決方案與最佳實踐

iOS系統中導航欄的轉場解決方案與最佳實踐

背景

目前,開源社群和業界內已經存在一些 iOS 導航欄轉場的解決方案,但對於歷史包袱沉重的美團 App 而言,這些解決方案並不完美。有的方案不能滿足複雜的頁面跳轉場景,有的方案遷移成本較大,為此我們提出了一套解決方案並開發了相應的轉場庫,目前該轉場庫已經成為美團點評多個 App 的基礎元件之一。

在美團 App 開發的早期,涉及到導航欄樣式改變的需求時,經常會遇到轉場效果不佳或者與預期樣式不符的“小問題”。在業務體量較小的情況下,為了滿足快速的業務迭代,通常會使用硬編碼的方式來解決這一類“小問題”。但隨著美團 App 業務的高速發展,這種硬編碼的方式遇到了以下的挑戰:

  1. 業務模組的不斷增加,導致使用硬編碼方式編寫的程式碼維護成本增加,程式碼質量迅速下降。
  2. 大型 App 的路由系統使得頁面間的跳轉變得更加自由和靈活,也使得導航欄相關的問題激增,不但增加了問題的排查難度,還降低了整體的開發效率。
  3. App 中的導航欄屬於各個業務方的公用資源,由於缺乏相應的約束機制和最佳實踐,導致業務方之間的程式碼耦合程度不斷增加。

從各個角度來看,硬編碼的方式已經不能很好的解決此類問題,美團 App 需要一個更加合理、更加持久、更加簡單易行的解決方案來處理導航欄轉場問題。

本文將從導航欄的概念入手,通過講解轉場過程中的狀態管理、轉換時機和樣式變化等內容,引出了在大型應用中導航欄轉場的三種常見解決方案,並對美團點評的解決方案進行剖析。

重新認識導航欄

導航欄裡的 MVC

在 iOS 系統中, 蘋果公司不僅建議開發者遵循 MVC 開發框架,在它們的程式碼裡也可以看到 MVC 的影子,導航欄元件的構成就是一個類似 MVC 的結構,讓我們先看看下面這張圖:

02導航欄元件關係圖

在這張圖裡,我們可以將 UINavigationController 看做是 C,UINavigationBar 看做是 V,而 UIViewController 和 UINavigationItem 組成的 Stack 可以看做是 M。這裡要說明的是,每個 UIViewController 都有一個屬於自己的 UINavigationItem,也就是說它們是一一對應的。

UINavigationController 通過驅動 Stack 中的 UIViewController 的變化來實現 View 層級的變化,也就是 UINavigationBar 的改變。而 UINavigationBar 樣式的資料就儲存在 UIViewController 的 UINavigationItem 中。這也就是為什麼我們在程式碼裡只要設定 self.navigationItem

的相關屬性就可以改變 UINavigationBar 的樣式。

很多時候,國內的開發者會將 UINavigationBar 和 UINavigationController 混在一起叫導航欄,這樣的做法不僅增加了開發者之間的溝通成本,也容易導致誤解。畢竟它們是兩個完全不一樣的東西。

所以本文為了更好的闡明問題,會採用英文區分不同的概念,當需要描述籠統的導航欄概念時,會使用導航欄元件一詞。

通過這一節的回顧,我們應該明確了 NavigationItem、ViewController、NavigationBar 和 NavigationController 在 MVC 框架下的角色。下面我們會重新梳理一下導航欄的生命週期和各個相關方法的呼叫順序。

導航欄元件的生命週期

大家可以通過下圖獲得更為直觀的感受,進而瞭解到導航欄元件在 push 過程中各個方法的呼叫順序。

03push過程中的方法呼叫順序圖

值得注意的地方有兩點:

第一個是 UINavigationController 作為 UINavigationBar 的代理,在沒有特殊需求的情況下,不應該修改其代理方法,這裡是通過符號斷點獲取它們的呼叫順序。如果我們建立了一個自定義的導航欄元件系統,它的呼叫順序可能會與此不同。

第二個是用虛線圈起來的方法,它們也有可能不被呼叫,這與 ViewController 裡的佈局程式碼相關,假設跳轉到新頁面後,新舊頁面中的控制元件位置會發生變化,或者由於資料改變驅動了控制元件之間的約束關係發生變化,這就會帶來新一輪的佈局,進而觸發 viewWillLayoutSubviewviewDidLayoutSubview 這兩個方法。當然,具體的呼叫順序會與業務程式碼緊密相關,如果我們發現順序有所不同,也不必驚慌。

下面這張圖展示了導航欄在 pop 過程中各個方法的呼叫順序:

04pop過程中的方法呼叫順序圖

除了上面說到的兩點,pop 過程中還需要注意一點,那就是從 B 返回到 A 的過程中,A 檢視控制器的 viewDidLoad 方法並不會被呼叫。關於這個問題,只要提醒一下,大多數人都會反應過來是為什麼。不過在實際開發過程中,總會有人忘記這一點。

通過這兩個圖,我們已經基本瞭解了導航欄元件的生命週期和相關方法的呼叫順序,這也是後面章節的理論基礎。

導航欄元件的改變與革新

導航欄元件在 iOS 11 釋出時,獲得了重大更新,這個更新可不是增加了一個大標題樣式(Large Title Display Mode)那麼簡單,需要注意的地方大概有兩點:

  1. 導航欄全面支援 Auto Layout 且 NavigationBar 的層級發生了明顯的改變,關於這一點可以閱讀 UIBarButtonItem 在 iOS 11 上的改變及應對方案

  2. 由於引進了 Safe Area 等概念,topLayoutGuidebottomLayoutGuide 等屬性會逐漸廢棄,雖然變化不大,但如果我們的導航欄在轉場過程中總是出現檢視上下移動的現象,不妨從這個方面思考一下,如果想深究可以檢視 WWDC 2017 Session 412

導航欄元件到底怎麼了?

經常有人說 iOS 的原生導航欄元件不好使用,抱怨主要集中在導航欄元件的狀態管理和控制元件的佈局問題上。

控制元件的佈局問題隨著 iOS 11 的到來已經變得相對容易處理了不少,但導航欄元件的狀態管理仍然讓開發者頭疼不已。

可能已經有朋友在思考導航欄元件的狀態管理到底是什麼東西?不要著急,下面的章節就會做相關的介紹。

導航欄的狀態管理

雖然導航欄元件的 push 和 pop 動畫給人一種每次操作後都會建立一遍導航欄元件的錯覺,但實際上這些 ViewController 都是由一個 NavigationController 所管理,所以你看到的 NavigationBar 是唯一的。

05導航欄示例圖

在 NavigationController 的 Stack 儲存結構下,每當 Stack 中的 ViewController 修改了導航欄,勢必會影響其他 ViewController 展示的效果。

例如下圖所示的場景,如果 NavigationBar 原先的顏色是綠色,但之後進入 Stack 裡的 ViewController 將 NavigationBar 顏色修改為紫色後,在此之後 push 的 ViewController 會從預設的綠色變為紫色,直到有新的 ViewController 修改導航欄顏色才會發生變化。

06導航欄push狀態

雖然在 push 過程中,NavigationBar 的變化聽起來合情合理,但如果你在 NavigationBar 為綠色的 ViewController 裡設定不當的話,那麼當你 pop 回這個 ViewController 時,NavigationBar 可就不一定是綠色了,它還會保持為紫色的狀態。

07導航欄pop狀態

通過這個例子,我們大概會意識到在導航欄裡的 Stack 中,每個 ViewController 都可以永久的影響導航欄樣式,這種全域性性的變化要求我們在實際開發中必須堅持“誰修改,誰復原”的原則,否則就會造成導航欄狀態的混亂。這不僅僅是樣式上的混亂,在一些極端狀況下,還有可能會引起 Stack 混亂,進而造成 Crash 的情況。

導航欄樣式轉換的時機

我們剛才提到了“誰修改,誰復原”的原則,但何時修改,何時復原呢?

對於那些儲存在 Stack 中的 ViewController 而言,它其實就是在不斷的經歷 appear 和 disappear 的過程,結合 ViewController 的生命週期來看,viewWillAppear:viewWillDisappear: 是兩個完美的時間節點,但很多人卻對這兩個方法的呼叫存在疑惑。

蘋果公司在它的 API 文件中專門用了一段文字來解答大家的疑惑,這段文字的標題為《Handling View-Related Notifications》,在這裡我們直接引用原文:

When the visibility of its views changes, a view controller automatically calls its own methods so that subclasses can respond to the change. Use a method like viewWillAppear: to prepare your views to appear onscreen, and use the viewWillDisappear: to save changes or other state information. Use other methods to make appropriate changes. Figure 1 shows the possible visible states for a view controller’s views and the state transitions that can occur. Not all ‘will’ callback methods are paired with only a ‘did’ callback method. You need to ensure that if you start a process in a ‘will’ callback method, you end the process in both the corresponding ‘did’ and the opposite ‘will’ callback method.

08檢視管理器的狀態轉換

這裡很好的解釋了所有的 will 系列方法和 did 系列方法的對應關係,同時也給我們吃了一個定心丸,那就是在 appearing 和 disappearing 狀態之間會由 will 系列方法進行銜接,避免了狀態中斷。這對於連續 push 或者連續 pop 的情況是及其重要的,否則我們無法做到 “誰修改,誰復原”的原則。

通常來說,如果只是一個簡單的導航欄樣式變化,我們的程式碼結構大體會如下所示:

- (void)viewWillAppear:(BOOL)animated{
    [super viewWillAppear:animated];
    // MARK: change the navigationbar style 
}

- (void)viewWillDisappear:(BOOL)animated{
    [super viewWillDisappear:animated];
    // MARK: restore the navigationbar style
}

現在,我們明確了修改時機,接下來要明確的就是導航欄的樣式會進行怎樣的變化。

導航欄的樣式變化

對於不同 ViewController 之間的導航欄樣式變化,大多可以總結為兩種情況:

  1. 導航欄的顯示與否
  2. 導航欄的顏色變化

導航欄的顯示與否

對於顯示與否的問題,可以在上一節提到的兩個方法裡呼叫 setNavigationBarHidden:animated: 方法,這裡需要提醒的有兩點:

  1. 在導航欄轉場的過程中,不要天真的以為 setNavigationBarHidden:setNavigationBarHidden:animated: 的效果是一樣的,直接使用 setNavigationBarHidden: 會造成導航欄轉場過程中的閃現、背景錯亂等問題,這一現象在使用手勢驅動轉場的場景中十分常見,所以正確的方式是使用帶有 animated 引數的 API。
  2. 在 push 和 pop 的方法裡也會帶有 animated 引數,儘量保證與 setNavigationBarHidden:animated: 中的 animated 引數一致。

導航欄的顏色變化

顏色變化的問題就稍微複雜一些,在 iOS 7 後,導航欄增加了 translucent 效果,這使得導航欄背景色的變化出現了兩種情況:

  1. translucent 屬性值為 YES 的前提下,更改導航欄的背景色。
  2. translucent 屬性值為 NO 的前提下,更改導航欄的背景色。

對於第一種情況,我們需要呼叫 UINavigationBar 的 setBackgroundColor: 方法。

對於第二種情況我們需要呼叫 UINavigationBar 的 setBackgroundImage:forBarMetrics: 方法。

對於第二種情況,這裡有三點需要提示:

  1. 在設定透明效果時,我們通常可以直接設定一個 [UIImage new] 建立的物件,無須建立一個顏色為透明色的圖片。
  2. 在使用 setBackgroundImage:forBarMetrics: 方法的過程中,如果影象裡存在 alpha 值小於 1.0 的畫素點,則 translucent 的值為 YES,反之為 NO。也就是說,如果我們真的想讓導航欄變成純色且沒有 translucent 效果,請保證所有畫素點的 alpha 值等於 1。
  3. 如果設定了一個完全不透明的圖片且強行將 NavigationBar 的 translucent 屬性設定為 YES 的話,系統會自動修正這個圖片併為它新增一個透明度,用於模擬 translucent 效果。
  4. 如果我們使用了一個帶有透明效果的圖片且導航欄的 translucent 效果為 NO 的話,那麼系統會在這個帶有透明效果的圖片背後,新增一個不透明的純色圖片用於整體效果的合成。這個純色圖片的顏色取決於 barStyle 屬性,當屬性為 UIBarStyleBlack 時為黑色,當屬性為 UIBarStyleDefault 時為白色,如果我們設定了 barTintColor,則以設定的顏色為基準。

分清楚 transparenttranslucentopaquealphaopacity 也挺重要

在剛接觸導航欄 API 時,許多人經常會把文件裡的這些英文詞搞混,也不太明白帶有這些詞的變數為什麼有的是布林型,有的是浮點型,總之一切都讓人很困惑。

在這裡將做了一個總結,這對於理解 Apple 的 API 設計原則十分有幫助。

transparenttranslucentopaque 三個詞經常會用在一起,它用於描述物體的透光強度,為了讓大家更好的理解這三個詞,這裡做了三個比喻:

  • transparent 是指透明,就好比我們可以透過一面乾淨的玻璃清楚的看到外面的風景。
  • translucent 是指半透明,就好比我們可以透過一面有點磨砂效果的塑料牆看外面的風景,不能說看不見,但我們肯定看不清。
  • opaque 是指不透明,就好比我們透過一個堵石牆是看不見任何外面的東西,眼前看到的只有這面牆。

這三個詞更多的是用來表述一種狀態,不需要量化,所以這與這三個詞相關的屬性,一般都是 BOOL 型別。

09transparent-translucent-opaque的區別

alphaopacity 經常會在一起使用,它要表示的就是透明度,在 Web 端這兩個屬性有著明顯的區別。

在 Web 端裡,opacity 是設定整個元素的透明值,而 alpha 一般是放在顏色設定裡面,所以我們可以做到對特定對元素的某個屬性設定 alpha,比如背景、邊框、文字等。

div {
  width: 100px;
  height: 100px;
  background: rgba(0,0,0,0.5);
  border: 1px solid #000000;
  opacity: 0.5;
}

這一概念同樣適用於 iOS 裡的概念,比如我們可以通過 alpha 通道單獨的去設定 backgroudColorborderColor,它們互不影響,且有著獨立的 alpha 通道,我們也可以通過 opacity 統一設定整個 view 的透明度。

但與 Web 端不一致的是,iOS 裡面的 view 不光擁有獨立的 alpha 屬性,同時也是基於 CALayer,所以我們可以看到任意 UIView 物件下面都會有一個 layer 的屬性,用於表明 CALayer 物件。view 的 alpha 屬性與 layer 裡面的 opacity 屬性是一個相等的關係,需要注意的是 view 上的 alpha 屬性是 Web 端並不具備的一個能力,所以筆者認為:在 iOS 中去說 alpha 時,要區分是在說 view 上的屬性,還是在說顏色通道里的 alpha

由於這兩個詞都是在描述程度,所以我們看到它們都是 CGFloat 型別:

10alpha-opacity的區別

轉場過程中需要注意的問題和細節

說完了導航欄的轉場時機和轉場方式,其實大體上你已經能處理好不同樣式間的轉換,但還有一些細節需要你去考慮,下面我們來說說其中需要你關注的兩點。

translucent 屬性帶來的佈局改變

translucent 會影響導航欄元件裡 ViewController 的 View 佈局,這裡需要大家理清 5 個 API 的使用場景:

  1. edgesForExtendedLayout
  2. extendedLayoutIncluedsOpaqueBars
  3. automaticallyAdjustScrollViewInsets
  4. contentInsetAdjustmentBehavior
  5. additionalSafeAreaInsets

如果我們先定義一個 UINavigationController,它裡面包含了多個 UIViewController,每個 UIViewController 裡面包含一個 UIView 物件:

  • 那麼 edgesForExtendedLayout 是為了解決 UIViewController 與 UINavigationController 的對齊問題,它會影響 UIViewController 的實際大小,例如 edgesForExtendedLayout 的值為 UIRectEdgeAll 時,UIViewController 會佔據整個螢幕的大小。
  • 當 UIView 是一個 UIScrollView 類或者子類時,automaticallyAdjustsScrollViewInsets 是為了調整這個 UIScrollView 與 UINavigationController 的對齊問題,這個屬性並不會調整 UIViewController 的大小。
  • 對於 UIView 是一個 UIScrollView 類或者子類且導航欄的背景色是不透明的狀態時,我們會發現使用 edgesForExtendedLayout 來調整 UIViewController 的大小是無效的,這時候你必須使用 extendedLayoutIncludesOpaqueBars 來調整 UIViewController 的大小,可以認為 extendedLayoutIncludesOpaqueBars 是基於 automaticallyAdjustsScrollViewInsets 誕生的,這也是為什麼經常會看到這兩個 API 會同時使用。

這些調整佈局的 API 背後是一套基於 topLayoutGuidebottomLayoutGuide 的計算而已,在 iOS 11 後,Apple 提出了 Safe Area 的概念,將原先分裂開來的 topLayoutGuidebottomLayoutGuide 整合到一個統一的 LayoutGuide 中,也就是所謂的 Safe Area,這個改變看起來似乎不是很大,但它的出現確實方便了開發者。

11safe-area示例圖

如果想對 Safe Area 帶來的改變有更全面的認識,十分推薦閱讀 Rosberry 的工程師 Evgeny Mikhaylov 在 Medium 上的文章 iOS Safe Area,這篇文章基本涵蓋了 iOS 11 中所有與 Safe Area 相關的 API 並給出了真正合理的解釋。

這裡只說一下 contentInsetAdjustmentBehavioradditionalSafeAreaInsets 兩個 API。

對於 contentInsetAdjustmentBehavior 屬性而言,它的誕生也意味著 automaticallyAdjustsScrollViewInsets 屬性的失效,所以我們在那些已經適配了 iOS 11 的工程裡能看到如下類似的程式碼:

if (@available(iOS 11.0, *)) {
    self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
} else {
    self.automaticallyAdjustsScrollViewInsets = NO;
}

此處的程式碼片段只是一個示例,並不適用所有的業務場景,這裡需要著重說明幾個問題:

  1. 關於 contentInsetAdjustmentBehavior 中的 UIScrollViewContentInsetAdjustmentAutomatic 的說明一直很“模糊”,通過 Evgeny Mikhaylov 的文章,我們可以瞭解到他在大多數情況下會與 UIScrollViewContentInsetAdjustmentScrollableAxes 一致,當且僅當滿足以下所有條件時才會與 UIScrollViewContentInsetAdjustmentAlways 相似:

    • UIScrollView 型別的檢視在水平軸方向是可滾動的,垂直軸是不可滾動的。
    • ViewController 視圖裡的第一個子控制元件是 UIScrollView 型別的檢視。
    • ViewController 是 navigation 或者 tab 型別控制器的子檢視控制器。
    • 啟用 automaticallyAdjustsScrollViewInsets
  2. iOS 11 後,通過 contentInset 屬性獲取的偏移量與 iOS 10 之前的表現形式並不一致,需要獲取 adjustedContentInset 屬性才能保證與之前的 contentInset 屬性一致,這樣的改變需要我們在程式碼裡對不同的版本進行適配。

對於 additionalSafeAreaInsets 而言,如果系統提供的這幾種行為並不能滿足我們的佈局要求,開發者還可以考慮使用 additionalSafeAreaInsets 屬性做調整,這樣的設定使得開發者可以更加靈活,更加自由的調整檢視的佈局。

backIndicator 上的動畫

蘋果提供了許多修改導航欄元件樣式的 API,有關於佈局的,有關於樣式的,也有關於動畫的。backIndicatorImagebackIndicatorTransitionMaskImage 就是其中的兩個 API。

backIndicatorImagebackIndicatorTransitionMaskImage 操作的是 NavigationBar 裡返回按鈕的圖片,也就是下圖紅色圓圈所標註的區域。

12backIndicator示例圖

想要成功的自定義返回按鈕的圖示樣式,我們需要同時設定這兩個 API ,從字面上來看,它們一個是返回圖片本身,另一個是返回圖片在轉場時用到的 mask 圖片,看起來不怎麼難,我們寫一段程式碼試試效果:

self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"];
self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrowMask"];

程式碼裡的圖片如下所示:

13mask圖片示例圖1

也許大多數人在這裡會都認為,mask 圖片會遮擋住文字使其在遇到返回按鈕右邊緣的時候就消失。但實際的執行效果是怎麼樣子的呢?我們來看一下:

14mask動態效果圖1

在上面的圖片中,我們可以看到返回按鈕的文字從返回按鈕的圖片下面穿過並且文字被圖片所遮擋,這種動畫看起來十分奇怪,這是無法接受的。我們需要做點修改:

self.navigationController.navigationBar.backIndicatorImage = [UIImage imageNamed:@"backArrow"];
self.navigationController.navigationBar.backIndicatorTransitionMaskImage = [UIImage imageNamed:@"backArrow"];

這一次我們將 backIndicatorTransitionMaskImage 改為 indicatorImage 所用的圖片。

15mask圖片示例圖2

到這裡,可能大多數人都會好奇,這程式碼也能行?讓我們看下它實際的效果:

16mask動態效果圖2

在上面的圖中,我們看到文字在到達圖片的右邊緣時就從下方穿過並被完全遮蓋住了,這種動畫效果雖然比上面好一些,但仍然有改進的空間,不過這裡我們先不繼續優化了,我們先來討論一下它們背後的運作原理。

iOS 系統會將 indicatorImage 中不透明的顏色繪製成返回按鈕的圖示, indicatorTransitionMaskImage 與 indicatorImage 的作用不同。indicatorTransitionMaskImage 將自身不透明的區域像 mask 一樣作用在 indicatorImage 上,這樣就保證了返回按鈕中的文字像左移動時,文字只出現在被 mask 的區域,也就是 indicatorTransitionMaskImage 中不透明的區域。

掌握了原理,我們來解釋下剛才的兩種現象:

在第一種實現中,我們提供的 indicatorTransitionMaskImage 覆蓋了整個返回按鈕的圖示,所以我們在轉場過程中可以清晰的看到返回按鈕的文字。

在第二種實現中,我們使用 indicatorImage 作為 indicatorTransitionMaskImage,記住文字是隻能出現在 indicatorTransitionMaskImage 裡不透明的區域,所以顯然返回按鈕中的文字會在圖示的最右邊就已經被遮擋住了,因為那片區域是透明的。

那麼前面提到的進一步優化指的是什麼呢?

讓我們來看一下下面這個示例圖,為了更好的區分,我們將 indicatorTransitionMaskImage 用紅色進行標註。黑色仍然是 indicatorImage。

17mask圖片示例圖3

按照剛才介紹的原理,我們應該可以理解,現在文字只會出現在紅色區域,那麼它的實際效果是什麼樣子的呢,我們可以看下圖:

18mask動態效果圖3

現在,一個完美的返回動畫,誕生啦!

導航欄的跳轉或許可以這麼玩兒…

前兩章的鋪墊就是為了這一章的內容,所以現在讓我們開始今天的大餐吧。

這樣真的好麼?

剛才我們說了兩個頁面間 NavigationBar 的樣式變化需要在各自的 viewWillAppear:viewWillDisappear: 中進行設定。那麼問題就來了:這樣的設定會帶來什麼問題呢?

試想一下,當我們的頁面會跳到不同的地方時,我們是不是要在 viewWillAppear:viewWillDisappear: 方法裡面寫上一堆的判斷呢?如果應用裡還有 router 系統的話,那麼頁面間的跳轉將變得更加不可預知,這時候又該如何在 viewWillAppear:viewWillDisappear: 裡做判斷呢?

現在我們的問題就來了,如何讓導航欄的轉場更加靈活且相互獨立呢?

常見的解決方案如下所示:

  1. 重新實現一個類似 UINavigationController 的容器類檢視管理器,這個容器類檢視管理器做好不同 ViewController 間的導航欄樣式轉換工作,而每個 ViewController 只需要關心自身的樣式即可。

    19常見的導航欄轉場方案1示例圖

  2. 將系統原有導航欄的背景設定為透明色,同時在每個 ViewController 上新增一個 View 或者 NavigationBar 來充當我們實際看到的導航欄,每個 ViewController 同樣只需要關心自身的樣式即可。

    20常見的導航欄轉場方案2示例圖

  3. 在轉場的過程中隱藏原有的導航欄並新增假的 NavigationBar,當轉場結束後刪除假的 NavigationBar 並恢復原有的導航欄,這一過程可以通過 Swizzle 的方式完成,而每個 ViewController 只需要關心自身的樣式即可。

    21常見的導航欄轉場方案3示例圖

這三種方案各有優劣,我們在網上也可以看到很多關於它們的討論。

例如方案一,雖然看起來工作量大且難度高,但是這個工作一旦完成,我們就會將處理導航欄轉場的主動權牢牢抓在手裡。但這個方案的一個弊端就是,如果蘋果修改了導航欄的整體風格,就好比 iOS 11 的大標題特效,那麼工作量就來了。

對於方案二而言,雖然看起來簡單易用,但這需要一個良好的繼承關係,如果整個工程裡的繼承關係混亂或者是歷史包袱比較重,後續的維護就像“打補丁”一樣,另外這個方案也需要良好的團隊程式碼規範和完善的技術文件來做輔助。

對於方案三而言,它不需要所謂的繼承關係,使用起來也相對簡單,這對於那些繼承關係和歷史包袱比較重的工程而言,這一個不錯的解決方案,但在解決 Bug 的時候,Swizzle 這種方式無疑會增加解決問題的時間成本和學習成本。

我們的解決方案

在美團 App 的早期,各個業務方都想充分利用導航欄的能力,但對於導航欄的狀態維護缺乏理解與關注,隨著業務方的增加和程式碼量的上升,與導航欄相關的問題逐漸暴露出來,此時我們才意識到這個問題的嚴重性。

大型 App 的導航欄問題就像一個典型的“公地悲劇”問題。在軟體行業,公用程式碼的所有權可以被視作“公地”,因為不注重長期需求而容易遭到消耗。如果開發人員傾向於交付“價值”,而以可維護性和可理解性為代價,那麼這個問題就特別普遍了。如果是這種情況,每次程式碼修改將大大減少其總體質量,最終導致軟體的不可維護。

所以解決這個問題的核心在於:明確公用程式碼的所有權,並在開發期施加約束。

明確公用程式碼的所有權,可以理解為將導航欄相關的元件抽離成一個單獨的元件,並交由特定的團隊維護。而在開發期施加約束,則意味著我們要提供一套完整的解決方案讓各個業務方遵守。

這一節我們會以美團內部的解決方案為例,講解如何實現一個流暢的導航欄跳轉過程和相關使用方法。

設計理念

使用者只用關心當前 ViewController 的 NavigationBar 樣式,而不用在 push 或者 pop 的時候去處理 NavigationBar 樣式。

舉個例子來說,當從 A 頁面 push 到 B 頁面的時候,轉場庫會儲存 A 頁面的導航欄樣式,當 pop 回去後就會還原成以前的樣式,因此我們不用考慮 pop 後導航欄樣式會改變的情況,同時我們也不必考慮 push 後的情況,因為這個是頁面 B 本身需要考慮的。

使用方法

轉場庫的使用十分簡單,我們不需要 import 任何標頭檔案,因為它在底層通過 Method Swizzling 進行了處理,只需要在使用的時候遵循下面 4 點即可:

  • 當需要改變導航欄樣式的時候,在檢視控制器的 viewDidLoad 或者 viewWillAppear: 方法裡去設定導航欄樣式。
  • setBackgroundImage:forBarMetrics: 方法和 shadowImage 屬性去修改導航欄的背景樣式。
  • 不要在 viewWillDisappear: 裡新增針對導航欄樣式修改的程式碼。
  • 不要隨意修改 translucent 屬性,包括隱式的修改和顯示的修改。

隱式修改是指使用 setBackgroundImage:forBarMetrics: 方法時,如果 image 裡的畫素點沒有 alpha 通道或者 alpha 全部等於 1 會使得 translucent 變為 NO 或者 nil。

基本原理

以上,我們講完了設計理念和使用方法,那麼我們來看看美團的轉場庫到底做了什麼?

從大方向上來看,美團使用的是前面所說的第三種方案,不過它也有一些自己獨特的地方,為了更好的讓大家理解整個過程,我們設計這樣一個場景,從頁面 A push 到頁面 B,結合之前探討過的方法呼叫順序,我們可以知道幾個核心方法的呼叫順序大致如下:

  1. 頁面 A 的 pushViewController:animated:
  2. 頁面 B 的 viewDidLoad or viewWillAppear:
  3. 頁面 B 的 viewWillLayoutSubviews
  4. 頁面 B 的 viewDidAppear:

在 push 過程的開始,轉場庫會在頁面 A 自身的 view 上新增一個與導航欄一模一樣的 NavigationBar 並將真的導航欄隱藏。之後這個假的導航欄會一直存在頁面 A 上,用於保留 A 離開時的導航欄樣式。

等到頁面 B 呼叫 viewDidLoad 或者 viewWillAppear: 的時候,開發者在這裡自行設定真的導航欄樣式。轉場庫在這裡會對頁面佈局做一些修正和輔助操作,但不會影響導航欄的樣式。

等到頁面 B 呼叫 viewWillLayoutSubviews 的時候,轉場庫會在頁面 B 自身的 view 上新增一個與真的導航欄一模一樣的 NavigationBar,同時將真的導航欄隱藏。此時不論真的導航欄,還是假的導航欄都已經與 viewDidLoad 或者 viewWillAppear: 裡設定的一樣的。

當然,這一步也可以放在 viewWillAppear: 裡並在 dispatch main queue 的下一個 runloop 中處理。

等到頁面 B 呼叫 viewDidAppear: 的時候,轉場庫會將假的導航欄樣式設定到真的導航欄中,並將假的導航欄從檢視層級中移除,最終將真的導航欄顯示出來。

為了讓大家更好地理解上面的內容,請參考下圖:

22KMNavigationBarTransiton的原理圖-push流程

說完了 push 過程,我們再來說一下從頁面 B pop 回頁面 A 的過程,幾個核心方法的呼叫順序如下:

  1. 頁面 B 的 popViewControllerAnimated:
  2. 頁面 A 的 viewWillAppear:
  3. 頁面 A 的 viewDidAppear:

在 pop 過程的開始,轉場庫會在頁面 B 自身的 view 上新增一個與導航欄一模一樣的 NavigationBar 並將真的導航欄隱藏,雖然這個假的導航欄會一直存在於頁面 B 上,但它自身會隨著頁面 B 的 dealloc 而消亡。

等到頁面 A 呼叫 viewWillAppear: 的時候,開發者在這裡自行設定真的導航欄樣式。當然我們也可以不設定,因為這時候頁面 A 還持有一個假的導航欄,這裡還保留著我們之前在 viewDidLoad 裡寫的導航欄樣式。

等到頁面 A 呼叫 viewDidAppear: 的時候,轉場庫會將假的導航欄樣式設定到真的導航欄中,並將假的導航欄從檢視層級中移除,最終將真的導航欄顯示出來。

同樣,我們可以參考下面的圖來理解上面所說的內容:

23KMNavigationBarTransiton的原理圖-pop流程

現在,大家應該對我們美團的解決方案有了一定的認識,但在實際開發過程中,還需要考慮一些佈局和適配的問題。

最佳實踐

在維護這套轉場方案的時間裡,我們總結了一些此類方案的最佳實踐。

判斷導航欄問題的基本準則

如果發現導航欄在轉場過程中出現了樣式錯亂,可以遵循以下幾點基本原則:

  • 檢查相應 ViewController 裡是否有修改其他 ViewController 導航欄樣式的行為,如果有,請做調整。
  • 保證所有對導航欄樣式變化的操作出現在 viewDidLoadviewWillAppear: 中,如果在 viewWillDisappear: 等方法裡出現了對導航欄的樣式修改的操作,如果有,請做調整。
  • 檢查是否有改動 translucent 屬性,包括顯示修改和隱式修改,如果有,請做調整。

只關心當前頁面的樣式

永遠記住每個 ViewController 只用關心自己的樣式,設定的時機點在 viewWillAppear: 或者 viewDidLoad 裡。

透明樣式導航欄的正確設定方法

如果需要一個透明效果的導航欄,可以使用如下程式碼實現:

[self.navigationController.navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
self.navigationController.navigationBar.shadowImage = [UIImage new]; 

導航欄的顏色漸變效果

如果需要導航欄實現隨滾動改變整體 alpha 值的效果,可以通過改變 setBackgroundImage:forBarMetrics: 方法裡 image 的 alpha 值來達到目標,這裡一般是使用監聽 scrollView.contentOffset 的手段來做。請避免直接修改 NavigationBar 的 alpha 值。

還有一點需要注意的是,在頁面轉場的過程中,也會觸發 contentOffset 的變化,所以請儘量在 disappear 的時候取消監聽。否則會容易出現導航欄透明度的變化。

導航欄背景圖片的規範

請避免背景圖裡的畫素點沒有 alpha 通道或者 alpha 全部等於 1,容易觸發 translucent 的隱式改變。

如果真的要隱藏導航欄

如果我們需要隱藏導航欄,請保證所有的 ViewController 能堅持如下原則:

  1. 每個 ViewController 只需要關心當前頁面下的導航欄是否被隱藏。
  2. viewWillAppear: 中,統一設定導航欄的隱藏狀態。
  3. 使用 setNavigationBarHidden:animated: 方法,而不是 setNavigationBarHidden:

轉場動畫與導航欄隱藏動畫的一致性

如果在轉場的過程中還會顯示或者隱藏導航欄的話,請保證兩個方法的動畫引數一致。

- (void)viewWillAppear:(BOOL)animated{
    [self.navigationController setNavigationBarHidden:YES animated:animated];
}

viewWillAppear: 裡的 animated 引數是受 push 和 pop 方法裡 animated 引數影響。

導航欄固有的系統問題

目前已知的有兩個系統問題如下:

  1. 當前後兩個 ViewController 的導航欄都處於隱藏狀態,然後在後一個 ViewController 中使用返回手勢 pop 到一半時取消,再連續 push 多個頁面時會造成導航欄的 Stack 混亂或者 Crash。
  2. 當頁面的層級結構大體如下所示時,在紅色導航欄的 Stack 中,返回手勢會大概率的出現跨層級的跳轉,多次後會導致整個導航欄的 Stack 錯亂或者 Crash。

24引發導航欄棧錯亂的檢視層級

導航欄內建元件的佈局規範

導航欄裡的元件佈局在 iOS 11 後發生了改變,原有的一些解決方案已經失效,這些內容不在本篇文章的討論範圍之內,推薦閱讀UIBarButtonItem 在 iOS 11 上的改變及應對方案,這篇文章詳細的解釋了 iOS 11 裡的變化和可行的應對方案。

總結

本文涉及內容較多,從 iOS 系統下的導航欄概念到大型應用裡的最佳實踐,這裡我們總結一下整篇文章的核心內容:

  • 理解導航欄元件的結構和相關方法的生命週期。
    • 導航欄元件的結構留有 MVC 架構的影子,在解決問題時,要去相應的層級處理。
    • 轉場問題的關鍵點是方法的呼叫順序,所以瞭解生命週期是解決此類問題的基礎。
  • 狀態管理,轉換時機和樣式變化是導航欄裡常見問題的三種表現形式,遇到實際問題時需要區分清楚。
    • 狀態管理要堅持“誰修改,誰復原”的原則。
    • 轉換時機的設定要做到連續可執行。
    • 樣式變化的核心點是導航欄的顯示與否與顏色變化。
  • 為了更好的配合大型應用裡的路由系統,導航欄轉場的常見解決方案有三種,各有利弊,需要根據自身的業務場景和歷史包袱做取捨。
    • 解決方案1:自定義導航欄元件。
    • 解決方案2:在原有導航欄元件裡新增 Fake Bar。
    • 解決方案3:在導航欄轉場過程中新增 Fake Bar。
  • 美團在實際開發過程中採用了第三種方案,並給出了適合美團 App 的最佳實踐。

特別感謝莫洲騏在此專案裡的貢獻與付出。

參考連結

作者簡介

思琦,美團點評 iOS 工程師。2016 年加入美團,負責美團平臺的業務開發及 UI 元件的維護工作。

招聘

美團平臺誠招 iOS、Android、FE 高階/資深工程師和技術專家,Base 北京、上海、成都,歡迎有興趣的同學投遞簡歷到[email protected]