iOS 11 safeArea详解 & iphoneX

作者: fruitymoon | 来源:发表于2017-10-05 12:54 被阅读33842次
    iphoneX.jpg

    最近看了许多iPhone X适配的文章,发现很少有介绍safeArea的,就来随便写写

    现在对于iPhone X的适配,有一种常见的做法是给导航栏或tabbar增加一个固定的距离,比如顶部增加44pt,底部增加34pt。这种写死距离的做法乍看上去挺简单,其实并不好,理由如下

    1. 不适合多机型的适配,如果以后出了一种带刘海的iPad,需要预留出来的距离就未必是现在写死的距离
    2. 不适合需要支持横竖屏的app,横屏顶部不需要增加距离,反而是左右各有44pt,底部的距离也和竖屏不同
    3. 不够动态。还是举个例子,假如有电话打进来了,导航栏应该会下移,这时候view可能还是会被挡住

    这里我想探讨一下如何使用safeAreaLayoutGuide和safeAreaInsets,以一种动态的方式,一劳永逸地解决iPhone X甚至后续所有机型的适配问题。

    safeAreaLayoutGuide


    首先我们看看什么是safeAreaLayoutGuide

    A6522569-34FC-4A13-A357-63D91CE134AB.png

    看起来复杂,其实很简单,我归纳一下有几点:

    1. 它是UIView的一个只读属性,意味着所有UIView对象都有并且是系统帮我们创建好的
    2. 它继承UILayoutGuide,有layoutFrame意味着它能代表一块区域
    3. 它代表的区域避开了诸如导航栏、tabbar或者其他有可能挡住你这个UIView对象显示的所有父view,意味着你的view对象只要相对另一个view的safeLayoutGuide做布局就不用担心她被奇奇怪怪的东西挡住
    4. 对于控制器的view的safeAreaLayoutGuide,他的区域同样避开了statusbar或其他有可能挡住view显示的东西,我们甚至可以用控制器的additionalSafeAreaInsets属性,来额外指定inset
    5. 如果view完全在父view的安全区域内,或者view不在视图层级或屏幕上,那么view的safeAreaLayoutGuide区域其实和view自身是一样大的

    safeAreaLayoutGuide是一个相对抽象的概念,为了便于理解,我们可以把safeAreaLayoutGuide看成是一个“view”,这个“view”系统自动帮我们调整它的bounds,让它不会被各种奇奇怪怪的东西挡住,包括iPhone X的刘海区域和底部的一道杠区域,可以认为在这个“view”上一定能完整显示所有内容。

    以下绿色部分就是当前控制器view的safeAreaLayoutGuide区域
    iphone X竖屏safeAreaLayoutGuide的bounds.png iPhone X横屏safeAreaLayoutGuide的bounds.png
    截图来自https://developer.apple.com/videos/play/fall2017/801/

    不过需要铭记的一点是这个“view”并不会显示在我们的视图层级上。
    UILayoutGuides will not show up in the view hierarchy, but may be used as items in an NSLayoutConstraint and represent a rectangle in the layout engine.

    在我看来,他最大的作用是作为参照物,让view可以相对某个view的safeAreaLayoutGuide做布局,从而保证view能正常、安全地显示(相对的那个view不一定要是父view)

    在一种常见的使用场景里,以前我的某个view是相对于控制器的view做布局,现在是相对控制器view的safeAreaLayoutGuide做布局了

    以前是这样写
    [NSLayoutConstraint constraintWithItem:someView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.vc.view attribute:NSLayoutAttributeTop multiplier:1.0 constant:0];

    现在是这样
    [NSLayoutConstraint constraintWithItem:someView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.vc.view.safeAreaLayoutGuide attribute:NSLayoutAttributeTop multiplier:1.0 constant:0];

    适配前后的效果
    适配前.png 改成相对于view的safeAreaLayoutGuide后-竖屏.png 改成相对于view的safeAreaLayoutGuide后-横屏.png

    可以看到,相同的布局下,横屏在没有statusbar时,距离顶部是0,左边是44,如果有statusbar,距离顶部就是20。反正不管怎么弄,只要我们相对safeAreaLayoutGuide做布局,我们的view就能够安全完整地显示出来

    那么非iOS11怎么办?

    非iOS11 还是只能对view做布局,就要写两套布局代码,稍后会介绍

    这样是不是就足够应对所有情况了呢?

    并不是

    1. 我们自定义的view有一边需要紧挨着屏幕边缘,比如我项目里是自定义的导航栏,它的顶部是挨着屏幕顶部的,那么导航栏就不能相对view的safeAreaLayoutGuide布局,否则顶部会空出来一截子
    2. frame布局

    这时就轮到safeAreaInsets来发挥作用啦

    safeAreaInsets


    先看一下safeAreaInsets的官方解释

    CABD7DCE-79F0-4464-81ED-161F5FA16452.png

    有没有觉得和safeAreaLayoutGuide很像?safeAreaLayoutGuide可能就是根据safeAreaInsets来调整自己的bounds的

    iPhone X竖屏时占满整个屏幕的控制器的view的safeAreaInsets是(44,0,34,0),横屏是(0,44,21,44),inset后的区域正好是safeAreaLayoutGuide区域

    既然如此,对于自定义的顶部导航栏来说,我们可以给导航栏的高度加上一个vc.view.safeAreaInsets.top,让他变高一点就可以了,这样在X上,竖屏时top = 44, 横屏时top = 0,导航栏的高度能响应改变

    需要注意的是,无论safeAreaLayoutGuide还是safeAreaInsets都是iOS11才能使用的。
    对于safeAreaInsets,我们可以把版本判断写在一个函数里

    我们可以这样写

    static inline UIEdgeInsets sgm_safeAreaInset(UIView *view) {
        if (@available(iOS 11.0, *)) {
            return view.safeAreaInsets;
        }
        return UIEdgeInsetsZero;
    }
    
    UIEdgeInsets safeAreaInsets = sgm_safeAreaInset(self.view);
    CGFloat height = kDefaultTopViewHeight; // 导航栏原本的高度,通常是44.0
    height += safeAreaInsets.top > 0 ? safeAreaInsets.top : 20.0; // 20.0是statusbar的高度
    

    问题又来了,这段代码放在什么地方合适呢?前面官方文档提到过,如果view不在屏幕上或显示层级里,view的safeAreaInsets = UIEdgeInsetsZero,所以我们需要明确知道safeAreaInsets改变的时机

    实际上系统已经提供了回调

    对于UIViewController

    -(void)viewSafeAreaInsetsDidChange NS_REQUIRES_SUPER API_AVAILABLE(ios(11.0), tvos(11.0));

    对于UIView

    -(void)safeAreaInsetsDidChange API_AVAILABLE(ios(11.0),tvos(11.0));

    这里主要探讨controller的回调,view的回调是类似的。只要controller的view的safeAreaInsets改变,系统就会调用viewSafeAreaInsetsDidChange。自然而然,我们会想把以上代码放在这里,然而这里有个大坑,你会发现,当这个控制器以动画的方式push进来时,导航栏的高度也会动画地变高,产生了不必要的多余动画,这种体验很糟糕

    那么究竟应该放在哪里?我们很有必要看一下新的viewController调用时序
    以下是从“rootVC” push 到 “pushVC”控制台输出的调用时序以及对应控制器的view的safeAreaInsets

    2017-10-04 16:59:59.594811+0800 XXX[15662:803767] Begin pushViewController to [<_TtCC8XXXTests27ContainerViewControllerTest20MockUIViewController: 0x7f9c07b643b0>]
    viewDidLoad()---Optional("pushVC")---UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
    willMove(toParentViewController:)---Optional("pushVC")---UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
    viewWillDisappear---Optional("rootVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    viewWillAppear---Optional("pushVC")---UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
    viewSafeAreaInsetsDidChange()---Optional("pushVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    viewWillLayoutSubviews()---Optional("pushVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    viewDidLayoutSubviews()---Optional("pushVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    viewWillLayoutSubviews()---Optional("pushVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    viewDidLayoutSubviews()---Optional("pushVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    viewDidAppear---Optional("pushVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    viewDidDisappear---Optional("rootVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    didMove(toParentViewController:)---Optional("pushVC")---UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    2017-10-04 16:59:59.604563+0800 XXX[15662:803767] Did PushViewController [<_TtCC8XXXTests27ContainerViewControllerTest20MockUIViewController: 0x7f9c0790d170>]->[<_TtCC8XXXTests27ContainerViewControllerTest20MockUIViewController: 0x7f9c07b643b0>] time = [0.009772]
    

    可以看到,viewSafeAreaInsetsDidChange调用时机很早,在viewWillAppear后,这是为什么出现多余动画的原因。并且“pushVC”的safeAreaInsets直到viewSafeAreaInsetsDidChange调用前,都是UIEdgeInsetsZero,之后才是正确的UIEdgeInsets(top: 44.0, left: 0.0, bottom: 34.0, right: 0.0)
    并且viewSafeAreaInsetsDidChange后面会调用两次viewDidLayoutSubviews,所以我们应该把改变高度或布局的代码都写在viewDidLayoutSubviews里,这样就不会有多余的动画效果了。需要注意viewDidLayoutSubviews可能会由别的操作频繁触发,所以如果调整safeArea布局的代码比较耗时,可以考虑加上一个状态标记,只在didChange后执行一次布局调整

    最后的代码应该长这样

    - (void)viewDidLayoutSubviews {
        [super viewDidLayoutSubviews];
        UIEdgeInsets safeAreaInsets = sgm_safeAreaInset(self.view);
        CGFloat height = 44.0; // 导航栏原本的高度,通常是44.0
        height += safeAreaInsets.top > 0 ? safeAreaInsets.top : 20.0; // 20.0是statusbar的高度,这里假设statusbar不消失
        if (_navigationbar && _navigationbar.height != height) {
            _navigationbar.height = height;
        }
    
    适配前后的效果
    适配前
    适配后

    这样对于frame布局和autolayout布局的各种情况,有了一个动态的适配方案,就是分别使用safeAreaLayoutGuide和safeAreaInsets来灵活处理布局,相比写死一个固定距离,当前和未来的各种机型都能一套代码适配,扩展性更好。我们项目目前也是采用这种做法,如果你的项目需要适配横竖屏或UI控件布局相对复杂,真的应该考虑使用safeArea

    顺便提一下,VFL似乎已经废了,因为|只能表示父view的边缘,并没有一个符号来表示父view的safeAreaLayoutGuide的边缘,以前我们写的VFL代码,好多得改,改起来也特别麻烦,建议别再用VFL了

    最后一个版本判断的问题,safeAreaInsets和safeAreaLayoutGuide都是iOS11的API,如果不做封装,直接在代码里写,势必会出现大量@available这种版本判断语句,代码里到处是@available,看起来很崩溃,破坏代码可读性。

    因为我之前写了一个自动布局框架,这次就将safeAreaLayoutGuide和版本判断都顺便封装在里面了,个人觉得这套框架比NSLayoutAnchor好用,主要作用是简化布局代码书写,以下是生成一个NSLayoutConstraint的对比

    // 需求是topLeftView的top等于self.view的safeAreaLayoutGuide的top
    // 使用系统API
    if (@available(iOS 11.0, *)) {
            [NSLayoutConstraint constraintWithItem:self.topLeftView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.view.safeAreaLayoutGuide attribute:NSLayoutAttributeTop multiplier:1.0 constant:0];
        } else {
            [NSLayoutConstraint constraintWithItem:self.topLeftView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeTop multiplier:1.0 constant:0];
        }
    
    // 使用NSLayoutConstraint-SSLayout
    self.topLeftView.top_attr = self.view.top_attr_safe
    

    在业务代码里不会出现任何版本判断,大家有兴趣的话可以试一下,哈哈
    传送门:http://www.jianshu.com/p/c044f3de564a

    相关文章

      网友评论

      • CoderJackySong:[self.view addSubview:self.tableView];
        __weak typeof(self)weakSelf = self;
        [self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.top.left.right.mas_equalTo(0);
        if (@available(iOS 11.0,*)) {
        make.bottom.equalTo(weakSelf.view.mas_safeAreaLayoutGuideBottom);
        }else{
        make.bottom.mas_equalTo(0);
        }
        }];
        在iPhonex及以上机型上,加上mas_safeAreaLayoutGuideBottom约束的后,跳转到这些代码对应的界面时,出现底部有动画过渡的情况,这个有解决的方法没?
      • 说干就干:为何要自定义导航栏啊,系统的不适合么,不自定义完全没问题呢
        glView:自定义导航栏有时也是需求所决定的,系统的灵活度低
        fruitymoon:自定义更灵活,看你具体需求了
      • 小草先生:那之后就在viewDidLoad创建控件, viewDidLayoutSubviews设置frame和mansory? viewDidLayoutSubviews 一个界面会调用多次,需要设置个变量判断是否设置了布局?
      • weizhijun:我试了一下。不行呀,还是我写错了
      • Code_Frank:楼主没有回答xib如何在不同机型上如何做
      • 经文纬武:请问为甚我的 iponeX 横屏情况下,导航条的高度变成34了。状态栏也怎么都显示不出来
        fruitymoon:@花下眠 横屏状态栏默认是不显示的,如果是系统的导航条可能他自己做了调整
      • szmichael:sgm_safeAreaInset 这个函数/block定义在哪里呢?怎么写的?
        fruitymoon:文中有写啊
      • 经文纬武:请问我用iponex 横屏时,导航条的高度怎么变成32了,导航栏上的按钮也被截取了一部分。
        fruitymoon:@花下眠 系统导航栏的高度应该是44啊
        经文纬武:@fruitymoon 没有啊,我说的是导航条的高度,不是距离顶部的距离。
        fruitymoon:@花下眠 横屏默认没有状态栏,safearea.top=0,32是你自己设置的高度吧?
      • __Feng:有用,赞一个:+1:
      • 熊猫丶Panda:加上导航栏怎么是88
      • YongjieBookQQ:你好,我在iOS 11,iPhone X的模拟器上怎么读到的都是0啊?
        UIEdgeInsets safeAreaInsets = self.view.safeAreaInsets;
        (UIEdgeInsets) safeAreaInsets = (top = 0, left = 0, bottom = 0, right = 0)
        Liusr:找到读取时机了吗 去也是为0 啊
        fruitymoon:你的view没有充满整个屏幕吧?或者是读取的时机不对
      • 咖啡绿茶1991:如果是用xib描述的view,安全区域该如何设置
      • 8dad18eb265c:用心,好文👍🏻👍🏻👍🏻
      • 梁森的简书:问题:
        1.如果使用了自定义的navigationBar,一个页面上的UITableView的底部要怎么适配?(非首页页面距离底部会有一个34的间距)。
        2.即使有安全区域,当一个UITableView没有滑动到底部,底部的那个按钮也是不能点击的。
      • e4f952e97186:有个疑惑 如果是frame布局 这么说基本要用到safeAreaInsets这个属性的view 都要在viewDidLayoutSubviews那里重新设置一下frame,可以这么理解么
        e4f952e97186:@fruitymoon 嗯嗯.
        fruitymoon:只有那些屏幕边缘附近,超出父view的safeArea的view需要重设safeAreaInset
      • d3571be692c3:自定义的导航高度计算出来后 那么导航条下面如果是tableview的话,tableview的frame也得根据导航条高度在viewDidLayoutSubviews里设置了吧?
        fruitymoon:@ChristDing 如果是frame布局要设置,autolayout就不用
      • 猴子的救兵520:请问,使用Xib布局的,如何兼顾iOS11和非iOS11的情况呢?
      • 小嗡嗡sky:楼主,我遇到了一个问题,iphoneX下横屏状态,状态栏不显示。这个要怎么适配呢?
        fruitymoon:@小嗡嗡sky iphoneX的statusBar结构变了,目前好像是个bug。如果一定要显示,可以[[[UIApplication sharedApplication] valueForKeyPath:@"statusBar"] setHidden:NO]
        小嗡嗡sky:@fruitymoon 这个在iphoneX 横屏是无效的。你可以试一下。其他机型都ok。就iphoneX不行
        fruitymoon:重写prefersStatusBarHidden,返回NO
      • 向阳花开ban:从“rootVC” push 到 “pushVC”控制台输出的调用时序以及对应控制器的view的safeAreaInsets

        这段调试的打印楼主是怎么做的:blush:
        fruitymoon:我是在单元测试里mock了UIViewController,重写了那几个方法,print一下
      • 毛毛可:我想问一下 我测试的时候 在 iOS11中UIViewController的viewSafeAreaInsetsDidChange 方法 并不是每次都能回调 请问是什么原因
        fruitymoon:@毛毛可 viewwillappear后可能会调,也可能不会,如果不是iphone X,safeAreaInsets可能没有变化,就不会调
        毛毛可:@fruitymoon 它不是作为view的生命周期方法 在viewwillappear之后回调的吗?
        fruitymoon:每次是什么意思,只有controller的view的safeAreaInsets改变时才会调用viewSafeAreaInsetsDidChange
      • 谈Xx:不是很理解在导航栏适配上,为什么选择增加高度。 用一开始说的safeAreaLayoutGuide约束 会导致在和状态栏之间多增加一块区域吗。 那如果增加高度,同时也要确保导航栏内的元素,都是以导航栏下边界为约束布局的吧,不然单单增加高度也不够,是这样吗
        谈Xx:@fruitymoon 我都试试,理解理解, 以前的导航栏内的元素都是系统布局的。 意思是现在可以自己布局了
        fruitymoon:其实还有种办法,导航栏内的元素也可以相对导航栏的safeAreaLayoutGuide做布局,因为导航栏本身不在父view的safeArea里面,所以导航栏的safeAreaInsets不为零。
        fruitymoon:会的,你可以试一下。
        仅增加高度确实不够,有两种办法,一种是以下边界做布局的,或者改一下约束优先级,下边界约束的优先级高于上边界约束,怎么方便怎么来
      • 0c97ee9afff6:我也好希望楼主能有一个demo就好啦。谢谢
        0c97ee9afff6:@fruitymoon 已经解决啦 哈哈 感谢回复
        fruitymoon:demo也没啥可写的啊,主要是结合自己项目,要不把某些约束改成相对safeAreaLayoutGuide,或者通过safeAreaInsets增加高度宽度之类的。在iphone x模拟器上试着写一下马上就能看到区别了
      • DesmondDAI:很棒的分享!请问调用时序是怎样输出的?感觉很齐整
        DesmondDAI:@fruitymoon 噢明白,谢谢:wink:
        fruitymoon:我是在单元测试里mock了UIViewController,重写了那几个方法,print一下:grin:
      • 大脸猫xiao3:厉害,有个demo就更好了:smile:
      • 简书lu:iPhone X竖屏时占满整个屏幕的控制器的view的safeAreaInsets是(44,0,34,0),横屏是(0,44,21,44),inset后的区域正好是safeAreaLayoutGuide区域

        这个横屏是(0,44,21,44)还是(0,44,21,34);下方的间距21 是什么
        fruitymoon:@简书lu 44,左右永远是对称的
        简书lu:@fruitymoon 是44还是34啊
        fruitymoon:@简书lu 21是下面那条黑杠杠,用来代替home键的
      • 烈人仰起飞:非iPhoneX设备下,也是在iOS11系统下,safeAreaInsets会不会是(0,0,0,0)呢?偷个懒,直接来问了。
        米哆_5066:@fruitymoon 但是在iOS 10 系统 怎么是 (0,0,0,0) 而不是理想的(20,0,0,0),在ios11系统 没有问题 ,在ios10 没有使用导航栏的情况下 ,界面都顶上去了
        烈人仰起飞:@fruitymoon 明了
        fruitymoon:不一定的,safeAreaInsets会把所有系统的ancestor view考虑进去,返回一个inset。比如你当前有UIStatusbar且没有UINavigationbar,就是20,0,0,0;如果有navigationbar就是64,0,0,0

      本文标题:iOS 11 safeArea详解 & iphoneX

      本文链接:https://www.haomeiwen.com/subject/aqqcyxtx.html