关键词:iOS、引导页、自定义View、气泡、AutoLayout、自动布局、OC、Objective-C、CALayer、CATextLayer、intrinsicContentSize
在上一篇文章 iOS: 引导页 UIScrollView 自动布局(AutoLayout)详解
中介绍了一个开屏引导页的实现,还有一种引导也很常用,就是浮动气泡引导。说白了就是在进入应用界面后为了防止用户一脸懵逼,给关键的按钮啊文字啊,高亮一下,加上一堆小气泡,气泡里再加点文字介绍。这样就能对界面起到一个说明的作用,也能让用户顺着你的思路使用。
气泡引导的关键技术是自定义气泡 View,气泡起到指示说明和承载消息的作用,是由一张图片和一段文字组成的,实现气泡的方法有好几种:
- UIView 组合:直接组合 UILabel 与 UIImageView
- CALayer: 使用 CATextLayer 结合 CALayer 寄宿图
- 单 UILabel:单独使用 UILabel 并使用 CALayer 寄宿图
其中最简单最灵活的实现方式就是第一种组合法,本文以引导气泡功能为例,总结自定义气泡 View (BubbleView)的组合方式的实现方法,并在后面简单介绍和分析一下本人尝试后两种方法遇到的坑😭😭😭。
需求
有三个需要引导的按钮,每一个按钮需要显示一个气泡对功能进行说明,一次只显示一个气泡,每按一次屏幕显示下一个气泡。如图:
基础知识:气泡图片如何合适地拉伸
合适地拉伸
气泡的大小需要适应文字内容,比如只有几个字的时候气泡要紧紧包裹文字不能过大:
文字多的时候就要显示成两行或更多:
直接用一张图行不行?
直接用图会产生整张图片拉伸的效果:
拉伸图片的方法
① 使用 UIImage 提供的拉伸方法:
- (UIImage *)resizableImageWithCapInsets:(UIEdgeInsets)capInsets;
该方法是直接操作 UIImage 的,根据原始的 UIImage 生成一个拉伸过的 UIImage。
参数 UIEdgeInsets capInsets
表示图片四个方向上的固定区域大小,中间区域就是可以拉伸的范围:
可拉伸区域的大小会影响绘制的效率。官方文档中指出,可拉伸区域只有 1x1 的像素大小是效率最高的。文档原文
使用方法 resizableImageWithCapInsets
设置的时候单位是 point,我们知道一个 point 在不同的设备上表示的像素可能不一样,不太方便设置成 1x1 像素。
② 在 Xcode IB 中对图片进行设置:
还可以使用图形化编辑界面,这个功能藏的好深……
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 的四个边对齐 UIImageView 的四个边,表示 BubbleView 要与图片大小相同。
- UIImageView 的四个边对齐 UILabel 的四个边,表示图片大小要与文字相同,这几个约束后面还需要通过代码来设置 UILabel 在整个 BubbleView 中的 padding。
图中被拉伸的气泡是 Xcode IB 的显示问题,即使正确设置了 Slicing 也不能正确地显示,不过不耽误运行效果。
再看一下如何设置 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 @100
和 height = 36 @100
,对 Xcode IB 来说就补上了缺失的宽和高不会再报错,而在运行时会有 BubbleView 内部 UILabel 传递过来的宽和高,这个宽和高的约束优先级就比较有趣了,是内部的 UILabel 的 Content Hugging Priority 和 Content Compression Resistance Priority,他们俩的默认值是 250 和 750,肯定比 100 要优先,因此会忽略设置的这两个 width = 253 @100
和 height = 36 @100
。达到了敷衍 Xcode 又能正确运行的目的。
Content Hugging(CH)与 Content Compression Resistance(CCR)是 UIView 的属性,用来表示当一个 UIView 自己决定自己的大小的时候(比如 UILabel),这个自定义大小在自动布局体系内的优先级。
- Content Hugging 表示不被拉伸的优先级
- Content Compression Resistance 表示不被压缩的优先级
这两个值都有两个维度:水平方向和竖直方向。
如果通过约束计算出来的宽度或高度与自定义的大小有冲突,这时候 CH 和 CCR 就派上用场了。定义:
- 约束计算出来的宽高为
w
、h
- 自定义宽高为
iw
、ih
- 最终结果宽高为
width
、height
- 约束为宽度
X
、高度Y
- CH 宽和高分别为
CH-W
、CH-H
- CCR 宽和高分别为
CCR-W
、CCR-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 上了,这个时候再设置寄宿图,就会将文字覆盖掉……
因此最终也没有采用这个方法。
结论
研究过若干种方法,回头看看组合方式的实现,简单、无坑、可靠,还是用最简单的组合方式吧。