完美适配iOS11和iPhone X的两套方案
文章导读:文章较长,若你已对iOS11和iPhone X适配有基本了解,请直接阅读第三部分解决方案。
拓展阅读:
iPhone X上push页面时tabbar“向上偏移”解决思路大全
iPhone X适配之MJ上拉加载更多的适配
iPhoneX适配之UI设计、交互设计
一、概述
iPhone X已经来了,您的APP完全适配了iOS 11和iPhone X了吗?参考下iPhone X模拟器的系统设置→隐私页面,发现其中的tableView列表未滑到底部时,视图能扩展到HomeIndicator区域,当tableView滑动到最底部时,底部的内容也不会被HomeIndicator遮挡问题,当然页面也不会有漂移哈,来看看下图的样式😎,这样才是完美的全面屏适配。然而因iOS11废弃了automaticallyAdjustsScrollViewInsets属性,同时也废弃了topLayoutGuide和bottomLayoutGuide属性替换为safearea系列,几乎所有的APP用xcode9在iPhone X运行,都会存在偏移或漂移问题。这里我为什么说是偏移或漂移呢?
读图说明:蓝色线条是iPhoneX 上虚拟home键 “分隔线”,蓝色线条下方区域为虚拟home键。图片顶部为样式标题,黑色区域文字为样式说明。
系统参考样式1、完全自定义的navigationBar 偏移
大多数是基于宏定义navigationBar高度,frame计算布局或添加约束布局,视图会存在偏移,显示不正常。原先的高度为20或64,iPhone X上是却是44或88,相对增加了24的高度。其实tabBar高度也有有变化,49变为83,相对增加了34的高度。可以通过修改宏定义调整,愿他们安好!
2、继承自系统的navigationBar 漂移
大多数是基于约束写的布局或异步获得主线程后再获得控制器self.view.bounds(异步获取,当页面布局较复杂会存在主线程还没有加载完成,网络数据已经存在,开始刷新视图,而视图此时并未获得正确的frame,布局会有问题),系统会根据navbar、tabbar、toolbar的存在与否等自动计算一个相对合适的frame和contentInset,在这个过程中,会因为navbar、tabbar的不透明,会多次计算,而且计算的值有可能不同,计算frame相对消耗时间,这期间会存在页面视图的冗余动画,即页面有漂移现象,可以参考第四部分知识拓展和第五部分探索思路的原理分析。当然还有不少使用手动计算frame后再布局子控件的方式,这就很尴尬了😓。
别担心,老司机已经帮你整理好两套适配方案,解决iOS7到iOS11在各类iPhone和iPad上的偏移或漂移问题,发车了😄!
二、项目现状和新需求
1、iOS11之前的项目布局
苹果从iOS7开始采用扁平化的界面风格,对系统的导航栏(含状态栏)和TabBar做了深度优化。iOS7开始导航栏(含状态栏)和tabbar默认是透明状态(iOS7之前默认不透明),view布局从右上角(0,0)开始,当有UIScrollView或其子类时,系统会根据视图,自动调整scrolView的布局,向下偏移20或64,或高度减少49,以免内容视图被导航栏(含状态栏)、tabbar给遮挡。
因早期开发的历史原因,项目现状:
-
在UIViewController加载自定义视图时,没有使用约束,而是手动计算frame或异步获取可用的内容区域(有时会存在问题,则用手动计算写死),目前项目还有少量的xib或storyboard布局的页面,当页面frame变化或屏幕旋转时,还需要手动刷新布局。
-
APP项目中调整了系统导航栏的样式,主要是导航栏和tabbar变的不再透明,导航栏存在的情况下,系统会将UIViewController的系统View的bounds调整为(0,NavBarHeight,ScreenWidth,ScreenHeight - NavBarHeight- TabBarHeight),不被内容视图被导航栏(含状态栏)、tabbar视等图遮挡的“安全区域”,若系统View上还有scrollView,则系统会自动调整scrolView的布局(部分页面可能不会被系统自动调整),向下偏移20或64,或高度减少49,以免内容视图被状态栏、导航栏、tabbar给遮挡。因为第一次调整已经不被导航栏遮挡,布局计算正确,但又会被系统自动调整向下偏移64,造成页面布局错乱,可通过UIViewController的automaticallyAdjustsScrollViewInsets = NO,禁止系统再次调整。
-
iOS11之前,虽然有纯代码的手动计算frame和代码约束,或xib/storyboard约束,部分页面使用self.automaticallyAdjustsScrollViewInsets = NO,禁用调整。总体来说,页面布局在混沌中前行,没有相对统一的布局方案。
2、iOS11新的需求和问题
-
iPhone X 手机界的一朵奇葩。因为iPhone X的加入,屏幕变得不再规则,尺寸也不再近似于所谓的16:9。iPhone X上,导航栏高度为88(44),相对其他iPhone增加了24的高度,tabBar高度也从49变为83(34),尤其homeIndicator的区域不属于安全区域,然而还需要能显示内容。顶部帅气的刘海,底部霸气的homeIndicator(不能强制隐藏啊),四个被和谐的圆角,横屏的游戏都气炸了,旁边的同学快加把手,把乔布斯的棺材板按住了!
-
为了自适应iPhone X的奇葩屏幕,苹果深吸一口气,憋出了神技——safeArea组件。在iOS11系统中废弃了automaticallyAdjustsScrollViewInsets属性,同时也废弃了topLayoutGuide和bottomLayoutGuide等属性,替换为safeArea组件。新的问题随之而来,用xcode9在iOS11上运行APP几乎所有的页面,都会在push或pop进入页面时,存在漂移动画或布局偏移问题,以及在iPhone X上UIScrollView类的视图最底部的内容被homeIndicator遮挡问题,需要对APP做全面的适配。
3、苹果对iOS 11的布局指南
-
在为iPhone X设计时,您必须确保布局充满屏幕(表格视图可以拓展到指示器区域展示),并且不会被设备的圆角,其传感器外壳或用于访问主屏幕的指示器遮挡。
-
大多数使用标准系统提供的UI元素(如导航栏,表格和集合)的应用程序会自动适应设备的新外形。背景材料延伸到显示器的边缘,并且UI元素被适当地插入和定位。
-
对于具有自定义布局的应用,支持iPhone X也应该比较容易,特别是如果您的应用使用自动布局并遵守安全区域和边距布局指南。
三、解决方案
1、方案实施参考建议
-
完全自定义导航栏和Tabbar(非继承自系统的)或者自定义系统导航栏和Tabbar为不透明状态,且多使用frame布局,xib/storyboard、代码约束等,布局混乱的项目,时间不太充足的情况下,建议参考表象适配方案,改动相对较小,对原来的代码冲击较小。
-
项目是基于自动布局等约束写的,请参考完全适配方案,在视图全部基于约束写的项目上,改动也较小。按照苹果的自动布局和自动调整scrollview布局的思路走,避开复杂的布局计算问题,适配各个设备将变得非常优雅而完美。
-
表象适配方案:调整UIScrollView禁用系统自动调整和手动计算contentInset,scrollIndicatorInsets。需要将导航栏和tabbar设置为不透明状态,translucent = NO 或者设置一张透明度为1.0的背景图片。
-
发现破洞,补丁打补丁,局部修复。代码:新一年,旧一年,修修补补又一年。
2. 表象方案
2.1、解决iOS11上的漂移问题
iOS 11废弃了UIVIewController的automaticallyAdjustsScrollViewInsets属性,对应UIScrollView新添了contentInsetAdjustmentBehavior属性,之前的self.contentInsetAdjustmentBehavior = NO 保持不变(安全起见加上版本校验),在对应的UIScrollView页面或视图中设置scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever。此时已解决,UIScrollView 在iOS11上的漂移或偏移问题,小激动下。
2.2、解决视图底部内容被HomeIndicator遮挡问题
把APP在iPhone X上溜溜,全面屏好帅哦😍,眼明的我看到底部的内容被homeIndicator遮挡了,怎么能忍,动手改!我先试着调整了视图高度,减去HomeIndicator的高度34,这样底部的内容就不会被HomeIndicator遮挡啦!底部有按钮或tabbar等带交互的视图时,不要HomeIndicator所遮挡。
2.3、解决UIScrollView在未滑到底部时,视图不能扩展到HomeIndicator区域的问题
解决视图底部内容被HomeIndicator遮挡问题,也引起了另一个问题:UIScrollView类的视图在未滑到底部时,视图不能“扩展”到HomeIndicator区域的问题。这个是很有意思的问题,参考下模拟器的系统设置——隐私,你会发现,其中的tableView列表未滑到底部时,视图能扩展到HomeIndicator区域,当tableView滑动到最底部时,底部的内容也不会被HomeIndicator遮挡问题,当然页面也不会有漂移哈。这样的全面屏,是不是很666,怎么实现呢?
综合问题2和问题3,想要视图拓展到底部则frame或约束值要到最底部,UIScrollView内容的最底部不被遮挡则需要调整其内容contentInset = UIEdgeInsetsMake(0, 0, 34, 0)或UIEdgeInsetsMake(0, 0, 83, 0),同时设置scrollIndicatorInsets = contentInset,避开HomeIndicator区域。基本上能满足设计需求。此处,会引起上拉加载更多的显示问题,参考处理方法:iPhone X适配之MJ上拉加载更多的适配
2.4、方案实施
导航栏和tabbar都不透明,原代码已对iOS10及以前的版本做了适配。偏移的问题表象在scrollView的contentInset问题,我们就来直接修复scrollView的问题。综合以上探索,整理出适配的参考代码:
封装的处理工具方法
/*
调整页面中ScrollView的内容适应屏幕
@param viewController 页面控制器
@param scrollView 页面的ScrollView视图,页面中无scrollview时处理,待验证
@param isExtendHomeIndicator iPhone X 上是否需要拓展到HomeIndicator区域
底部无按钮等视图、无tabbar的scrollview页面时可传入YES,其他为NO
*/
+ (void)yg_scrollViewAdjustWithViewController:(UIViewController *)viewController
scrollView:(nullable UIScrollView *)scrollView
isExtendHomeIndicator:(BOOL)isExtendHomeIndicator
{
if(![viewController isKindOfClass:[UIViewController class]]) {
return;
}
//有tabbar存在则强制不拓展到homeIndicator,避免误传值
if(!viewController.hidesBottomBarWhenPushed) {
isExtendHomeIndicator = NO;
}
//版本适配
if(@available(iOS11, *)) {
if([scrollView isKindOfClass:[UIScrollView class]]) {
scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
//机型适配
if(IS_IPHONE_X_YG && isExtendHomeIndicator) {
scrollView.contentInset = UIEdgeInsetsMake(0, 0, IPHONE_X_HOME_HEIGHT, 0);
scrollView.scrollIndicatorInsets = scrollView.contentInset;
}
}
} else {
viewController.automaticallyAdjustsScrollViewInsets = NO;
}
}
调用用参考
BOOL showToolBar =YES;
dispatch_async(dispatch_get_main_queue(), ^{
_coverView.frame = self.view.bounds;
_coverView.ygHeight -= (showToolBar ? IPHONE_X_HOME_HEIGHT : 0);
});
[YGScrollViewAdjustToolyg_scrollViewAdjustWithViewController:self
scrollView:_coverView.collectionView
isExtendHomeIndicator:!showToolBar]
2.5、相关思考
-
因为项目大多基于MVC或MVVM等,视图层View和控制器ViewController都是低耦合的,是否有tabbar的值的传入,会加大耦合性,再调整scrollView frame时,注意关联视图的布局是否受影响。
-
viewController.automaticallyAdjustsScrollViewInsets实在控制器中处理,而scrollview的调整却在视图中,当存在控制器存在继承关系时,关联性的查找相对较复杂。
-
正确获得内容视图的frame布局,计算或约束设置的时机很关键,可以参考使用异步获得主线程空闲后,再获得系统View的frame,再以此计算布局,当屏幕旋转等,还需要手动刷新布局。
-
当页面的scrollView的下边还有底部按钮或toolbar等视图时,scrollView.ygHeight 高度的调整问题。
-
在scrollView底部添加上拉刷新的视图,会被显示在homeIndicator下。
3、完全适配方案:“透明化”导航栏,安全区域约束,系统自适应
站在苹果的肩膀上看风景。随着iOS版本迭代,API更具开放性,对各种设备的支持也更完善,从iOS9 之后,代码约束也变得更精简,使用系统原生API时,好处就是苹果会在升级系统或API时,自动帮我们做好对接和优化。
完全适配方案,不调整scrollView,而是调整控制和视图约束。让我们来先看看方案怎么实施,在后续的第四部分知识拓展给出说明。同也解决了页面切换过程中的navigationBar视图的颜色渐变问题。
3.1、“改造”APP的NavigationBar和TabBar为“半透明”状态
若APP的navigationBar和TabBar使用的是系统的或继承自系统后简易自定义的,一定不要将UINavigationBar.translucent 设置为NO(不透明),系统默认为半透明,并且背景色为白色。若需要调整背景颜色,需分别给UINavigationBar和UITabBar创建一个透明度小于1.0(具体值,根据需求做调整)的图片作为背景图,代码创建或切图。这部分的原因请看第四部分知识拓展。
AppDelegate 中实现并调用下面的参考代码:
- (void)addAppStyle {
//设置navBar的样式
//隐藏UINavigationBar分割线
[UINavigationBar appearance].shadowImage = [[UIImage alloc] init];
//这里用代码创建一个透明度(一定要)小于1.0的图片UIColor *navBarBgImageColor = [[UIColor redColor] colorWithAlphaComponent:0.99];
[[UINavigationBar appearance] setBackgroundImage:[Util createImageWithColor:navBarBgImageColor] forBarMetrics:UIBarMetricsDefault];
[UINavigationBar appearance].titleTextAttributes = [NSDictionary dictionaryWithObject:[UIColor whiteColor] forKey:NSForegroundColorAttributeName];
//设置tabbar的样式
[UITabBar appearance].barTintColor = [UIColor redColor];
// 这里用代码创建一个透明度(一定要)小于1.0的图片
UIColor *tabBarBgImageColor = [[UIColor whiteColor] colorWithAlphaComponent:0.99];
[[UITabBar appearance] setBackgroundImage:[Util createImageWithColor:tabBarBgImageColor]];
//不要设置translucent属性为NO,iOS7及以上系统默认为translucent 为YES
// [UINavigationBar appearance].translucent = NO;
// [UITabBar appearance].translucent = NO;
}
3.2、给UIViewController的自定义ContentView(整个屏幕)的添加约束
若你的控制器和对应contentView 已经添加约束,请参照方下面方法,升级你的约束设置。在创建ContentView的控制器中,调用这个工具(或分类)方法
/**
给控制器的自定义内容视图添加约束
@param viewController 控制器
@param contentView 控制器的内容视图(非自带的view)
@param isExtendHomeIndicator iPhone X 上是否需要拓展到HomeIndicator区域
底部无按钮等视图、无tabbar的scrollview页面时传入YES,其他为NO
*/
+ (void)yg_addLayoutConstraintsWithViewController:(UIViewController *)viewController
contentView:(UIView *)contentView
isExtendHomeIndicator:(BOOL)isExtendHomeIndicator
{
if (@available(iOS 11, *)) {
} else {
viewController.automaticallyAdjustsScrollViewInsets = NO;
}
//添加约束
dispatch_async(dispatch_get_main_queue(), ^{
contentView.translatesAutoresizingMaskIntoConstraints = NO;//使用autolayout
if (@available(iOS 11, *)) {
CGFloat iphoneXHomeAdjust = (IS_IPHONE_X_YG && isExtendHomeIndicator) ? 34 : 0;
NSLayoutConstraint *top = [contentView.topAnchor constraintEqualToAnchor:viewController.view.safeAreaLayoutGuide.topAnchor];
NSLayoutConstraint *bottom = [contentView.bottomAnchor constraintEqualToAnchor:viewController.view.safeAreaLayoutGuide.bottomAnchor constant:iphoneXHomeAdjust];
NSLayoutConstraint *left = [contentView.leadingAnchor constraintEqualToAnchor:viewController.view.safeAreaLayoutGuide.leadingAnchor];
NSLayoutConstraint *right = [contentView.trailingAnchor constraintEqualToAnchor:viewController.view.safeAreaLayoutGuide.trailingAnchor];
[NSLayoutConstraint activateConstraints:@[left, right, top, bottom]];
} else {
CGFloat adjustBottom = viewController.hidesBottomBarWhenPushed ? 0 : -49;
NSLayoutConstraint *top = [NSLayoutConstraint constraintWithItem:contentView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:viewController.topLayoutGuide attribute:NSLayoutAttributeBottom multiplier:1 constant:0];
NSLayoutConstraint *bottom = [NSLayoutConstraint constraintWithItem:contentView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:viewController.view attribute:NSLayoutAttributeBottom multiplier:1 constant:adjustBottom];
NSLayoutConstraint *left = [NSLayoutConstraint constraintWithItem:contentView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:viewController.view attribute:NSLayoutAttributeLeft multiplier:1 constant:0];
NSLayoutConstraint *right = [NSLayoutConstraint constraintWithItem:contentView attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:viewController.view attribute:NSLayoutAttributeRight multiplier:1 constant:0];
[NSLayoutConstraint activateConstraints:@[top, bottom, left, right]];
}
});
}
3.3、修复ContentView使用frame布局造成布局错乱问题
若你的contentView使用的是代码布局或xib布局,此处你几乎可以不做任何改动,依然完美适配。若你是基于frame计算subview的布局,可以通过重写contentView 的layoutSubviews方法,依据contentView的frame重新计算子视图的布局。
3.4、相关思考
-
iOS10之前,调整viewController.automaticallyAdjustsScrollViewInsets在控制器中,当前添加约束也在控制器中调整,当存在控制器的继承问题时,关联性的解决问题依然相对复杂。
-
若项目完全基于约束布局和半透明的导航栏和tabBar,则升级控制器层面的布局约束即可,其他地方改动非常小,不会存在复杂的计算问题,耦合性很低,便于维护。
-
在iPhone X上视图是否需要拓展到homeIndicator区域,控制的比较明确。
4、方案过渡
4.1、现行方案(表象适配)
将视图层中的滚动视图通过get方法暴露给控制层,在控制层根据页面的内容调整布局逻辑,避免视图层和控制层分别调整,保持逻辑的集中和一致。不调整视图层的逻辑,维持frame布局和storyboard布局。控制层的主要逻辑:视图初始化设置视图中滚动视图的contentInset和scrollIndicatorInsets、禁用系统自动调整异步获得frame的正确值,根据页面需求修正frame,利用Frame更新视图布局。
4.2、过渡方案
在新开发和后续优化具体页面时,将现行方案中的frame布局和storyboard布局,升级为代码约束布局。
4.3、最终方案(完全适配)
开发工期允许时,对未调整的frame布局和storyboard布局做统一升级,并对导航栏和Tabbar做透明化处理,完成完全适配。
5、iOS11和iPhone X表象适配和完全适配的对比
4.1、展示效果差别
适配前的样式和完全适配的样式
适配前后对比
4.2、改动范围:表象适配主要调整视图层,完全适配主要调整视图层和控制层
-
表象适配主要是调整布局偏移的列表视图UIScrollView,不会调整页面的ViewController和根视图ContentView,不影响原有的视图布局逻辑,改动较小,对项目原有逻辑冲击较小。
-
完全适配需要调整所有的ViewController和给其根视图ContentView添加约束布局,不再调整UIScrollView,借助系统自动适配布局,修复视图偏移,部分frame布局的ContentView和storyboard布局的页面,还需要再做进一步适配调整。
4.3、项目发展角度:完全适配方案完成项目深层次优化
-
项目中存在代码约束、xib约束、storyboard页面和frame计算等布局方式,页面的根视图主要使用的frame布局,对不同的机型和版本适配存在较大的遗留问题,不便于项目的后续快速开发和维护。
-
表象适配方案只是“简单修复”(并不完善)视图偏移问题,补丁打补丁,属于取巧的办法,而且还需要多处设置不同contentInset数值,不够灵活,后期还会遇到类似的问题。
-
完全适配方案参照苹果系统APP的设计思路,调整整个APP的布局逻辑,从控制层到视图层统一使用代码约束和xib约束布局,更利于开发和维护。
4.4、预估开发工期(根据自己项目):表象适配需要2周左右,完全适配需要3周左右。
-
这次适配需要对所有页面在不同的机型(iPhone X、iPad、iPhone 其他机型)和不同版本(iOS11、iOS11之前)iOS11上,进行检查、调整和测试验证。
-
表象适配方案改动较小,对项目原有逻辑冲击较小。
-
完全适配改动层次更深,改动范围也更大,对项目原有逻辑冲击较大,改动中可能会遇到未知的问题。
四、知识拓展和问题分析
是否对第二套方案有点疑惑?没关系,让我们回头再看看automaticallyAdjustsScrollViewInsets,系统自动调整scrollView的contentInset,注意该属性是从iOS7 开始的,什么?你不知道?有老司机在😆。
1、iOS自动布局演进
Auto Layout一个一直被同学们嫌弃的系统框架,起初该类api 使用相对复杂(苹果默默地出来背会黑锅),早期设备样式单一时,甚至好多APP是基于宏定义屏幕储存,手动frame算布局,后来有位大拿出了masonry约束库,简直是iOS开发的福音啊,现在依然支持很好,膜拜下。现在我要给Auto Layout 来整个面。其实对于Autolayout的资料非常非常的多,老司机略知皮毛,大致说说,也上不了大雅之堂。
1.1、iOS6之前 Autoresizing 启蒙
autoresizing是UIView的属性,一直都有,使用简单,但是没有autolayout强大。当UIView的autoresizesSubviews是YES时,(默认是YES), 那么在其中的子view会根据它自身的autoresizingMask属性来自动适应其与superView之间的位置和大小。autoresizingMask是一个枚举类型, 默认是UIViewAutoresizingNone, 主要是早期的开发中在xib中使用。
1.2、iOS6 Auto Layout 发芽
iOS6 之后有了基于约束(NSLayoutConstraint)的强大的Auto Layout。此时的Auto Layout使用起来晦涩难懂,另外还有采用VFL创建约束的方式,代码很简洁,但是读写都更晦涩。不过此时xcode4还不支持在nib(xib)中添加约束,当时写约束是个很头大的事,masonry 也应运而生。
1.3、iOS7 Auto Layout 起飞
xcode5的发布,实现了nib (xib)中添加约束的功能,xib可设置的constraints的类别还比较简单,没法设置相对比例约束等高级约束,这些都需要用晦涩的代码来添加约束。这个时期UIViewController添加了topLayoutGuide、bottomLayoutGuide和automaticallyAdjustsScrollViewInsets,布局引导和布局调整,极大的方便了页面框架的布局。
1.4、iOS8 Auto Layout 爆发
在iOS 8中新增Size Classes,将不同的设备尺寸归类抽象,Auto Layout布局可视化得到加强 。添加了许多NSLayoutAttribute的属性,activateConstraints和deactivateConstraints让在添加约束上更加方便以及可添加的约束更加细致到具体细节,StoryBoardMargin的属性和cell的自适应高度等。
1.5、iOS9 Auto Layout 升级
在iOS 9中新增一个神奇的类:UILayoutGuide,是UIView的一个属性。这个类然不能像UIView一样显示在界面上,但是描述了其他视图控件在布局中位的置和空间,就好像有一个透明的UIView一样,但是它却不在视图的层次中,减少了多余的视图层,代码添加约束变的精简,运行效率更高,自动布局体系开始完善。
1.6、iOS10 Auto Layout 没啥进展
iOS 10如iPhone7一样没有太大进展。
1.7、iOS11 Auto Layout 进化
因为iPhone X的加入,屏幕变得极不规则,尺寸也不再近似于所谓的16:9。顶部帅气的刘海,底部霸气的homeIndicator ,四周圆被和谐的圆角,旁边的同学快加把手,乔布斯的棺材板快按不住了!
为了优化iPhone X的视图,苹果憋出了神技,安全区布局指南(Safe Area Layout Guide),废除了topLayoutGuide 和 bottomLayoutGuide,automaticallyAdjustsScrollViewInsets 。UIViewController 新增了additionalSafeAreaInsets、safeAreaLayoutGuide和safeAreaInsets等属性,viewSafeAreaInsetsDidChange和safeAreaInsetsDidChange等方法。代码中的具体使用,第四节会给出,xib的使用参考iOS11安全区布局指南 设计样式参考iPhoneX适配之UI设计、交互设计。
还在frame布局的同学,面壁十分钟,没收晚饭😤!
2、追根溯源iOS7
很久之前,苹果从iOS7开始采用扁平化的界面风格,颠覆了果粉们“迷恋”的拟物化风格,对系统的tabbar和导航栏,状态栏做了深度优化。对于开发者而言,全新的风格带来新的接口和属性,来让我重点说几个。
2.1、UITabBar/UINavigationBar的translucent(iOS7 ~)
官方API我都不贴了,简而言之,这个BOOL属性能控制UITabBar/UINavigationBar的半透明效果,默认为YES,即默认情况下为半透明效果。该属性比较“诡异”,主要体现为被半透明效果处理后产生的色差和添加的view控件的坐标变动。
2.1.1、滚动视图调整
默认情况下,如果使用UITabBarController和UINavigationBarController(translucent属性默认为YES),设置一个蓝色的view添加其中并设置距离屏幕边距为(0,0,0,0),UITabBarController和UINavigationBarController被蓝色的view“穿透”了,此时view的边距也正如设置的一样,零点坐标在(0,0)处。这时候,如果将view替换成tableView可以看到,tableView的cell并没有因为“穿透”效果而出现被遮挡的情况,这是由于苹果对滚动视图的特殊性进行处理:对于UIScrollView,系统默认默认控制器属性automaticallyAdjustsScrollViewInsets默认为YES。
automaticallyAdjustsScrollViewInsets = YES时系统底层所干的事,scrollView的内容原本没有内边距,但是考虑到导航栏(高度44px)、状态栏(高度20px)、TabBar(高度49px)会挡住后面scrollView所展示的内容,系统自动为scrollView增加上下的内边距,这时候我们打印一下tableView可以看出,contentOffset(内容坐标偏移量)和contentSize(内容尺寸大小)都发生了变化,结合tableView自身的frame可以看出,系统自动为scrollView增加了顶部64px的内边距以及底部49px的内边距,正好是导航栏高度+状态栏高度以及TabBar高度。一旦手动在系统布局页面之前设置automaticallyAdjustsScrollViewInsets = NO,将会取消上述操作,届时scrollView内容将会被部分挡住。
请注意:上述的情况仅仅对UIScrollView或者子类(如UITableView)有效。
2.1.2、非滚动视图布局问题
从上述内容中,我们可以了解到滚动视图默认情况下系统会给滚动内容增加上下内边距,以防内容被导航条和TabBar遮挡,但是实际上在常用的项目界面结构(TabBarController--NavigationController--ViewController)以及系统默认情况下,viewController中的view是没有内边距的(也就是说view穿透上下两个Bar)。但是我们有时候会看到应用中会有这样的情形:非滚动视图依然有类似增加上下内边距的效果,自己并没有手动更改过视图的frame。这种情况,就要结合其他几个系统的属性来调整了。
2.1.3、设置导航条或者TabBar背景图片的注意事项
这里会详细解释官方文档对translucent属性的注释,为什么放到这里才说?因为官方文档对该属性的解释全部跟设置导航条或者TabBar的背景图片有关!说明苹果也知道,这里坑很多,下面就来梳理一下吧。UINavigationBar/UITabBar的translucent属性解释:默认为YES,可以通过设置NO来强制使用非透明背景,如果导航条使用自定义背景图片,那么默认情况该属性的值由图片的alpha(透明度)决定,如果alpha的透明度小于1.0值为YES。如果手动设置translucent为YES并且使用自定义不透明图片,那么会自动设置系统透明度(小于1.0)在这个图片上。如果手动设置translucent为NO并且使用自定义带透明度(透明度小于0)的图片,那么系统会展示这张背景图片,只不过这张图片会使用事先确定的barTintColor进行不透明处理,若barTintColor为空,则会使用UIBarStyleBlack(黑色)或者UIBarStyleDefault(白色)。
设置导航栏背景图片透明度问题。如果背景图片没有透明度,系统会自动把导航控制器的栈顶控制器的view的Y值增加64,如果有透明度,则不会增加。这一情况的图片必须是imageSet格式的,并且图片的透明度为1(不透明),系统才会去调整。
2.2、topLayoutGuide 和 bottomLayoutGuide (iOS7 ~iOS 11)
topLayoutGuide属性表示不希望被透明的状态栏或导航栏遮挡的内容范围的最高位置。这个属性的值是它的length属性的值(topLayoutGuide.length),这个值可能由当前的ViewController或这个ViewController所属的NavigationController或TabBarController决定。
一个独立的ViewController,不包含于任何其他的ViewController。如果状态栏可见,topLayoutGuide表示状态栏的底部,否则表示这个ViewController的上边缘。包含于其他ViewController的ViewController不对这个属性起决定作用,而是由容器ViewController决定这个属性的含义:
如果导航栏(Navigation Bar)可见,topLayoutGuide表示导航栏的底部。
如果状态栏可见,topLayoutGuide表示状态栏的底部。
如果都不可见,表示ViewController的上边缘。
这部分还比较好理解,总之是屏幕上方任何遮挡内容的栏的最底部。
通过对ViewController的生命周期消息进行跟踪,在下一页显示出来前,首先调用下一页ViewController的viewDidLoad方法,也就是加载过程,此时topLayoutGuide值为0;而显示的消息viewWillAppear:和布局的消息viewWillLayoutSubview在放开手指之后才会发送,而这个时候已经完全显示了,topLayoutGuide的值为64或88,也就是在状态栏下边缘。
当translucent为NO或背景色不透明时,系统会根据navigationBar(含statusBar)是否显示和Frame等属性计算navigationBar的实际高度,再交给topLayoutGuide,计算布局高度,是耗时操作,并且有调用顺序。这时,你会发现,topLayoutGuide变更多次或viewSafeAreaInsetsDidChange调用多次,而且计算的值有差别。这就导致我们看到页面的向上漂移的问题。
2.3、automaticallyAdjustsScrollViewInsets(iOS7 ~iOS 11)
是否由系统自动调整滚动视图的内边距,默认为YES,意味着系统将会根据导航条和TabBar的情况自动增加上下内边距以防止滚动视图的内容被Bar遮挡。主要是为了修正translucent和topLayoutGuide 和 bottomLayoutGuide 引起的scrollView的布局问题。
五、探究思路
问题的根源很早可以追溯到iOS7时期,让我们带着问题,去探索iOS的layoutGuide的发展历程,踏雪无痕的解决UIScrollView在 iOS 11和iPhone X上偏移的问题。
苹果给的建议和大部分网友都说,通过scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever 调整,来让我们探究下。
1、探究思路1—UIScrollViewContentInsetAdjustmentNever和设置scrollView的contentInset
1.1、问题表像
当automaticallyAdjustsScrollViewInsets在iOS 11 废弃后,系统也给出了很“友好”的提示。
@property(nonatomic,assign) BOOL automaticallyAdjustsScrollViewInsets API_DEPRECATED_WITH_REPLACEMENT("Use UIScrollView's contentInsetAdjustmentBehavior instead", ios(7.0,11.0),tvos(7.0,11.0)); // Defaults to YES
typedef NS_ENUM(NSInteger, UIScrollViewContentInsetAdjustmentBehavior) {
UIScrollViewContentInsetAdjustmentAutomatic, //默认。类似scrollableaxes,但向后的兼容性也将调整顶部和底部contentinset当滚动视图是由一个automaticallyadjustsscrollviewinsets =是的导航控制器在视图控制器的所有,无论是滚动滚动视图
UIScrollViewContentInsetAdjustmentScrollableAxes, // 对滚动轴边调整(即contentsize.width/height > frame.size.width/height或alwaysbouncehorizontal /垂直=是的)
UIScrollViewContentInsetAdjustmentNever, // contentInset 不作调整
UIScrollViewContentInsetAdjustmentAlways, // contentinset总是由滚动视图的safeareainsets调整
} API_AVAILABLE(ios(11.0),tvos(11.0));
1.2、解决方式
可以通过设置UIScrollView或子类的contentInsetAdjustmentBehavior属性值为枚举值UIScrollViewContentInsetAdjustmentNever,来替补automaticallyAdjustsScrollViewInsets在iOS 11上的欠缺问题,为了方便调整一般会通过宏定义方法的形式,参考代码:
+ (void)yg_layoutAdjustWithScrollView:(UIScrollView *)scrollView
isShowTabBar:(BOOL)isShowTabBar
isExtendHomeIndicator:(BOOL)isExtendHomeIndicator
{
if (@available(iOS 11.0,*)) {
scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
}
if (IS_IPHONE_X_YG) {
CGFloat adjustHeight = isShowTabBar ? 0 : (isExtendHomeIndicator ? 0 : 34);
CGFloat adjustContentBottom = isShowTabBar ? 0 : (isExtendHomeIndicator ? 0 : 34);
scrollView.ygHeight -= adjustHeight;
scrollView.contentInset = UIEdgeInsetsMake(0, 0, adjustContentBottom, 0);
scrollView.scrollIndicatorInsets =scrollView.contentInset;
}
}
当然,甚至还有同学,想到了这个神奇的东东
if (@available(iOS 11.0, *)) {
[[UIScrollView appearance] setContentInsetAdjustmentBehavior:UIScrollViewContentInsetAdjustmentNever];
}
其实,这样用是可能存在问题,慎用:
1、实际开发中, 所有含有UIScrollView或其子类的控制器,并不是都使用了automaticallyAdjustsScrollViewInsets来调整布局,是存在部分页面的UIScrollView 需要系统自动调整。
2、这样写法,会存在APP崩溃风险,提示找不到方法,具体原因分析可以参考 同时点击手势深度优化处理 setExclusiveTouch。
1.3、处理结果——表象解决iOS11和iPhone X上的适配问题
scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever,确实解决了app在iOS11(iPad、iPhone、iPhone X)上的视图漂移问题。然并卵,在iPhone X上运行,我却发现了一个很尴尬的问题,scrollView的最底部内容被HomeIndicator 所遮挡或通过约束等将scrollView的contentsize的高度减少34 ,但在scrollView滚动过程中,不能将视图自动扩展到HomeIndicator区域。想要视图拓展到底部则frame或约束值要到最底部,UIScrollView内容的最底部不被遮挡则需要调整其内容contentInset = UIEdgeInsetsMake(0, 0, 34, 0)或UIEdgeInsetsMake(0, 0, 83, 0),scrollView.scrollIndicatorInsets =scrollView.contentInset,避开HomeIndicator区域,基本上能满足设计需求。
2、探究思路2—self.additionalSafeAreaInsets
经过探究思路1我们废弃了scrollView.contentInsetAdjustmentBehavior方法,使用了系统的自动调整功能。
2.1、问题表象
网上还有不少文章说可以通过控制器的self.additionalSafeAreaInsets(默认UIEdgeInsetsMake(0, 0, 0, 0)),来调整view的安全区域,来实现布局的调整。self.additionalSafeAreaInsets的变更,我们可以通过重写
UIViewController 的 - (void)viewSafeAreaInsetsDidChange 或 UIView 的- (void)safeAreaInsetsDidChange 方法,来捕获变更后的值。
- (void)viewSafeAreaInsetsDidChange {
[super viewSafeAreaInsetsDidChange];
YGLog(@"安全区域改变后");
YGLog(@"self.additionalSafeAreaInsets %@",NSStringFromUIEdgeInsets(self.additionalSafeAreaInsets));
YGLog(@"self.view.safeAreaInsets %@",NSStringFromUIEdgeInsets(self.view.safeAreaInsets));
YGLog(@"_coverView.safeAreaInsets %@",NSStringFromUIEdgeInsets(_coverView.safeAreaInsets));
YGLog(@"self.view.frame %@",NSStringFromCGRect(self.view.frame));
YGLog(@"_coverView.frame %@",NSStringFromCGRect(_coverView.frame));
}
2.2、解决方式
2.2.1、self.additionalSafeAreaInsets = UIEdgeInsetsMake(0, 0, 0, 0);
安全区域改变后
self.additionalSafeAreaInsets {0, 0, 0, 0}
self.view.safeAreaInsets {0, 0, 34, 0}
_coverView.safeAreaInsets {0, 0, 0, 0}
self.view.frame {{0, 0}, {375, 724}}
_coverView.frame {{0, 0}, {375, 812}}
_coverView.safeAreaInsets {0, 0, 34, 0}
安全区域改变后
self.additionalSafeAreaInsets {0, 0, 0, 0}
self.view.safeAreaInsets {44, 0, 34, 0}
_coverView.safeAreaInsets {0, 0, 34, 0}
self.view.frame {{0, 0}, {375, 724}}
_coverView.frame {{0, 0}, {375, 812}}
_coverView.safeAreaInsets {44, 0, 34, 0}
安全区域改变后
self.additionalSafeAreaInsets {0, 0, 0, 0}
self.view.safeAreaInsets {0, 0, 34, 0}
_coverView.safeAreaInsets {44, 0, 34, 0}
self.view.frame {{0, 0}, {375, 724}}
_coverView.frame {{0, 0}, {375, 812}}
_coverView.safeAreaInsets {0, 0, 34, 0}
2.2.2、self.additionalSafeAreaInsets = UIEdgeInsetsMake(44, 0, 34, 0);
安全区域改变后
self.additionalSafeAreaInsets {44, 0, 34, 0}
self.view.safeAreaInsets {44, 0, 68, 0}
_coverView.safeAreaInsets {0, 0, 0, 0}
self.view.frame {{0, 0}, {375, 724}}
_coverView.frame {{0, 0}, {375, 812}}
_coverView.safeAreaInsets {44, 0, 68, 0}
安全区域改变后
self.additionalSafeAreaInsets {44, 0, 34, 0}
self.view.safeAreaInsets {88, 0, 68, 0}
_coverView.safeAreaInsets {44, 0, 68, 0}
self.view.frame {{0, 0}, {375, 724}}
_coverView.frame {{0, 0}, {375, 812}}
_coverView.safeAreaInsets {88, 0, 68, 0}
安全区域改变后
self.additionalSafeAreaInsets {44, 0, 34, 0}
self.view.safeAreaInsets {44, 0, 68, 0}
_coverView.safeAreaInsets {88, 0, 68, 0}
self.view.frame {{0, 0}, {375, 724}}
_coverView.frame {{0, 0}, {375, 812}}
_coverView.safeAreaInsets {44, 0, 68, 0}
_coverView.safeAreaInsets {0, 0, 68, 0}
_coverView.safeAreaInsets {0, 0, 34, 0}
2.3、处理结果——未能解决漂移问题
其实在探究思路1 中打印的信息也是这样子,分析以上打印信息,可以看到,在进入页面的过程中,viewSafeAreaInsetsDidChange会多次调用。self.additionalSafeAreaInsets 的设置虽然影响到了self.view.safeAreaInsets 值 ,但加载页面的过程中self.view.safeAreaInset的的top值每次都在变化,变化值是44 😲,同时_coverView.safeAreaInsets也会随之调整。
无论怎么设置self.additionalSafeAreaInsets发现viewSafeAreaInsetsDidChange会多次调用 ,而且打印出的安全区域的边界值都不同,漂移问题依然存在。
3、探究思路3—保持系统原生调整和自动布局
经过追根溯源iOS7,将 [UINavigationBar appearance].translucent = NO; 记得设置的背景图片透明度一定要小于1.0(第四部分知识拓展)
当translucent为NO或背景色不透明时,系统会根据navigationBar(含statusBar)是否显示和Frame等属性计算navigationBar的实际高度,再交给topLayoutGuide,计算布局高度,是耗时操作,并且有调用顺序。这时,你会发现,topLayoutGuide变更多次或viewSafeAreaInsetsDidChange调用多次,而且计算的值有差别。这就导致我们看到页面的向上漂移的问题。
安全区域改变后
self.additionalSafeAreaInsets {0, 0, 0, 0}
self.view.safeAreaInsets {88, 0, 34, 0}
_coverView.safeAreaInsets {0, 0, 0, 0}
self.view.frame {{0, 0}, {375, 812}}
_coverView.frame {{0, 0}, {375, 812}}
惊奇的事情发生了, viewSafeAreaInsetsDidChange 只调用了一次,视图不再漂移,视图显示正常,而且完美适配iOS 11和iPhone X。
六、总结
-
遇到比较难的问题,先不要盲目的下手改动,多去看看官方文档或API代码,多去问问前人,可能你的问题都不是问题或已经有完整的方案。
-
请用约束写布局或xib布局,千万别frame了。多参考系统API的框架和原理,在升级系统或出新设备时,你会很惬意。
-
对知识要有探索和敬畏之心,拥抱变化,适应变化,迎接未来。
本文为作者原创,水平有限,如有不当之处欢迎指正。
尊重劳动,转载请注明出处,谢谢!