深入理解图文混排原理并自定义图文控件
本文初次发表于InfoQ 深入理解 iOS 图文混排原理并自定义图文控件
iOS开发中一般用UILabel来展示文字、UIImageView用来显示图片、UIButton用于简单的图文点击响应事件,稍复杂一点的可以借助NSAttributedString
来实现图文混排需求,又或者将图文内容转换为HTML由WKWebView(UIWebView)来展示。然而以上方案都有各自的局限性:UILabel绘制NSAttributedString不能灵活定位文本内的点击锚点区域,转换为HTML展示则带来Native与Web端交互成本以及WKWebView自身的性能问题。
那么,是否能有一种控件,在满足富文本图文混排的同时还能响应自定义锚点点击事件?要实现以上需求,我们首先从iOS图文展示原理说起。
图文绘制架构
iOS7之后的图文绘制架构如下图所示,越往上封装程度越高,但可定制程度也越低,本文涉及讲解的主要是CoreText
以及CoreGraphics
层级 。
CoreGraphics
从下往上说,首先是CoreGraphics。这是一个C语言接口的核心图形库,而且它是跨平台的类库,iOS和macOS系统均可使用。虽然它很偏底层,但很多情况下其实你已经使用过它了:比如CGAffineTransform用于形变,CGBitmapContext用于截图或者图片绘制,CGContext用于获取上下文进行直线、曲线、不规则图形绘制等。
这里着重说明下CGContext上下文。上下文类似于进行绘画时候的画布,使用UIGraphicsGetCurrentContext()
可以快捷得到当前上下文,同时需要注意在CoreText下坐标系的原点为视图的左下角,x轴向右为正方向,y轴向上为正方向。而UIKit坐标系的原点是视图的左上角,x轴向右为正方向,y轴向下为正方向,所以我们在进行图文绘制前需要进行坐标反转,如图所示:
1 | //获取上下文 |
以上使用CoreGraphics进行图文绘制的过程,可以在drawRect:
或 drawTextInRect:
等相关方法中进行操作。
CoreText框架
CoreText是iOS中用于文本绘制的引擎,其位于UIKit
中和CoreGraphics/Quartz
之间。查看开发文档,可以看到CoreText架构主要包含以下类,其中标红部分是图文绘制需要使用到的相关类,我们逐个介绍。
CTFramesetter
CTFramesetter是管理生成CTFrame的工厂类,其中记录了需要绘制的文本内容中不同字符串对应的富文本属性(加粗、颜色、字号等),通过NSAttributedString可生成CTFrameSetter。
1 | NSMutableAttributedString *attributedStr = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes]; |
CTFrame
CTFrame描述了总的文本绘制区域的frame
,通过它你可以得到在指定区域内绘制的文本一共有多少行。
1 | CGRect rect = CGRectMake(0, 0, 100, 100); |
CTLine
CTLine记录了需要绘制的单行文本信息,通过它你可以得到当前行的上行高、下行高以及行间距等信息。
1 | //获取第一行信息 |
关于行文本的上下行高、行间距、原点、基线等的说明,可参照下图:
系统绘制文本的时候,首先会以基线(Baseline)为基准,从当前行的基线最左侧的原点(Origin)开始,计算得到上行高(Ascent),下行高(Descent),不同行之间的行间距(Leading),以及行宽信息。
CTRun
CTRun描述了单行文本中具有相同富文本属性的字符实体,每一行文字中可能有多个CTRun,也有可能只包含一个CTRun。如下图,这行文字中包含三个CTRun,分别为:这是
一段
测试数据
与CTLine一样,同样可以计算得到单个CTRun的绘制区域大小。
1 | //初始化CTRun的区域大小为CGRectZero |
CTRunDelegate
CTRunDelegate用于图文混排时候的图片绘制,因为CoreText本身并不能进行图文混排,但是可以使用CTRunDelegate在需要显示图片的地方添加占位符,当CoreText绘制到该位置的时候,会触发CTRunDelegate代理,在代理方法中可以获取到该区域的大小以及图片信息,然后调用 CGContextDrawImage(c, runBounds, image.CGImage)
绘制图片即可。
1 | NSDictionary *imgInfoDic = @{kCJImage:image,//需要绘制的图片 |
看完上面的概念介绍,相信你已经对iOS的图文绘制原理有了基础的认识,以上各个类的关联关系如图所示:
完整的绘制流程如下:
自定义图文混排控件
自定义图文混排控件,可以基于UILabel来实现,UILabel本身已支持NSAttributedString富文本展示,我们只需在原有基础上扩展指定字符区域背景色、插入图片(或自定义view)展示、指定锚点点击响应事件、点击时候的字符高亮展示等功能即可。
绘制关键点说明
首先在设置NSAttributedString富文本属性的时候,增加自定义属性。NSAttributedString的富文本属性Attributes其实就是字典,那么可以在其中添加自定义的key-value配置,类似以下说明:
1 | /** |
第二步就是在遍历获取CTRun时,将属于锚点的CTRun的frame
区域信息记录起来,同时还要记录该锚点对应的扩展参数,以及合并具有相同属性的CTRun。
第三步是绘制字符,如果包含自定义属性,那么需要调用CoreGraphics的相关方法进行扩展属性的设置,比如先填充背景色CGContextSetFillColorWithColor(c,color);
再绘制文字CTRunDraw(runRef, c, CFRangeMake(0, 0));
再添加边框线、删除线CGContextSetStrokeColorWithColor(c,color);
你可以把这个过程想象是成在一张画布上绘画,绘制时候需要注意不同图层的层级关系,不然上面的图层会将下面的图层覆盖。
最后一步是图片展示,如果CTRun是包含CTRunDelegate的显示区域,那么系统会将你设置好大小的区域空白出来,你只需在该位置上画出图片即可:CGContextDrawImage(c, runBounds, image.CGImage);
另外我还在此基础上做了扩展,不单单支持图文混排,还可以在指定区域上插入任意UIView。原理是同样借助CTRunDelegate在对应位置上预留出指定大小的空白区域,然后将需要插入的UIView存储在NSAttributedString的Attributes属性中,当绘制到该位置时只需调用[self addSubview:view];
即可。
点击响应
UILabel继承自UIView和UIResponder,那么可以基于iOS的事件响应链机制来实现锚点点击事件。
重写hitTest: withEvent:
方法,在其中判断是否需要响应点击事件,否则将响应事件向下传递。
1 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { |
至于如何判断是否在锚点点击范围内,可参照linkAtPoint: extendsLinkTouchArea:
伪函数说明:
1 | - (CJGlyphRunStrokeItem *)linkAtPoint:(CGPoint)point extendsLinkTouchArea:(BOOL)extendsLinkTouchArea { |
重写touches系列方法,首先在touchesBegan: withEvent:
中判断是否点击了锚点区域,如果是则触发重绘以达到点击高亮效果。
1 | - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { |
然后在touchesEnded: withEvent:
中处理点击事件的响应操作
1 | - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { |
这里需要注意一下,如果是双击点击事件或者长按点击事件,那么在touches系列的回调方法中是不能处理的,交互处理应该放到UITapGestureRecognizer
和UILongPressGestureRecognizer
的响应方法中判断。另外手势事件的响应方法中是无法得到当前点击位置的点坐标CGPoint
的,这里用到了比较取巧的方式(通过Rumtime关联属性)达到了判断点击响应的效果。
1 | // 在此时刚接收到手势事件的回调中,将UITouch关联到 UIGestureRecognizer实例 |
来看一下自定义点击控件的效果图
选择、复制
自定义图文混排控件还可以支持选择复制功能,当然这里说的选择复制不是指点击唤起UIMenuController
菜单,然后出现复制剪切选项,点击则只能复制所有文本。那样的例子网上已经有很多,没有必要在这里再大费周章地罗列说明。 其需要具备的是类似于UITextView或UIWebView那样双击或长按,可出现拷贝、选择、全选
选项,同时选中字符左右出现指示大头针,拖动则有放大镜提示当前选中的字符,并且要尽量做到与系统行为一致。
需求细化后选择复制的要点主要包含以下:
- 选中字符后出现
拷贝、选择 全选
菜单,这个使用系统的UIMenuController
功能即可实现,不存在难点问题。 - 对于选中的文字,起始要有大头针标识,中间填充浅蓝色背景,而且这一部分区域会是一块不规则多边形。系统没有提供现成可复用的对应UI控件,但只要我们能够判断到选中区域,就能在左右画上大头针,中间填充颜色,所以这一块也不存在问题。
- 拖动选择的过程中,出现放大镜来提示选中字符的更改。在能够准确获取到当前触摸点坐标的前提下,只需要将触摸点周围区域的图层截取并作
CGContextScaleCTM
缩放,然后再将放大后的图层显示出来即可,所以这个也是可以实现的。 - 最后便是重点了,如何判断每一个字符对应的
frame
坐标位置,并要求在手指移动时能够准确判断选择区域的变化。
前面已经讲到,单行文本中具有相同富文本属性的字符会被绘制到同一个CTRun
中,而通过CTRun
可以计算得到它的frame
大小。那么重点则变成如何使得每一个字符(图片或插入UIView)对应一个CTRun
。
解决很简单:只要保证NSAttributedString中每一个字符的Attributes属性不一样就可以了。我开始的做法是添加一个自定义属性kCJIndexAttributesName
,然后给每个字符存储不同的index值,并且在全部图文遍历绘制过一次后将kCJIndexAttributesName
移除,这样在后续的重绘中就会减少CTRun
的拆分数量,提高了效率。
然而,理想很美好,现实很打击。就算自定义属性kCJIndexAttributesName
移除了,可CTRun
还是会被拆分为单个字符,但是如果使用系统自带的属性则不会如此。无奈只能从系统方法中寻找解决思路,幸好发现了NSLinkAttributeName
属性,这是UITextView中用来设置http链接的扩展属性,存储的对象是NSURL
或NSString
类型,而UILabel默认是不支持http链点的,使用NSLinkAttributeName
属性可以最大限度的降低UILabel对默认NSAttributedString展示的影响。同时为了更好的判断计算,我将存储的对象改为NSURL的子类CJCTRunUrl
。
1 | //给每一个字符设置index值,enableCopy=YES时生效 |
选择复制视图展示的交互逻辑则在双击或长按手势中实现,前面 点击响应 的伪代码示意中已经说明。另外讲解一下选择复制视图的实现细节:
其中的拷贝、选择、全选
菜单使用系统提供的UIMenuController
实现,在双击或长按时只要将它显示到手指点击对应的位置上就行。
放大镜则是自定义UIView,并在上面添加一个CALayer
,再在CALayer
上根据更新的点坐标做放大效果,CALayer
的放大境处理逻辑如下:
1 | - (void)drawInContext:(CGContextRef)ctx { |
大头针包含的提示用户选中区域,同样由自定义UIView实现,在自定义view上面填充颜色以及在起始结束位置画出大头针,其中的填充颜色区域包含三部分headRect
middleRect
tailRect
这三部分存在任意组合的情况,填充颜色的时候要对这三部分区分开来分别进行填充。因为有可能存在只有headRect
middleRect
或只有middleRect
tailRect
,又或者只有 middleRect
的情况,而且填充颜色使用的是CoreGraphics
中的绘图API:
1 | CGContextRef ctx = UIGraphicsGetCurrentContext(); |
接下来便是如何显示这三个选择复制相关的视图了,一开始我只是简单的将它们添加到Label
上面来统一管理,但这样会存在一个问题。那就是当页面中存在多个Label
,并且对每个Label
分别执行选择复制操作时,那么不同的label
上都会出现选择复制视图,这是与系统的默认行为是不一致的。权衡之后将以上三个视图的显示作为单例处理,全局只初始化一次,避免了重复初始化的开销。并且将它添加到UIWindow
层,这样在不同的Label
之间进行选择复制时,也只会显示一个选择复制视图。
写在最后
以上便是iOS图文绘制原理以及自定义图文控件的说明,关键点是充分理解你看到的每一个字符在底层绘制显示的时候与CTFrame
CTLine
CTRun
等实体类的对应关系,并借助其计算得到每一个字符的区域frame
信息,有了区域frame
信息便能够扩展实现各种自定义功能(点击响应、插入图片、选择拷贝等)。
全文完,更多的实现可以查看源码CJLabel。