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

背景

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

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

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

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

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

從新認識導航欄

導航欄裏的 MVC

在 iOS 系統中, 蘋果公司不只建議開發者遵循 MVC 開發框架,在它們的代碼裏也能夠看到 MVC 的影子,導航欄組件的構成就是一個相似 MVC 的結構,讓咱們先看看下面這張圖:架構

02導航欄組件關係圖

在這張圖裏,咱們能夠將 UINavigationController 看作是 C,UINavigationBar 看作是 V,而 UIViewController 和 UINavigationItem 組成的 Stack 能夠看作是 M。這裏要說明的是,每一個 UIViewController 都有一個屬於本身的 UINavigationItem,也就是說它們是一一對應的。app

UINavigationController 經過驅動 Stack 中的 UIViewController 的變化來實現 View 層級的變化,也就是 UINavigationBar 的改變。而 UINavigationBar 樣式的數據就存儲在 UIViewController 的 UINavigationItem 中。這也就是爲何咱們在代碼裏只要設置 self.navigationItem 的相關屬性就能夠改變 UINavigationBar 的樣式。框架

不少時候,國內的開發者會將 UINavigationBar 和 UINavigationController 混在一塊兒叫導航欄,這樣的作法不只增長了開發者之間的溝通成本,也容易致使誤解。畢竟它們是兩個徹底不同的東西。ide

因此本文爲了更好的闡明問題,會採用英文區分不一樣的概念,當須要描述籠統的導航欄概念時,會使用導航欄組件一詞。oop

經過這一節的回顧,咱們應該明確了 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

前三個 API 是 iOS 11 以前的 API,它們之間的區別和聯繫在 Stack Overflow 上有一個比較精彩的回答 - Explaining difference between automaticallyAdjustsScrollViewInsets, extendedLayoutIncludesOpaqueBars, edgesForExtendedLayout in iOS7,我在這裏就不作詳細闡述,總結一下它的觀點就是:

若是咱們先定義一個 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

如今,一個完美的返回動畫,誕生啦!

此節所用的部分效果圖出自 Ray Wenderlich 的文章 UIAppearance Tutorial: Getting Started

導航欄的跳轉或許能夠這麼玩兒...

前兩章的鋪墊就是爲了這一章的內容,因此如今讓咱們開始今天的大餐吧。

這樣真的好麼?

剛纔咱們說了兩個頁面間 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 北京、上海、成都,歡迎有興趣的同窗投遞簡歷到zhangsiqi04@meituan.com。

相關文章
相關標籤/搜索