安全区适配

前言

随着苹果日趋追赶大屏风潮的脚步,之前让 Android 同学头疼不已的屏幕适配问题,也逐渐开始困扰我们广大 iOS 小伙伴们。该篇文章在 WWDC 关于 UIKit:Apps for Every Size and Shape 的基础上进行总结与实践,希望能够帮助大家快速准确的处理布局与适配问题。

文章结构如下:

  1. Safe Area 的概念与特性
  • safeAreaInsets 与 safeAreaLayoutGuide 调用时机
  • 安全区域扩展
  • 安全区域计算规则
  • 安全区域传递性
  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 的概念与特性

可被完全看见的,不影响用户操作的矩形区域,称为安全区,在 iOS 11 伴随着全(刘)面(海)屏被提出。它的出现取代了 iOS 7 以来的 topLayoutGuidebottomLayoutGuide,成为新的参照物来保证 view 能正常、安全地显示。

这个区域的大小是由系统来决定的。在某个 view 上的布局只需要相对于其 safe area 就可以了。每个 view 的 safe area 都可以通过 iOS 11 新增的 API safeAreaInsets 或者 safeAreaLayoutGuide 获取。

image1.png

safeAreaInsets 与 safeAreaLayoutGuide 调用时机

在视图显示在屏幕上或者装载到一个视图层级中的时候,才能正确获取到 safeAreaInsetssafeAreaLayoutGuide,否则返回0。
所以与安全区相关的布局操作应放在 layoutSubviewsviewDidLayoutSubviews 中进行处理。

image2.png

安全区域扩展

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 建立约束。从最终的呈现效果可以论证父视图的安全区域会向上传递

image3.png

小结

这个由系统控制的区域,是我们适配时候需要重点关注的对象。尤其是要清楚地知道,安全区到屏幕边距在横屏或竖屏、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

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 的存在,适配安全区的时候也有一定的差异化。

UITableView 的 insetsContentViewsToSafeArea

该属性能够控制 UITableViewHeaderFooterView 与 UITableViewCell 的 contentView 是否被 safeAreaInsets 所影响,默认值为 YES,具体的呈现效果如下:

image5.png

这就意味着在 iOS 11,我们在为header、footer、cell添加子控件时,不需要改变子控件的位置,UITableView自动帮我们适配。

insetsContentViewsToSafeArea为 NO,横屏下 cell 部分区域会被齐刘海遮挡:

image6.png

UICollectionViewFlowLayout 的 sectionInsetReference

现在,我们使用 UICollectionView 实现一个一样的列表界面:

image7.png

从截图中可以看到,UICollectionView 在默认情况下没有像 UITableView 那样去处理。这是由于 UICollectionReusableView 不存在 contentView,同时考虑到 UICollectionView 布局的复杂性,并不适合统一进行适配。

单列列表布局情况下想要正确布局内容,唯一的方法是让子视图依据安全区建立约束。

这里有一个需要注意的地方就是,
UICollectionViewCell 虽然存在contentView属性,但是通过 xib 拖拽的 cell 视图层级中没有contentView,添加在 cell 上的子视图也不能够直接以 cell 的 safe area 为基准添加约束。可以通过手动添加view作为内容容器来解决。

[图片上传失败...(image-c96b19-1542424776344)]


image11.png

接下来改变 UICollectionViewFlowLayout 的 itemSize,使其呈现多列网格布局:

image8.png

可以看到在横屏时 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

image10.png

小结

对于 Scroll View,我们通常会禁用掉系统自动添加偏移量的行为,即设置 contentInsetAdjustmentBehaviorUIScrollViewContentInsetAdjustmentNever,然后通过 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!

参考文献:

iOS开发-LayoutGuide

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

随手记在iPhone X上的适配实践总结

UIView的Margins

iOS 11 安全区域适配总结

WWDC 2018:快速将开发项目适配所有的iOS设备

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容