美文网首页iOS新手学习iOS学习专题
iOS: 自定义引导气泡的 N 种实现方法

iOS: 自定义引导气泡的 N 种实现方法

作者: 拾识物者 | 来源:发表于2019-01-20 15:24 被阅读36次

    关键词:iOS、引导页、自定义View、气泡、AutoLayout、自动布局、OC、Objective-C、CALayer、CATextLayer、intrinsicContentSize

    在上一篇文章 iOS: 引导页 UIScrollView 自动布局(AutoLayout)详解
    中介绍了一个开屏引导页的实现,还有一种引导也很常用,就是浮动气泡引导。说白了就是在进入应用界面后为了防止用户一脸懵逼,给关键的按钮啊文字啊,高亮一下,加上一堆小气泡,气泡里再加点文字介绍。这样就能对界面起到一个说明的作用,也能让用户顺着你的思路使用。

    气泡引导的关键技术是自定义气泡 View,气泡起到指示说明和承载消息的作用,是由一张图片和一段文字组成的,实现气泡的方法有好几种:

    • UIView 组合:直接组合 UILabel 与 UIImageView
    • CALayer: 使用 CATextLayer 结合 CALayer 寄宿图
    • 单 UILabel:单独使用 UILabel 并使用 CALayer 寄宿图

    其中最简单最灵活的实现方式就是第一种组合法,本文以引导气泡功能为例,总结自定义气泡 View (BubbleView)的组合方式的实现方法,并在后面简单介绍和分析一下本猫尝试后两种方法遇到的坑😭😭😭。

    需求

    有三个需要引导的按钮,每一个按钮需要显示一个气泡对功能进行说明,一次只显示一个气泡,每按一次屏幕显示下一个气泡。如图:


    气泡引导

    基础知识:气泡图片如何合适地拉伸

    合适地拉伸

    气泡的大小需要适应文字内容,比如只有几个字的时候气泡要紧紧包裹文字不能过大:


    12.png

    文字多的时候就要显示成两行或更多:


    大量文字

    直接用一张图行不行?

    直接用图会产生整张图片拉伸的效果:


    被拉伸的气泡

    拉伸图片的方法

    ① 使用 UIImage 提供的拉伸方法:

    - (UIImage *)resizableImageWithCapInsets:(UIEdgeInsets)capInsets;
    

    该方法是直接操作 UIImage 的,根据原始的 UIImage 生成一个拉伸过的 UIImage。

    参数 UIEdgeInsets capInsets 表示图片四个方向上的固定区域大小,中间区域就是可以拉伸的范围:

    指定拉伸范围

    可拉伸区域的大小会影响绘制的效率。官方文档中指出,可拉伸区域只有 1x1 的像素大小是效率最高的。文档原文

    使用方法 resizableImageWithCapInsets 设置的时候单位是 point,我们知道一个 point 在不同的设备上表示的像素可能不一样,不太方便设置成 1x1 像素。

    ② 在 Xcode IB 中对图片进行设置:


    使用 Xcode 设置

    还可以使用图形化编辑界面,这个功能藏的好深……


    Show Slicing

    Slicing 在属性窗口的最下端,其中填写的数字的单位是像素而不是 point。注意对每个尺寸的图需要进行单独设置,也就是说有几个图就要设置几次。设置的时候麻烦一些,但使用的时候方便,可以直接在 Xcode IB 中设置给需要 UIImage 的属性,也可以直接调用 + (NSImage *)imageNamed:(NSImageName)name; 获取到有拉伸效果的 UIImage,不必再调用 resizableImageWithCapInsets。这个方法设置 1x1 像素拉伸区域比较方便。

    ③ 还有一种更麻烦的方法:使用 CALayer 寄宿图,通过 contentsCenter 属性来设置可拉伸区域,这里就不展开了,可以参考这里:iOS核心动画高级技巧 - contents 属性

    组合 UILabel 与 UIImageView 实现 BubbleView

    自适应的 UILabel 与 BubbleView

    UILabel 的一个重要功能是自适应大小,在自动布局中分为几种情况:

    • 不设置宽度和高度,此时 UILabel 会将文字显示为一行,并且有多宽显示多宽。
    • 设置宽度约束不设置高度约束,此时 UILabel 会满足宽度约束,如果文字太多,宽度超出了显示范围会根据 numberOfLines 属性计算高度,裁剪掉超出的部分,如果没超出或者 numberOfLines = 0 则自动调整高度显示所有文字内容。
    • 同时设置了宽度和高度约束,此时 UILabel 大小固定,内容无法影响大小,如果显示不下内容会截断。

    对气泡来说,指定宽度最大值,不限制高度是比较常见的需求,但最好是什么情况都能支持。

    最重要的文字自适应已经由 UILabel 解决了,只要让 BubbleView 的长宽约束依赖于 UILabel 就能使 BubbleView 获得与 UILabel 同样的自适应能力。

    下面列出 BubbleView 的约束:

    BubbleView 内部约束

    其实就是两批约束:

    • BubbleView 的四个边对齐 UIImageView 的四个边,表示 BubbleView 要与图片大小相同。
    • UIImageView 的四个边对齐 UILabel 的四个边,表示图片大小要与文字相同,这几个约束后面还需要通过代码来设置 UILabel 在整个 BubbleView 中的 padding。

    图中被拉伸的气泡是 Xcode IB 的显示问题,即使正确设置了 Slicing 也不能正确地显示,不过不耽误运行效果。

    再看一下如何设置 BubbleView 的外部约束:


    BubbleView 外部约束

    BubbleView 的位置没有什么影响,可以随意设置,关键在于宽度和高度的约束,图中所示使用了 width <= 253 来指定宽度最大值。但由于 Xcode IB 不知道 BubbleView 能计算自己的大小因此会有红色的错误提示。

    Content Hugging Priority 与 Content Compression Resistance Priority

    这两个特长的东西是个啥玩意,别着急请接着上文继续看。

    自定义 View 想要告知 Xcode IB 自己能计算大小,并在 IB 中实时刷新效果,需要在 interface 声明前加上 IB_DESIGNABLE。一旦自定义 View 有修改,然后回到 xib 文件时就会触发 build 并且刷新 IB 界面,在开发过程中会比较慢和卡,我的 Air 能卡成💩,而且 Xcode IB 中总有一些小问题,不建议在开发自定义 View 的过程中开启这个功能。

    虽然有红色的错误提示,但是不管它最终运行也是正确的,只是看起来不爽……不行,我吴小猫受不了这个委屈,得研究研究怎么解决,这一研究就发现了 Content Hugging Priority 与 Content Compression Resistance Priority 的神奇奥秘。

    设置优先级较低的定值宽高 width = 253 @100height = 36 @100,对 Xcode IB 来说就补上了缺失的宽和高不会再报错,而在运行时会有 BubbleView 内部 UILabel 传递过来的宽和高,这个宽和高的约束优先级就比较有趣了,是内部的 UILabel 的 Content Hugging Priority 和 Content Compression Resistance Priority,他们俩的默认值是 250 和 750,肯定比 100 要优先,因此会忽略设置的这两个 width = 253 @100height = 36 @100。达到了敷衍 Xcode 又能正确运行的目的。

    Content Hugging(CH)与 Content Compression Resistance(CCR)是 UIView 的属性,用来表示当一个 UIView 自己决定自己的大小的时候(比如 UILabel),这个自定义大小在自动布局体系内的优先级。

    • Content Hugging 表示不被拉伸的优先级
    • Content Compression Resistance 表示不被压缩的优先级

    这两个值都有两个维度:水平方向和竖直方向。

    如果通过约束计算出来的宽度或高度与自定义的大小有冲突,这时候 CH 和 CCR 就派上用场了。定义:

    • 约束计算出来的宽高为 wh
    • 自定义宽高为 iwih
    • 最终结果宽高为 widthheight
    • 约束为宽度 X、高度 Y
    • CH 宽和高分别为 CH-WCH-H
    • CCR 宽和高分别为 CCR-WCCR-H
    • 优先级为 .priority

    伪代码如下:

    if (w > iw) width = X.priority > CH-W.priority ? w : iw;
    if (w < iw) width = X.priority > CCR-W.priority ? w : iw;
    if (h > ih) height = Y.priority > CH-H.priority ? h : ih;
    if (h < ih) height = Y.priority > CCR-H.priority ? h : ih;
    

    通常都用两个 UILabel 来实验 CH 和 CCR 的效果,这也是关于 CH 与 CCR 最常见的 case,具体可以参考这篇文章,iOS开发之AutoLayout中的Content Hugging Priority和 Content Compression Resistance Priority解析

    BubbleView 的接口

    做为一个自定义 View,应该提供给使用者怎样的接口呢?BubbleView 是不提供图片资源的,因此需要外部指定图片,同时跟图片有关系的还有一个可选的 UIEdgeInsets 表示图片拉伸信息;另一个显而易见的属性是文字,文字同样也有个 UIEdgeInsets,表示文字在整个 BubbleView 中的 padding;另外还有文字样式的设置。

    @interface BubbleView : UIView
    @property (nonatomic, strong) UIImage *image;
    @property (nonatomic, assign) UIEdgeInsets imageCapInsets;
    @property (nonatomic, copy) NSString* text;
    @property (nonatomic, assign) UIEdgeInsets textEdgeInsets;
    @property (nonatomic, strong) UIFont *font;
    @property (nonatomic, strong) UIColor *textColor;
    @end
    

    使用 CALayer 图层组合实现 BubbleView

    CALayer 是特别强大的,它是 UIKit 图形部分的基础,平常最常用的应该就是设置圆角了吧:view.layer.cornerRadius。它还有许多强大的高级功能,例如上文也提到过 contentsCenter 可以用来拉伸气泡图。实际上用 CALayer 实现的气泡就用到了这个属性。下面来简单分析一下。

    同样是一张图片和一段文字,图片好说,用寄宿图,伸缩也没问题。文字就要用到 CATextLayer 了,这个 CATextLayer 简直就是 UILabel 啊,可以设置字体、颜色、换行行为等等,貌似什么功能都有的。

    但 CATextLayer 这货有一个最大的问题是无法自适应文字来调整自己的大小。CATextLayer 并不是 AutoLayout 体系中的,CATextLayer 的 frame 属性需要明确的手动设置,而不是自己自动设置。

    那么怎么计算一段文字应该占多大的矩形空间呢?比较原始的方法可以用 CoreText。也可以用比较简单的 NSAttributedString 的 boundingRectWithSize:options:context: 方法。由于 CATextLayer 直接支持设置 NSAttributedString 文字,而且这两种方法效果相同,因此就直接使用第二种方式计算。

    虽然理论上很完美,但这个计算还是有点问题,因为 CATextLayer 这货虽然支持 NSAttributedString,但并不是所有的样式都支持,比如行间距就无法设置。无法设置就没办法控制精确的样式,而且你也无法得知 CATextLayer 的默认样式的精确值,因此无法通过 boundingRectWithSize:options:context: 方法来计算出精确的应有尺寸。

    根据经验,行间距大概是 1,但经过本猫的实验,并不精确,可能还要小一点。有些实验计算出来后大小就是不准确,实际绘制的文字区域要比计算出来的矩形区域要大。

    既然没法办精确控制和计算,而且也导致最终气泡效果有些问题,因此这个方法没有应用在实际项目中。

    这个方法本质上相当于实现一个带边距带底图的 UILabel,而且还要能自动计算大小,上问提到了计算文字矩形的方法和问题,但还有另一个问题待解决就是如何与 AutoLayout 系统沟通并最终决定大小。

    首先要看 intrinsicContentSize 这个属性,这是一个只读属性:

    @property(nonatomic, readonly) CGSize intrinsicContentSize;
    

    其实就是一个返回 CGSize 的无参数方法,当自定义 View 需要自己计算大小的时候,要重写这个方法,默认实现是返回 CGSize(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric)UIViewNoIntrinsicMetric 表示没有自定义大小。简单地说,这个方法是用来通知 AutoLayout 系统自己「本来应该有多大」,注意「本来应该有多大」的判定时只能通过自己的属性来判断,而无法得知 AutoLayout 给你留了多大地方。

    就像是父母对孩子说,你要多少压岁钱,虽然父母心中有数,但不告诉孩子啊,孩子只知道自己要一个游戏机,于是说那就 3000 吧,结果父母一翻白眼,给你 300 买个小霸王吧。

    所以在父母只给 300 的前提下如何玩到游戏……那就只好再讨价还价了。

    - (void)layoutSublayersOfLayer:(CALayer *)layer; 执行时可以通过 layer.bounds.size 得知 AutoLayout 到底给你准备了多大的空间,这时可以记录下来备用。通过调用 invalidateIntrinsicContentSize 这个方法通知 AutoLayout 系统重新计算大小,就会重新调用 intrinsicContentSize 方法,这时可以根据之前记录的大小来重新计算,比如第一次 intrinsicContentSize 返回了 CGSize(3000, 40) 但在 layoutSublayersOfLayer 内发现给你分配的大小是 CGSize(300, 40),这个时候按照宽度 200 重新计算文字矩形返回 CGSize(300, 400),这样就计算出了在规定了最大宽度时的文字觉醒。

    孩子说 300 买不了游戏机,每天多玩两个小时平板电脑吧,结果父母一翻白眼,多玩半个小时。

    所以讨价还价一次还是不够,最终大小还得再来一次,看看父母在高度上的容忍底线在哪里……这是一个非常复杂的过程就不继续分析了,有兴趣的可以重写一下 UILabel 的 intrinsicContentSize 方法打个 log 看看会被调用多少次,看到 UILabel 也要调用 n 次才行,本猫就平衡了。

    根本原因还是单方向的沟通造成的,intrinsicContentSize 方法本身并不知道对自己的大小限制是怎样的,必须靠来来回回的问答方式迂回地解决这个问题。熟悉安卓的朋友可以对比一下安卓的做法,安卓的 onMeasure 方法传入的参数就是父控件对子控件的要求,子控件只要在重写的 onMeasure 方法中根据父控件的要求设置自己的大小就可以了,一次搞定不用反复沟通。

    关于 intrinsicContentSize 的具体用法可以参考这篇文章:只有20%的iOS程序员能看懂:详解intrinsicContentSize 及 约束优先级/content Hugging/content Compression Resistance

    单个 UILabel 的实现

    这是个有趣的方式,它的问题更多,但在某些情况下还是正确的,而且它是最简单的一种方案。

    还是通过 CALayer,给 UILabel 的根 CALayer 设置寄宿图表示气泡图片。这个思路貌似可以,经过本猫的一次试验也是可以的。但问题在于显示中文时能正确将气泡铺在文字底部,而显示英文时文字没了。

    真是一个神奇的效果,经过调试分析发现,显示中文时用的是额外的一个 CALayer,这时 UILabel 的根 CALayer 就会显示在额外的 CALayer 之下,达成了气泡成就;显示英文时就直接绘制在根 CALayer 上了,这个时候再设置寄宿图,就会将文字覆盖掉……

    因此最终也没有采用这个方法。

    结论

    研究过若干种方法,回头看看组合方式的实现,简单、无坑、可靠,还是用最简单的组合方式吧。

    相关文章

      网友评论

        本文标题:iOS: 自定义引导气泡的 N 种实现方法

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