天天看點

iOS8界面開發的大一統

iOS8界面開發的大一統

(via: OneV's Den)   本文是我的 WWDC 2014 筆記 中的一篇,涉及的 Session 有 What's New in Cocoa Touch Building Adaptive Apps with UIKit What's New in Interface Builder View Controller Advancements in iOS 8 A Look Inside Presentation Controllers   iOS 8 和 OS X 10.10 中一個被強調了多次的主題就是大一統,Apple 希望通過 Hand-off 和各種體驗的無縫切換和內建将使用者黏在由 Apple 裝置構成的生态圈中。而對開發者而言,今年除了 Swift 的一個大主題也是平台的統一。在 What's New in Cocoa Touch 的 Seesion 一開始,UIKit 的工程師 Luke 就指出了 iOS 8 SDK 的最重要的關鍵字就是自适應 (adaptivity)。這是一個很激動人心的詞,首先自适應是一種設計哲學,盡量使事情保持簡單,我們便可從中擢取優雅;另一方面,可能這也是 Apple 不得不做的轉變。随着傳說中的更大屏和超大屏的 iPhone 6 的到來,開發者在為 iOS 進行開發的時候似乎也開始面臨着和安卓一樣的裝置尺寸的碎片化的問題。而 iOS 8 所着重希望解決的,就是這一問題。   Size Classes 首先最值得一說的是,iOS 8 應用在界面設計時,迎來了一個可以說是革命性的變化 - Size Classes。   基本概念 在 iPad 和 iPhone 5 出現之前,iOS 裝置就隻有一種尺寸。我們在做螢幕适配時需要考慮的僅僅有裝置方向而已。而很多應用并不支援轉向,這樣的話就完全沒有螢幕适配的工作了。随着 iPad 和 iPhone 5,以及接下來的 iPhone 6 的推出,螢幕尺寸也變成了需要考慮的對象。在 iOS 7 之前,為一個應用,特别是 universal 的應用制作 UI 時,我們總會首先想我們的目标裝置的長寬各是多少,方向變換以後布局又應該怎麼改變,然後進行布局。iOS 6 引入了 Auto Layout 來幫助開發者使用限制進行布局,這使得在某些情況下我們不再需要考慮尺寸,而可以專注于使用限制來規定位置。   既然我們有了 Auto Layout,那麼其實通過限制來指定視圖的位置和尺寸是沒有什麼問題的了,從這個方面來說,螢幕的具體的尺寸和方向已經不那麼重要了。但是實戰中這還不夠,Auto Layout 正如其名,隻是一個根據限制來進行布局的方案,而在對應不同裝置的具體情況下的體驗上還有欠缺。一個最明顯的問題是它不能根據裝置類型來确定不同的互動體驗。很多時候你還是需要判斷裝置到底是 iPhone 還是 iPad,以及現在的裝置方向究竟是豎直還是水準來做出判斷。這樣的話我們還是難以徹底擺脫對于裝置的判斷和依賴,而之後如果有新的尺寸和裝置出現的話,這種依賴關系顯然顯得十分脆弱的(想想要是有 iWatch 的話..)。   是以在 iOS 8 裡,Apple 從最初的設計哲學上将原來的方式推翻了,并引入了一整套新的理念,來适應裝置不斷的發展。這就是 Size Classes。   不再根據裝置螢幕的具體尺寸來進行區分,而是通過它們的感官表現,将其分為普通 (Regular) 和緊密 (Compact) 兩個種類 (class)。開發者便可以無視具體的尺寸,而是對這這兩類和它們的組合進行适配。這樣不論在設計時還是代碼上,我們都可以不再受限于具體的尺寸,而是變成遵循尺寸的視覺感官來進行适配。

iOS8界面開發的大一統

  簡單來說,現在的 iPad 不論橫屏還是豎屏,兩個方向均是 Regular 的;而對于 iPhone,豎屏時豎直方向為 Regular,水準方向是 Compact,而在橫屏時兩個方向都是 Compact。要注意的是,這裡和談到的裝置和方向,都僅僅隻是為了給大家一個直覺的印象。相信随着裝置的變化,這個分類也會發生變動和更新。Size Classes 的設計哲學就是尺寸無關,在實際中我們也應該盡量把具體的尺寸抛開腦後,而去盡快習慣和适應新的體系。   UITraitCollection 和 UITraitEnvironment 為了表征 Size Classes,Apple 在 iOS 8 中引入了一個新的類--UITraitCollection。這個類封裝了像水準和豎直方向的 Size Class 等資訊。iOS 8 的 UIKit 中大多數 UI 的基礎類 (包括 UIScreen,UIWindow,UIViewController 和 UIView) 都實作了 UITraitEnvironment 這個接口,通過其中的 traitCollection 這個屬性,我們可以拿到對應的 UITraitCollection 對象,進而得知目前的 Size Class,并進一步确定界面的布局。   和 UIKit 中的響應者鍊正好相反,traitCollection 将會在 view hierarchy 中自上而下地進行傳遞。對于沒有指定 traitCollection 的 UI 部件,将使用其父節點的 traitCollection。這在布局包含 childViewController 的界面的時候會相當有用。在 UITraitEnvironment 這個接口中另一個非常有用的是 -traitCollectionDidChange:。在 traitCollection 發生變化時,這個方法将被調用。在實際操作時,我們往往會在 ViewController 中重寫 -traitCollectionDidChange: 或者 -willTransitionToTraitCollection:withTransitionCoordinator: 方法 (對于 ViewController 來說的話,後者也許是更好的選擇,因為提供了轉場上下文友善進行動畫;但是對于普通的 View 來說就隻有前面一個方法了),然後在其中對目前的 traitCollection 進行判斷,并進行重新布局以及動畫。代碼看起來大概會是這個樣子:

  1. - (void)willTransitionToTraitCollection:(UITraitCollection *)newCollection  
  2.               withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator 
  3.     [super willTransitionToTraitCollection:newCollection  
  4.                  withTransitionCoordinator:coordinator]; 
  5.     [coordinator animateAlongsideTransition:^(id <UIViewControllerTransitionCoordinatorContext> context) { 
  6.         if (newCollection.verticalSizeClass == UIUserInterfaceSizeClassCompact) { 
  7.             //To Do: modify something for compact vertical size 
  8.         } else { 
  9.             //To Do: modify something for other vertical size 
  10.         } 
  11.         [self.view setNeedsLayout]; 
  12.     } completion:nil]; 

在兩個 To Do 中,我們應該删除或者添加或者更改不同條件下的 Auto Layout 限制 (當然,你也可以幹其他任何你想做的事情),然後調用 -setNeedsLayout 來在上下文中觸發轉移動畫。如果你堅持用代碼來處理的話,可能需要面臨對于不同 Size Classes 來做移除舊的限制和添加新的限制這樣的事情,可以說是很麻煩 (至少我覺得是麻煩的要死)。但是如果我們使用 IB 的話,這些事情和代碼都可以省掉,我們可以非常友善地在 IB 中指定各種 Size Classes 的限制 (稍後會介紹如何使用 IB 來對應 Size Classes)。另外使用 IB 不僅可以節約成百上千行的布局代碼,更可以從新的 Xcode 和 IB 中得到很多設計時就可以實時監視,檢視并且調試的特性。可以說手寫 UI 和使用 IB 設計的時間消耗和成本差距被進一步拉大,并且出現了很多手寫 UI 無法實作,但是 IB 可以不假思索地完成的任務。從這個意義上來說,新的 IB 和 Size Classes 系統可以說無情地給手寫代碼判了個死緩。   另外,新的 API 和體系的引入也同時給很多我們熟悉的 UIViewController 的有關旋轉的老朋友判了死刑,比如下面這些 API 都棄用了:

  1. @property(nonatomic, readonly) UIInterfaceOrientation interfaceOrientation 
  2. - willRotateToInterfaceOrientation:duration: 
  3. - willAnimateRotationToInterfaceOrientation:duration: 
  4. - didRotateFromInterfaceOrientation: 
  5. - shouldAutomaticallyForwardRotationMethods 

現在全部統一到了 viewWillTransitionToSize:withTransitionCoordinator:,旋轉的概念不再被提倡使用。其實仔細想想,所謂旋轉,不過就是一種 Size 的改變而已,我們都被 Apple 騙了好多年,不是麼?   Farewell, I will NOT miss you at all.   在 Interface Builder 中使用 Size Classes 第一次接觸 Xcode 6 和打開 IB 的時候你可能會驚呼,為什麼我的畫布變成正方形了。我在第一天 Keynote 結束後在 Moscone Center 的食堂裡第一次打開的時候,還滿以為自己找到了 iWatch 方形顯示屏的确鑿證據。到後來才知道,這是新的 Size Classes 對應的編輯方式。   既然我們不需要關心實際的具體尺寸,那麼我們也就完全沒有必要在 IB 中使用像 3.5/4 寸的 iPhone 或是 10 寸的 iPad 來分開對界面進行編輯。使用一個通用的具有 "代表" 性質的尺寸在新體系中确實更不容易使人迷惑。   在現在的 IB 界面的正下方,你可以看到一個 wAny hAny 的按鈕 (因為今年 NDA 的一個明确限制是不能發相關軟體截圖,雖然其實可能沒什麼太大問題,但是還是尊重 license 比較好),這代表現在的 IB 是對應任意高度和任意寬度的。點選後便可以選擇需要為哪種 Size Class 進行編輯。預設情況在 Any Any 下的修改會對任意裝置和任意方向生效,而如果先進行選擇後再進行編輯,就表示編輯隻對選中的設定生效。這樣我們就很容易在同一個 storyboard 檔案裡對不同的裝置進行适配:按照裝置需要添加或者編輯某些限制,或者是在特定尺寸下隐藏某些 view (使用 Attribute Inspector 裡的 Installed 選框的加号添加)。這使得使用 IB 制作通用程式變簡單了,我們不再需要為 iPhone 和 iPad 準備兩套 storyboard 了。   可以發揮的想象空間實在太大,一套界面布局通吃所有裝置的畫面太美好,我都不敢想。   Size Classes 和 Image Asset 及 UIAppearence Image Asset 裡也加入了對 Size Classes 的支援,也就是說,我們可以對不同的 Size Class 指定不同的圖檔了。在 Image Asset 的編輯面闆中選擇某張圖檔,Inspector 裡現在多了一個 Width 和 Height 的組合,添加我們需要對應的 Size Class, 然後把合适的圖拖上去,這樣在運作時 SDK 就将從中挑選對應的 Size 的圖進行替換了。不僅如此,在 IB 中我們也可以選擇對應的 size 來直接在編輯時檢視變化(新的 Xcode 和 IB 添加了非常多編輯時的可視化特性,關于這方面我有計劃單獨寫一篇可視化開發的文章進行說明)。   這個特性一個最有用的地方在于對于不同螢幕尺寸可能我們需要的圖像尺寸也有所不同。比如我們希望在 iPhone 豎屏或者 iPad 時的按鈕高一些,而 iPhone 橫屏時由于螢幕高度實在有限,我們希望得到一個扁一些的按鈕。對于純色按鈕我們可以通過簡單的限制和拉伸來實作,但是對于有圖案的按鈕,我們之前可能就需要在 VC 裡寫一些髒代碼來處理了。現在,隻需要指定好特定的 Image Asset,然後配置合适的 (比如不含有尺寸限制) 限制,我們就可以一行代碼不寫,就完成這樣複雜的各個機型和方向的适配了。   實際做起來實在是太簡單了..但拿個 demo 說明一下吧,比如下面這個實作了豎直方向 Compact 的時候将笑臉換成哭臉 -- 當然了,一行代碼都不需要。

iOS8界面開發的大一統

另外,在 iOS 7 中 UIImage 添加了一個 renderingMode 屬性。我們可以使用 imageWithRenderingMode: 并傳入一個合适的 UIImageRenderingMode 來指定這個 image 要不要以 Template 的方式進行渲染。在新的 Xcode 中,我們可以直接在 Image Asset 裡的 Render As 選項來指定是不是需要作為 template 使用。而相應的,在 UIApperance 中,Apple 也為我們對于 Size Classes 添加了相應的方法。使用 +appearanceForTraitCollection: 方法,我們就可以針對不同 trait 下的應用的 apperance 進行很簡單的設定。比如在上面的例子中,我們想讓笑臉是綠色,而哭臉是紅色的話,不要太簡單。首先在 Image Asset 裡的渲染選項設定為 Template Image,然後直接在 AppDelegate 裡加上這樣兩行:

  1. UIView.appearanceForTraitCollection(UITraitCollection(verticalSizeClass:.Compact)).tintColor = UIColor.redColor()   
  2.         UIView.appearanceForTraitCollection(UITraitCollection(verticalSizeClass:.Regular)).tintColor = UIColor.greenColor() 
iOS8界面開發的大一統

完成,隻不過拖拖滑鼠,兩行簡單的代碼,随後還能随喜換色,果然是大快所有人心的大好事。   UIViewController 的表現方式   UISplitViewController 在用 Regular 和 Compact 統一了 IB 界面設計之後,Apple 的工程師可能發現了一個讓人兩難的曆史問題,這就是 UISplitViewController。一直做 iPhone 而沒太涉及 iPad 的童鞋可能對着這個類不是很熟悉,因為它們是 iPad Only 的。iPad 推出時為了适應突然變大的螢幕,并且遠離 "放大版 iTouch" 的诟病,Apple 為 iPad 專門設計了這個主從關系的 ViewControlle容器。事實也證明了這個設計在 iPad 上确實是被廣泛使用,是非常成功的。   現在的問題是,如果我們隻有一套 UI 畫布的話,我們要怎麼在這個單一的畫布上處理和表現這個 iPad Only 的類呢?   答案是,讓它在 iPhone 上也能用就行了。沒錯,現在你可以直接在 iPhone 上使用 SplitViewController 了。在 Regular 的寬度時,它保持原來的特性,在 DetailViewController 中顯示内容,這是毫無疑問的。而在 Compact 中,我們第一想法就是以 push 的表現形式展示。在以前,我們可能需要寫不少代碼來處理這些事情,比如在 AppDelegate 中就在一開始判斷裝置是不是 iPad,然後為應用設定兩套完全不同的導航:一套基于 UINavigationController,另一套基于 UISplitViewController。而現在我們隻需要一套 UISplitViewController,并将它的 MasterViewController 設定為一個 navgationController 就可以輕松搞定所有情況了。   也許你會想,即使這樣,我是不是還是需要判斷裝置是不是 iPad,或者現在的話是判斷 Size Class 是不是 Compact,來決定是要做的到底是 navVC 的 push 還是改變 splitVC 的 viewControllers。其實不用,我們現在可以無痛地不加判斷,直接用統一的方式來完成兩種表現方式。這其中的奧妙在于我們不需要使用 (事實上 iOS 8 後 Apple 也不再提倡使用) UINavigationController 的 pushViewController:animated: 方法了 (又一個老朋友要和我們說再見了)。其實雖然很常用,但是這個方法是一直受到社群的議論的:因為正是這個方法的存在使得 ViewController 的耦合特性上了一個檔次。在某個 ViewController 中這個方法的存在時,就意味着我們需要確定目前的 ViewController 必須處于一個導航棧的上下文中,這是完全和上下文耦合的一種方式 (雖然我們也可以很蛋疼地用判斷 navController 是不是 nil 來繞開,但是畢竟真的很醜,不是麼)。   我們現在有了新的展示 viewController 的方法,-showViewController:sender: 以及 -showDetailViewController:sender:。調用這兩個方法時,将順着包括調用 vc 自身的響應鍊而上,尋找最近的實作了這個方法的 ViewController 來執行相應代碼。在 iOS SDK 的預設實作中,在 UISplitViewController 這樣的容器類中,已經有這兩個方法的實作方式,而 UINavigationController 也實作了 -showViewController:sender: 的版本。對于在 navController 棧中的 vc,會調用 push 方式進行展示,而對 splitVC,showViewController:sender: 将在 MasterViewController 中進行 push。而 showDetailViewController:sender: 将根據水準方向的 Size 的情況進行選擇:對于 Regular 的情況,将在 DetailViewController 中顯示新的 vc,而對于 Compact 的情況,将由所在上下文情況發回給下級的 navController 或者是直接以 modal 的方式展現。關于這部分的具體内容,可以仔細看看這個 示例項目和相關的 文檔 (beta版)。   這麼設計的好處是顯而易見的,首先是解除了原來的耦合,使得我們的 ViewController 可以不被局限于導航控制器上下文中;另外,這幾個方法都是公開的,也就是說我們的 ViewController 可以實作這兩個方法,截斷響應鍊的響應,并實作我們自己的呈現方式。這在自定義 Container Controller 的時候會非常有用。   UIPresentationController iOS 7 中加入了一套實作非常漂亮的自定義轉場動畫的方法 (如果你還不知道或者不記得了,可以看看我去年的這篇筆記)。Apple 在解耦和重用上的努力确實令人驚歎。而今年,順着自适應和平台開發統一的東風,在呈現 ViewController 的方式上 Apple 也做出了從 iOS SDK 誕生以來最大的改變。iOS 8 中新加入了一個非常重要的類 UIPresentationController,這個 NSObject 的子類将用來管理所有的 ViewController 的呈現。在實作方式上,這個類和去年的自定義轉場的幾個類一樣,是完全解耦合的。而 Apple 也在自己的各種 viewController 呈現上完全統一地使用了這個類。   再見 UIPopoverController 和 SplitViewController 類似,UIPopoverController 原來也隻是 iPad 使用的,現在 iPhone 上也将适用。準确地說,現在我們不再使用 UIPopoverController 這個類 (雖然現在文檔還沒有将其标為 deprecated,但是估計也是遲早的事兒了),而是改用一個新的類 UIPopoverPresentationController。這是 UIPresentationController 的子類,專門用來負責呈現以 popover 的形式呈現内容,是 iOS 8 中用來替代原有的 UIPopoverController 的類。   比起原來的類,新的方式有什麼優點呢?最大的優勢是自适應,這和 UISplitViewController 在 iOS 8 下的表現是類似的。在 Compact 的寬度條件下,UIPopoverPresentationController 的呈現将會直接變成 modal 出來。這樣我們基本就不再需要去判斷 iPhone 還是 iPad (其實相關的判定方法也已經被标記成棄用了),就可以對應不同的裝置了。以前我們可能要寫類似這樣的代碼:

  1. if UIDevice.currentDevice().userInterfaceIdiom == .Pad {   
  2.     let popOverController = UIPopoverController(contentViewController: nextVC) 
  3.     popOverController.presentPopoverFromRect(aRect, inView: self.view, permittedArrowDirections: .Any, animated: true) 
  4. } else { 
  5.     presentViewController(nextVC, animated: true, completion: nil) 

而現在需要做的是:  

  1. nextVC.modalPresentationStyle = .Popover   
  2. let popover = nextVC.popoverPresentationController   
  3. popover.sourceRect = aRect   
  4. popover.permittedArrowDirections = .Any 
  5. presentViewController(nextVC, animated: true, completion: nil)   

沒有可惡的條件判斷,一切配置井井有條,可讀性也非常好。   除了自适應之外,新方式的另一個優點是非常容易自定義。我們可以通過繼承 UIPopoverPresentationController 來實作我們自己想要的呈現方式。其實更準确地說,我們應該繼承的是 UIPresentationController,主要通過實作 -presentationTransitionWillBegin 和 -presentationTransitionDidEnd: 來自定義我們的展示。像以前我們想要實作隻占半個螢幕,後面原來的 view 還可見的 modal,或者是将從下到上的動畫改為百葉窗或者漸隐漸現,那都是可費勁兒的事情。而在 UIPresentationController 的幫助下,一切變得十分自然和簡單。在自己的 UIPresentationController 子類中:

  1. override func presentationTransitionWillBegin() {   
  2.     let transitionCoordinator = self.presentingViewController.transitionCoordinator() 
  3.     transitionCoordinator.animateAlongsideTransition({context in 
  4.         //Do animation here 
  5.     }, completion: nil) 
  6. override func presentationTransitionDidEnd(completed: Bool)  {   
  7.     //Do clean here 

  具體的用法和 iOS 7 裡的自定義轉場很類似,設定需要進行呈現操作的 ViewController 的 transition delegate,在 UIViewControllerTransitioningDelegate 的 -presentationControllerForPresentedViewController:sourceViewController: 方法中使用 -initWithPresentedViewController:presentingViewController: 生成對應的 UIPresentationController 子類對象傳回給 SDK,然後就可以喝茶看戲了。   再見 UIAlertView, 再見 UIActionSheet 自适應和 UIPresentationController 給我們帶來的另一個大變化是 UIAlertView 和 UIActionSheet 這兩個類的消亡 (好吧其實算不上消亡,棄用而已)。現在,Alert 和 ActionSheet 的呈現也通過 UIPresentationController 來實作。原來在沒有 Size Class 和需要處理旋轉的黑暗年代 (抱歉在這裡用了這個詞,但是我真的一點也不懷念那段處理裝置旋轉的時光) 裡,把這兩個 view 顯示出來其實幕後是一堆惡心的事情:建立新的 window,處理新 window 的大小和方向,然後将 alert 或者 action sheet 按合适的大小和方向加到視窗上,然後還要考慮處理轉向,最後顯示出來。雖然 Apple 幫我們做了這些事情,但是輪到我們使用時,往往它們也隻能滿足最基本的需求。在适配 iPhone 和 iPad 時,UIAlertView 還好,但是對于 UIActionSheet 我們往往又得進行不同處理,來選擇是不是需要 popover。   另外一個要命的地方是因為這兩個類是 iOS 2.0 開始就存在的爺爺級的類了,而最近一直也沒什麼大的更新,設計模式上還使用的是傳統的 delegate 那一套東西。實際上對于這種很輕很明确的使用邏輯,block handler 才是最好的選擇,君不見滿 GitHub 的 block alert view 的代碼,但是沒轍,4.0 才出現的 block 一直由于種種原因,在這兩個類中一直沒有得到官方的認可和使用。   而作為替代品的 UIAlertController 正是為了解決這些問題而出現的,值得注意的是,這是一個 UIViewController 的子類。可能你會問 UIAlertController 對應替代 UIAlertView,這很好,但是 UIActionSheet 怎麼辦呢?哈..答案是也用 UIAlertController,在 UIAlertController 中有一個 preferredStyle 的屬性,暫時給我們提供了兩種選擇 ActionSheet 和 Alert。在實際使用時,這個類的 API 還是很簡單的,使用工廠方法建立對象,進行配置後直接 present 出來:

  1. let alert = UIAlertController(title: "Test", message: "Msg", preferredStyle: .Alert) 
  2. let okAction = UIAlertAction(title: "OK", style: .Default) {   
  3.     [weak alert] action in 
  4.     print("OK Pressed") 
  5.     alert!.dismissViewControllerAnimated(true, completion: nil) 
  6. alert.addAction(okAction)   
  7. presentViewController(alert, animated: true, completion: nil)   

  使用上除了小心循環引用以外,并沒有太多好說的。在 Alert 上加文本輸入也變得非常簡單了,使用 -addTextFieldWithConfigurationHandler: 每次向其上添加一個文本輸入,然後在 handler 裡拿資料就好了。   要記住的是,在幕後,做呈現的還是 UIPresentationController。   UISearchDisplayController -> UISearchController 最後想簡單提一下在做搜尋欄的時候的同樣類似的改變。在 iOS 8 之前做搜尋欄簡直是一件讓人崩潰的事情,而現在我們不再需要讨厭的 UISearchDisplayController 了,也沒有莫名其妙的在視圖樹中強制插入 view 了 (如果你做過搜尋欄,應該知道我在說什麼)。這一切在 iOS 8 中也和前面說到的 alert 和 actionSheet 一樣,被一個 UIViewController 的子類 UISearchController 替代了。背後的呈現機制自然也是 UIPresentationController,可見新的這個類在 iOS 8 中的重要性。   總結 對于廣大 iOS 開發者賴以生存的 UIKit 來說,這次最大的變化就是 Size Classes 的引入和新的 Presentation 系統了。在 Keynot 上 Craig 就告訴我們,iOS 8 SDK 将是 iOS 開發誕生以來最大的一次變革,此言不虛。雖然 iOS 8 SDK 的廣泛使用估計還有要有個兩年時間,但是不同裝置的開發的 API 的統一這一步已然邁出,這也正是 Apple 之後的發展方向。正如兩年前的 Auto Layout 正在今天大放光彩一樣,之後 Size Classes 和新的 ViewController 也必将成為日常開發的主力工具。   程式員嘛,果然需要每年不斷學習,才能跟上時代。