前言
隨著蘋果日趨追趕大屏風潮的腳步,之前讓 Android 同學頭疼不已的屏幕適配問題,也逐漸開始困擾我們廣大 iOS 小伙伴們。該篇文章在 WWDC 關于 UIKit:Apps for Every Size and Shape 的基礎上進行總結與實踐,希望能夠幫助大家快速準確的處理布局與適配問題。
文章結構如下:
- Safe Area 的概念與特性
- safeAreaInsets 與 safeAreaLayoutGuide 調用時機
- 安全區(qū)域擴展
- 安全區(qū)域計算規(guī)則
- 安全區(qū)域傳遞性
- UIView 的 Margins
- layoutMargins
- directionalLayoutMargins
- systemMinimumLayoutMargins
- preservesSuperviewLayoutMargins
- insetsLayoutMarginsFromSafeArea
- UIScrollView 適配
- adjustedContentInset
- contentInsetAdjustmentBehavior
- UITableView & UICollectionVIew
- UITableView 的 insetsContentViewsToSafeArea
- UICollectionViewFlowLayout 的 sectionInsetReference
常見機型尺寸對照表:
| 機型 | 尺寸(英寸) | 寬高 | 分辨率 |
|---|---|---|---|
| 5、SE | 4 | 320x568 | 640x1136 |
| 6、7、8 | 4.7 | 375x667 | 750x1334 |
| 6+、7+、8+ | 5.5 | 414x736 | 1080x1920 |
| X、XS | 5.8 | 375x812 | 1125x2436 |
| XR | 6.1 | 414x896 | 828x1792 |
| XS Max | 6.5 | 414x896 | 1242x2688 |
Safe Area 的概念與特性
可被完全看見的,不影響用戶操作的矩形區(qū)域,稱為安全區(qū),在 iOS 11 伴隨著全(劉)面(海)屏被提出。它的出現取代了 iOS 7 以來的 topLayoutGuide 與 bottomLayoutGuide,成為新的參照物來保證 view 能正常、安全地顯示。
這個區(qū)域的大小是由系統來決定的。在某個 view 上的布局只需要相對于其 safe area 就可以了。每個 view 的 safe area 都可以通過 iOS 11 新增的 API safeAreaInsets 或者 safeAreaLayoutGuide 獲取。

safeAreaInsets 與 safeAreaLayoutGuide 調用時機
在視圖顯示在屏幕上或者裝載到一個視圖層級中的時候,才能正確獲取到 safeAreaInsets 與 safeAreaLayoutGuide,否則返回0。
所以與安全區(qū)相關的布局操作應放在 layoutSubviews 或 viewDidLayoutSubviews 中進行處理。

安全區(qū)域擴展
UIViewController 支持使用 additionalSafeAreaInsets 屬性,自定義擴展安全區(qū)域大小,以滿足一些應用場景。這里蘋果官方給出了一個例子:
You might use this property to extend the safe area to include custom content in your interface. For example, a drawing app might use this property to avoid displaying content underneath tool palettes.
安全區(qū)域計算規(guī)則
對于 ViewController 的根視圖,會根據各種 bar 的高度,以及開發(fā)者自己設置的 additionalSafeAreaInsets 屬性來計算。
對于層級中的其他視圖,safeAreaInsets反映了 View 被覆蓋的區(qū)域。只有當該視圖存在超出其父視圖安全區(qū)域的部分,safeAreaInsets 才會返回相應的值。如果整個視圖已經處于安全區(qū)域中,那么 safeAreaInsets 返回 0。
安全區(qū)域傳遞性
通過 self.additionalSafeAreaInsets = UIEdgeInsetsMake(15, 15, 15, 15) 更改 ViewController 根視圖的 Safe Area(藍色部分),然后在其上添加一個超出安全區(qū)域的子視圖(黃色部分),最后在子視圖上添加一個Label(綠色部分)并依據該子視圖的 safeAreaInsets 建立約束。從最終的呈現效果可以論證父視圖的安全區(qū)域會向上傳遞。

小結
這個由系統控制的區(qū)域,是我們適配時候需要重點關注的對象。尤其是要清楚地知道,安全區(qū)到屏幕邊距在橫屏或豎屏、NavigationBar 與 TabBar 存在或不存在等情況下的具體數值,這樣在對控件進行布局時,才能準確把握其位置。
iOS 11 之后,我們可以通過下面的代碼來獲取這個具體數值:
UIEdgeInsets insets = UIEdgeInsetsZero;
if(@available(iOS 11.0, *)) {
insets = view.safeAreaInsets;
}
iOS 11 之前就需要我們通過宏定義去進行相應的處理。例如狀態(tài)欄、導航欄等的高度獲?。?/p>
#define StatusBarHeight CGRectGetHeight([UIApplication sharedApplication].statusBarFrame)
#define TabBarHeight(tabBar) CGRectGetHeight(tabBar.frame)
#define NavigationBarHeight(navigationBar) CGRectGetHeight(navigationBar.frame)
常見系統控件高度表:
| 控件 | 豎屏 | 橫屏 |
|---|---|---|
| StatusBar | 20 | 0 |
| StatusBar(X) | 44 | 0 |
| NavigationBar | 44 | 32 |
| NavigationBar(XR、Max) | 44 | 44 |
| TabBar | 49 | 32 |
| TabBar(X) | 83 | 53 |
| TabBar(XR、Max) | 83 | 70 |
| HomeIndicator | 34 | 21 |
注:iPhone X 橫屏下左右兩邊安全距離為44
UIView 的 Margins
就像我們用田字格寫漢字時上留天,下留地,左右要留空一樣,界面的布局也應該留有邊界。這個邊界就是 Margins。

layoutMargins
iOS 8 中提出了 layoutMargins 的概念,其主要用于設置子視圖與父視圖之間的邊距。默認情況下,layoutMargin 到各邊的距離為8。
typedef struct UIEdgeInsets {
CGFloat top, left, bottom, right;
} UIEdgeInsets;
In iOS 11 and later, use the directionalLayoutMargins property to specify layout margins instead of this property.
directionalLayoutMargins
iOS 11 提出,主要是為了Right To Left(RTL)語言下可以進行自動適配。上面蘋果的官方文檔也有指出,用directionalLayoutMargins替換掉layoutMargin,這樣在做國際化的時候就無需針對語言專門進行適配了。
typedef struct NSDirectionalEdgeInsets {
CGFloat top, leading, bottom, trailing;
} NSDirectionalEdgeInsets API_AVAILABLE(ios(11.0),tvos(11.0),watchos(4.0));
systemMinimumLayoutMargins
UIViewController 存在屬性 systemMinimumLayoutMargins,當 viewRespectsSystemMinimumLayoutMargins 為 YES 時,可以通過該屬性更改 layoutMargins 的默認值。
preservesSuperviewLayoutMargins
iOS 8 開始引入,當這個屬性的值為 YES 的時候,一個視圖布局內容時其父視圖的 margins 也會被考慮在內,此時該視圖的實際 margins 為該視圖與父視圖 margins 中的最大值。默認是 NO。
insetsLayoutMarginsFromSafeArea
iOS 11 開始引入,控制 safeAreaInsets 是否加到 layoutMargins 上。默認為 YES。
//orangeView.insetsLayoutMarginsFromSafeArea = YES;
safeAreaInsets: {88, 0, 0, 0}
layoutMargins: {88, 50, 0, 0}
//orangeView.insetsLayoutMarginsFromSafeArea = NO;
safeAreaInsets: {88, 0, 0, 0}
layoutMargins: {0, 50, 0, 0}
獲取實際layoutMargins的偽代碼如下:
- (UIEdgeInsets)getRealLayoutMargins {
UIEdgeInsets layoutMargins = self.layoutMargins; //默認是8
if (self.preservesSuperviewLayoutMargins) {
layoutMargins = Max(layoutMargins, self.superview.layoutMargins);
}
if (self.insetsLayoutMarginsFromSafeArea) {
layoutMargins = Add(layoutMargins, self.safeAreaInsets);
}
return layoutMargins;
}
小結
日常布局的時候,往往都不會使用 layoutMargins,這是因為提供的視覺標注是不會考慮這個值的,我們更希望能直接設置約束值為標注值,而不是進行轉換操作。目前看來只有在進行語言適配的情況下才會以 view 的 directionalLayoutMargins 為基準建立約束吧。
UIScrollView 適配
adjustedContentInset
The insets derived from the content insets and the safe area of the scroll view.
這句話用代碼翻譯過來就是adjustedContentInset = safeAreaInset + contentInset,這里要不要加safeAreaInset則取決于contentInsetAdjustmentBehavior。下面將重點解釋下contentInsetAdjustmentBehavior這個屬性。
另外值得注意的一點是 iOS 10 中 contentInset 與 iOS 11 的 adjustedContentInset 表現是一致的,即 contentInset 的值在 iOS 10 中 與 iOS 11 中可能是不同的:
//iOS 10 automaticallyAdjustsScrollViewInsets = YES
contentInset = UIEdgeInsets(top: 74.0, left: 10.0, bottom: 59.0, right: 10.0)
//iOS 11 contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAlways
contentInset = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0)
adjustedContentInset = UIEdgeInsets(top: 74.0, left: 10.0, bottom: 59.0, right: 10.0)
contentInsetAdjustmentBehavior
iOS 7 中 UIViewController 的 automaticallyAdjustsScrollViewInsets 屬性在 iOS 11 中被廢棄掉了。取而代之的是 UIScrollView 的 contentInsetAdjustmentBehavior。
typedef NS_ENUM(NSInteger, UIScrollViewContentInsetAdjustmentBehavior) {
UIScrollViewContentInsetAdjustmentAutomatic, // Similar to .scrollableAxes, but for backward compatibility will also adjust the top & bottom contentInset when the scroll view is owned by a view controller with automaticallyAdjustsScrollViewInsets = YES inside a navigation controller, regardless of whether the scroll view is scrollable
UIScrollViewContentInsetAdjustmentScrollableAxes, // Edges for scrollable axes are adjusted (i.e., contentSize.width/height > frame.size.width/height or alwaysBounceHorizontal/Vertical = YES)
UIScrollViewContentInsetAdjustmentNever, // contentInset is not adjusted
UIScrollViewContentInsetAdjustmentAlways, // contentInset is always adjusted by the scroll view's safeAreaInsets
} API_AVAILABLE(ios(11.0),tvos(11.0));
- UIScrollViewContentInsetAdjustmentNever
不依據 scroll view 的safeAreaInsets進行適配。
- (UIEdgeInsets)adjustedContentInset {
return self.contentInset;
}
- UIScrollViewContentInsetAdjustmentAlways
總是依據 scroll view 的safeAreaInsets進行適配
- (UIEdgeInsets)adjustedContentInset {
return UIEdgeInsetsMake(self.contentInset.top + self.safeAreaInsets.top,
self.contentInset.left + self.safeAreaInsets.left,
self.contentInset.bottom + self.safeAreaInsets.bottom,
self.contentInset.right + self.safeAreaInsets.right);
}
- UIScrollViewContentInsetAdjustmentScrollableAxes
在可以滑動的方向上(contentSize.width/height > frame.size.width/height或alwaysBounceHorizontal/Vertical = YES)依據 scroll view 的safeAreaInsets進行適配。
當展示內容太少,contentSize.width/height < frame.size.width/height而導致 scroll view 不能滑動,則不會適配。
- (UIEdgeInsets)adjustedContentInset {
UIEdgeInsets adjustedContentInset = self.contentInset;
if (self.contentSize.width > self.frame.size.width || self.alwaysBounceHorizontal == YES) {
adjustedContentInset.left += self.safeAreaInsets.left;
adjustedContentInset.right += self.safeAreaInsets.right;
}
if (self.contentSize.height > self.frame.size.height || self.alwaysBounceVertical) {
adjustedContentInset.top += self.safeAreaInsets.top;
adjustedContentInset.bottom += self.safeAreaInsets.bottom;
}
return adjustedContentInset;
}
- UIScrollViewContentInsetAdjustmentAutomatic
處于導航層級且automaticallyAdjustsScrollViewInsets = YES的 view controller 中,作為第一個被添加子視圖的 scroll view, 無論能否滑動,頂部與底部都將依據 scroll view 的safeAreaInsets進行適配。其他情況下與ScrollableAxes表現相同。
- (UIEdgeInsets)adjustedContentInset {
UIEdgeInsets adjustedContentInset = self.contentInset;
if (viewController.automaticallyAdjustsScrollViewInsets == YES && viewController.navigationController && viewController.view.subviews.firstObject == self) {
adjustedContentInset.top += self.safeAreaInsets.top;
adjustedContentInset.bottom += self.safeAreaInsets.bottom;
}
else {
if (self.contentSize.width > self.frame.size.width || self.alwaysBounceHorizontal == YES) {
adjustedContentInset.left += self.safeAreaInsets.left;
adjustedContentInset.right += self.safeAreaInsets.right;
}
if (self.contentSize.height > self.frame.size.height || self.alwaysBounceVertical) {
adjustedContentInset.top += self.safeAreaInsets.top;
adjustedContentInset.bottom += self.safeAreaInsets.bottom;
}
}
return adjustedContentInset;
}
UITableView & UICollectionVIew
作為 UIScrollView 的子類,以上特性 UITableView 與 UICollectionVIew 也都具有。但是由于 UITableViewHeaderFooterView、UITableViewCell、UICollectionReusableView、UICollectionViewCell 的存在,適配安全區(qū)的時候也有一定的差異化。
UITableView 的 insetsContentViewsToSafeArea
該屬性能夠控制 UITableViewHeaderFooterView 與 UITableViewCell 的 contentView 是否被 safeAreaInsets 所影響,默認值為 YES,具體的呈現效果如下:

這就意味著在 iOS 11,我們在為header、footer、cell添加子控件時,不需要改變子控件的位置,UITableView自動幫我們適配。
當insetsContentViewsToSafeArea為 NO,橫屏下 cell 部分區(qū)域會被齊劉海遮擋:

UICollectionViewFlowLayout 的 sectionInsetReference
現在,我們使用 UICollectionView 實現一個一樣的列表界面:

從截圖中可以看到,UICollectionView 在默認情況下沒有像 UITableView 那樣去處理。這是由于 UICollectionReusableView 不存在 contentView,同時考慮到 UICollectionView 布局的復雜性,并不適合統一進行適配。
單列列表布局情況下想要正確布局內容,唯一的方法是讓子視圖依據安全區(qū)建立約束。
這里有一個需要注意的地方就是,
UICollectionViewCell 雖然存在contentView屬性,但是通過 xib 拖拽的 cell 視圖層級中沒有contentView,添加在 cell 上的子視圖也不能夠直接以 cell 的 safe area 為基準添加約束。可以通過手動添加view作為內容容器來解決。
[圖片上傳失敗...(image-c96b19-1542424776344)]

接下來改變 UICollectionViewFlowLayout 的 itemSize,使其呈現多列網格布局:

可以看到在橫屏時 cell 被齊劉海遮擋了一部分。當然我們通過設置 UICollectionViewFlowLayout 的 sectionInset 能夠適配安全區(qū)。但是在 iOS 11 中,蘋果為 UICollectionViewFlowLayout 添加了一個新屬性 sectionInsetReference 來處理它。
typedef NS_ENUM(NSInteger, UICollectionViewFlowLayoutSectionInsetReference) {
UICollectionViewFlowLayoutSectionInsetFromContentInset,
UICollectionViewFlowLayoutSectionInsetFromSafeArea,
UICollectionViewFlowLayoutSectionInsetFromLayoutMargins
} API_AVAILABLE(ios(11.0), tvos(11.0)) API_UNAVAILABLE(watchos);
系統默認值為 UICollectionViewFlowLayoutSectionInsetFromContentInset,那么我們將 sectionInsetReference 設置為 UICollectionViewFlowLayoutSectionInsetFromSafeArea:

這種情況下 sectionInset 等于原來的大小加上 safeAreaInsets 的大小。
類似地,當使用 UICollectionViewFlowLayoutSectionInsetFromLayoutMargins 時,collection view 的 layoutMargins 會被添加到 sectionInset。

小結
對于 Scroll View,我們通常會禁用掉系統自動添加偏移量的行為,即設置 contentInsetAdjustmentBehavior 為 UIScrollViewContentInsetAdjustmentNever,然后通過 frame 去控制展示。其他三個枚舉值的具體使用場景目前還不是很常見,我覺得一些沉浸式的設計可能會有發(fā)揮的余地。
Table View 不需要我們做額外的處理,它的 insetsContentViewsToSafeArea 屬性默認為 YES,會幫我們調整 header、footer 以及 cell 的 contentView。
Collection View 在 iOS 11 之前,單列情況可以設置 cell 子視圖與 cell 左右邊距的距離大于 44 來避免橫屏齊劉海的遮蓋問題,多列則可以通過設置 sectionInset 進行適配。iOS 11 之后單列情況 cell 子視圖直接依據安全區(qū)進行布局,多列可以使用 sectionInsetReference 防止齊劉海遮蓋。
尾聲
Safe Area 的出現,為我們提供了一個虛擬的布局區(qū)域,我們只需要專注于根據安全區(qū)建立約束即可,不用再去關心狀態(tài)欄、導航欄等系統控件變化導致的適配??偟膩碚f是對界面布局效率的重大提升,蘋果也通過使 xib 的安全區(qū)向下兼容到 iOS 9 等方式,鼓勵廣大開發(fā)者去使用這些新特性,做出更精美的應用。那就讓我們愉快的使用起來吧,Just Do IT!
參考文獻: