前言
随着苹果日趋追赶大屏风潮的脚步,之前让 Android 同学头疼不已的屏幕适配问题,也逐渐开始困扰我们广大 iOS 小伙伴们。该篇文章在 WWDC 关于 UIKit:Apps for Every Size and Shape 的基础上进行总结与实践,希望能够帮助大家快速准确的处理布局与适配问题。
文章结构如下:
- Safe Area 的概念与特性
- safeAreaInsets 与 safeAreaLayoutGuide 调用时机
- 安全区域扩展
- 安全区域计算规则
- 安全区域传递性
- 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 的概念与特性
可被完全看见的,不影响用户操作的矩形区域,称为安全区,在 iOS 11 伴随着全(刘)面(海)屏被提出。它的出现取代了 iOS 7 以来的 topLayoutGuide
与 bottomLayoutGuide
,成为新的参照物来保证 view 能正常、安全地显示。
这个区域的大小是由系统来决定的。在某个 view 上的布局只需要相对于其 safe area 就可以了。每个 view 的 safe area 都可以通过 iOS 11 新增的 API safeAreaInsets
或者 safeAreaLayoutGuide
获取。
safeAreaInsets 与 safeAreaLayoutGuide 调用时机
在视图显示在屏幕上或者装载到一个视图层级中的时候,才能正确获取到 safeAreaInsets
与 safeAreaLayoutGuide
,否则返回0。
所以与安全区相关的布局操作应放在 layoutSubviews
或 viewDidLayoutSubviews
中进行处理。
安全区域扩展
UIViewController 支持使用 additionalSafeAreaInsets
属性,自定义扩展安全区域大小,以满足一些应用场景。这里苹果官方给出了一个例子:
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.
安全区域计算规则
对于 ViewController 的根视图,会根据各种 bar 的高度,以及开发者自己设置的 additionalSafeAreaInsets
属性来计算。
对于层级中的其他视图,safeAreaInsets
反映了 View 被覆盖的区域。只有当该视图存在超出其父视图安全区域的部分,safeAreaInsets
才会返回相应的值。如果整个视图已经处于安全区域中,那么 safeAreaInsets
返回 0。
安全区域传递性
通过 self.additionalSafeAreaInsets = UIEdgeInsetsMake(15, 15, 15, 15)
更改 ViewController 根视图的 Safe Area(蓝色部分),然后在其上添加一个超出安全区域的子视图(黄色部分),最后在子视图上添加一个Label(绿色部分)并依据该子视图的 safeAreaInsets
建立约束。从最终的呈现效果可以论证父视图的安全区域会向上传递。
小结
这个由系统控制的区域,是我们适配时候需要重点关注的对象。尤其是要清楚地知道,安全区到屏幕边距在横屏或竖屏、NavigationBar 与 TabBar 存在或不存在等情况下的具体数值,这样在对控件进行布局时,才能准确把握其位置。
iOS 11 之后,我们可以通过下面的代码来获取这个具体数值:
UIEdgeInsets insets = UIEdgeInsetsZero;
if(@available(iOS 11.0, *)) {
insets = view.safeAreaInsets;
}
iOS 11 之前就需要我们通过宏定义去进行相应的处理。例如状态栏、导航栏等的高度获取:
#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 的存在,适配安全区的时候也有一定的差异化。
UITableView 的 insetsContentViewsToSafeArea
该属性能够控制 UITableViewHeaderFooterView 与 UITableViewCell 的 contentView
是否被 safeAreaInsets
所影响,默认值为 YES,具体的呈现效果如下:
这就意味着在 iOS 11,我们在为header、footer、cell添加子控件时,不需要改变子控件的位置,UITableView自动帮我们适配。
当insetsContentViewsToSafeArea
为 NO,横屏下 cell 部分区域会被齐刘海遮挡:
UICollectionViewFlowLayout 的 sectionInsetReference
现在,我们使用 UICollectionView 实现一个一样的列表界面:
从截图中可以看到,UICollectionView 在默认情况下没有像 UITableView 那样去处理。这是由于 UICollectionReusableView 不存在 contentView
,同时考虑到 UICollectionView 布局的复杂性,并不适合统一进行适配。
单列列表布局情况下想要正确布局内容,唯一的方法是让子视图依据安全区建立约束。
这里有一个需要注意的地方就是,
UICollectionViewCell 虽然存在contentView
属性,但是通过 xib 拖拽的 cell 视图层级中没有contentView
,添加在 cell 上的子视图也不能够直接以 cell 的 safe area 为基准添加约束。可以通过手动添加view
作为内容容器来解决。
[图片上传失败...(image-c96b19-1542424776344)]
接下来改变 UICollectionViewFlowLayout 的 itemSize,使其呈现多列网格布局:
可以看到在横屏时 cell 被齐刘海遮挡了一部分。当然我们通过设置 UICollectionViewFlowLayout 的 sectionInset
能够适配安全区。但是在 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 去控制展示。其他三个枚举值的具体使用场景目前还不是很常见,我觉得一些沉浸式的设计可能会有发挥的余地。
Table View 不需要我们做额外的处理,它的 insetsContentViewsToSafeArea
属性默认为 YES,会帮我们调整 header、footer 以及 cell 的 contentView
。
Collection View 在 iOS 11 之前,单列情况可以设置 cell 子视图与 cell 左右边距的距离大于 44 来避免横屏齐刘海的遮盖问题,多列则可以通过设置 sectionInset
进行适配。iOS 11 之后单列情况 cell 子视图直接依据安全区进行布局,多列可以使用 sectionInsetReference
防止齐刘海遮盖。
尾声
Safe Area 的出现,为我们提供了一个虚拟的布局区域,我们只需要专注于根据安全区建立约束即可,不用再去关心状态栏、导航栏等系统控件变化导致的适配。总的来说是对界面布局效率的重大提升,苹果也通过使 xib 的安全区向下兼容到 iOS 9 等方式,鼓励广大开发者去使用这些新特性,做出更精美的应用。那就让我们愉快的使用起来吧,Just Do IT!
参考文献: