安全區(qū)適配

前言

隨著蘋果日趨追趕大屏風潮的腳步,之前讓 Android 同學頭疼不已的屏幕適配問題,也逐漸開始困擾我們廣大 iOS 小伙伴們。該篇文章在 WWDC 關于 UIKit:Apps for Every Size and Shape 的基礎上進行總結與實踐,希望能夠幫助大家快速準確的處理布局與適配問題。

文章結構如下:

  1. Safe Area 的概念與特性
  • safeAreaInsets 與 safeAreaLayoutGuide 調用時機
  • 安全區(qū)域擴展
  • 安全區(qū)域計算規(guī)則
  • 安全區(qū)域傳遞性
  1. UIView 的 Margins
  • layoutMargins
  • directionalLayoutMargins
  • systemMinimumLayoutMargins
  • preservesSuperviewLayoutMargins
  • insetsLayoutMarginsFromSafeArea
  1. UIScrollView 適配
  • adjustedContentInset
  • contentInsetAdjustmentBehavior
  1. 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 以來的 topLayoutGuidebottomLayoutGuide,成為新的參照物來保證 view 能正常、安全地顯示。

這個區(qū)域的大小是由系統來決定的。在某個 view 上的布局只需要相對于其 safe area 就可以了。每個 view 的 safe area 都可以通過 iOS 11 新增的 API safeAreaInsets 或者 safeAreaLayoutGuide 獲取。

image1.png

safeAreaInsets 與 safeAreaLayoutGuide 調用時機

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

image2.png

安全區(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ū)域會向上傳遞。

image3.png

小結

這個由系統控制的區(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。

image4.png

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/heightalwaysBounceHorizontal/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,具體的呈現效果如下:

image5.png

這就意味著在 iOS 11,我們在為header、footer、cell添加子控件時,不需要改變子控件的位置,UITableView自動幫我們適配。

insetsContentViewsToSafeArea為 NO,橫屏下 cell 部分區(qū)域會被齊劉海遮擋:

image6.png

UICollectionViewFlowLayout 的 sectionInsetReference

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

image7.png

從截圖中可以看到,UICollectionView 在默認情況下沒有像 UITableView 那樣去處理。這是由于 UICollectionReusableView 不存在 contentView,同時考慮到 UICollectionView 布局的復雜性,并不適合統一進行適配。

單列列表布局情況下想要正確布局內容,唯一的方法是讓子視圖依據安全區(qū)建立約束。

這里有一個需要注意的地方就是,
UICollectionViewCell 雖然存在contentView屬性,但是通過 xib 拖拽的 cell 視圖層級中沒有contentView,添加在 cell 上的子視圖也不能夠直接以 cell 的 safe area 為基準添加約束。可以通過手動添加view作為內容容器來解決。

[圖片上傳失敗...(image-c96b19-1542424776344)]


image11.png

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

image8.png

可以看到在橫屏時 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。

image10.png

小結

對于 Scroll View,我們通常會禁用掉系統自動添加偏移量的行為,即設置 contentInsetAdjustmentBehaviorUIScrollViewContentInsetAdjustmentNever,然后通過 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!

參考文獻:

iOS開發(fā)-LayoutGuide

最近很火的 Safe Area 到底是什么

隨手記在iPhone X上的適配實踐總結

UIView的Margins

iOS 11 安全區(qū)域適配總結

WWDC 2018:快速將開發(fā)項目適配所有的iOS設備

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容