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

关键词: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 中对图片进行设置:


使用 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 上了,这个时候再设置寄宿图,就会将文字覆盖掉……

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

结论

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

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

推荐阅读更多精彩内容

  • 1 CALayer IOS SDK详解之CALayer(一) http://doc.okbase.net/Hell...
    Kevin_Junbaozi阅读 5,118评论 3 23
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • 运动族的宝宝们大家好,很高兴我们族长大人的抬爱,让我作为优秀学员来分享,心里很紧张呀,因为第一次做这样的分享,我想...
    千羽塔罗师阅读 175评论 12 7
  • 一、看书之前 今天和大家分享是一本有关于模仿的故事:Caps for sale。这是一本享誉75年的美国经典童书,...
    叠叠不休阅读 498评论 0 10
  • 可能对于异乡人而言,每个陌生的城市都是迷宫。 还记得今年2月份在天津艺考等公交的时候,一个裹着厚厚黄色外套的小孩子...
    cdacd61a5d3b阅读 235评论 0 0