深入理解 iOS 图文混排原理并自定义图文控件

阅读数:1286 2019 年 10 月 28 日 18:26

深入理解iOS图文混排原理并自定义图文控件

iOS 开发中一般用 UILabel 来展示文字、UIImageView 用来显示图片、UIButton 用于简单的图文点击响应事件,稍复杂一点的可以借助NSAttributedString来实现图文混排需求,又或者将图文内容转换为 HTML 由 WKWebView(UIWebView)来展示。然而以上方案都有各自的局限性:UILabel 绘制 NSAttributedString 不能灵活定位文本内的点击锚点区域,转换为 HTML 展示则带来 Native 与 Web 端交互成本以及 WKWebView 自身的性能问题。

那么,是否能有一种控件,在满足富文本图文混排的同时还能响应自定义锚点点击事件?要实现以上需求,我们首先从 iOS 图文展示原理说起。

图文绘制架构

iOS7 之后的图文绘制架构如下图所示,越往上封装程度越高,但可定制程度也越低,本文涉及讲解的主要是CoreText以及CoreGraphics层级 。

深入理解iOS图文混排原理并自定义图文控件

CoreGraphics

从下往上说,首先是CoreGraphics。这是一个 C 语言接口的核心图形库,而且它是跨平台的类库,iOS 和 macOS 系统均可使用。虽然它很偏底层,但很多情况下其实你已经使用过它了:比如CGAffineTransform用于形变,CGBitmapContext用于截图或者图片绘制,CGContext用于获取上下文进行直线、曲线、不规则图形绘制等。

这里着重说明下CGContext上下文。上下文类似于进行绘画时候的画布,使用UIGraphicsGetCurrentContext()可以快捷得到当前上下文,同时需要注意在 CoreText 下坐标系的原点为视图的左下角,x 轴向右为正方向,y 轴向上为正方向。而 UIKit 坐标系的原点是视图的左上角,x 轴向右为正方向,y 轴向下为正方向,所以我们在进行图文绘制前需要进行坐标反转,如图所示:

深入理解iOS图文混排原理并自定义图文控件

复制代码
// 获取上下文
CGContextRef c = UIGraphicsGetCurrentContext();
// 将当前图形状态推入堆栈
CGContextSaveGState(c);
// 设置字形变换矩阵为 CGAffineTransformIdentity,也就是说每一个字形都不做图形变换
CGContextSetTextMatrix(c, CGAffineTransformIdentity);
// 坐标转换,UIKit 坐标原点在左上角,CoreText 坐标原点在左下角
CGContextTranslateCTM(c, 0.0f, insetRect.size.height);
CGContextScaleCTM(c, 1.0f, -1.0f);
// TODO: 进行图文绘制操作
//...
// 绘制完成,将堆栈顶部的状态弹出,返回到之前的图形状态
CGContextRestoreGState(c);

以上使用 CoreGraphics 进行图文绘制的过程,可以在drawRect:drawTextInRect:等相关方法中进行操作。

CoreText 框架

CoreText是 iOS 中用于文本绘制的引擎,其位于UIKit中和CoreGraphics/Quartz之间。查看开发文档,可以看到 CoreText 架构主要包含以下类,其中标红部分是图文绘制需要使用到的相关类,我们逐个介绍。

深入理解iOS图文混排原理并自定义图文控件

CTFramesetter

CTFramesetter 是管理生成 CTFrame 的工厂类,其中记录了需要绘制的文本内容中不同字符串对应的富文本属性(加粗、颜色、字号等),通过 NSAttributedString 可生成 CTFrameSetter。

复制代码
NSMutableAttributedString *attributedStr = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes];
// 生成 CTFramesetter
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedStr);
CFRelease(framesetter);

CTFrame

CTFrame 描述了总的文本绘制区域的frame,通过它你可以得到在指定区域内绘制的文本一共有多少行。

复制代码
CGRect rect = CGRectMake(0, 0, 100, 100);
// 生成绘制区域路径
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, rect);
// 生成 CTFrame
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attributedStr length]), path, NULL);
// 获取一共有多少行
CFArrayRef lines = CTFrameGetLines(frame);
CFRelease(frame);
CGPathRelease(path);

CTLine

CTLine 记录了需要绘制的单行文本信息,通过它你可以得到当前行的上行高、下行高以及行间距等信息。

复制代码
// 获取第一行信息
CTLineRef line = CFArrayGetValueAtIndex(lines, 0);
// 上行高、下行高、行间距
CGFloat lineAscent = 0.0f, lineDescent = 0.0f, lineLeading = 0.0f;
// 获取行宽、行高信息
CGFloat lineWidth = CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, &lineLeading);

关于行文本的上下行高、行间距、原点、基线等的说明,可参照下图:

深入理解iOS图文混排原理并自定义图文控件

系统绘制文本的时候,首先会以基线(Baseline)为基准,从当前行的基线最左侧的原点(Origin)开始,计算得到上行高(Ascent),下行高(Descent),不同行之间的行间距(Leading),以及行宽信息。

CTRun

CTRun 描述了单行文本中具有相同富文本属性的字符实体,每一行文字中可能有多个 CTRun,也有可能只包含一个 CTRun。如下图,这行文字中包含三个 CTRun,分别为:这是 一段 测试数据

深入理解iOS图文混排原理并自定义图文控件

与 CTLine 一样,同样可以计算得到单个 CTRun 的绘制区域大小。

复制代码
// 初始化 CTRun 的区域大小为 CGRectZero
CGRect runBounds = CGRectZero;
// 初始化 CTRun 的上行高、下行高、行间距
CGFloat runAscent = 0.0f, runDescent = 0.0f, runLeading = 0.0f;
// 计算得到上下行高、行间距以及 CTRun 绘制区域宽度
runBounds.size.width = (CGFloat)CTRunGetTypographicBounds(glyphRun, CFRangeMake(0, 0), &runAscent, &runDescent, &runLeading);
// 计算高度,注意下行高为负数的情况
CGFloat runHeight = runAscent + fabs(runDescent);
runBounds.size.height = runHeight;

CTRunDelegate

CTRunDelegate 用于图文混排时候的图片绘制,因为 CoreText 本身并不能进行图文混排,但是可以使用 CTRunDelegate 在需要显示图片的地方添加占位符,当 CoreText 绘制到该位置的时候,会触发 CTRunDelegate 代理,在代理方法中可以获取到该区域的大小以及图片信息,然后调用 CGContextDrawImage(c, runBounds, image.CGImage) 绘制图片即可。

复制代码
NSDictionary *imgInfoDic = @{kCJImage:image,// 需要绘制的图片
kCJImageWidth:@(size.width),// 需要绘制的图片区域宽度
kCJImageHeight:@(size.height),// 需要绘制的图片区域高度};
// 创建 CTRunDelegateRef 并设置回调函数
CTRunDelegateCallbacks imageCallbacks;
imageCallbacks.version = kCTRunDelegateVersion1;
imageCallbacks.dealloc = RunDelegateDeallocCallback;
imageCallbacks.getWidth = RunDelegateGetWidthCallback;// 图片区域宽度回调
imageCallbacks.getAscent = RunDelegateGetAscentCallback;// 图片区域上行高回调
imageCallbacks.getDescent = RunDelegateGetDescentCallback;// 图片区域下行高回调
CTRunDelegateRef runDelegate = CTRunDelegateCreate(&imageCallbacks, (__bridge void *)imgInfoDic);
// 初始化空白占位字符
unichar imgReplacementChar = 0xFFFC;
NSString *imgReplacementString = [NSString stringWithCharacters:&imgReplacementChar length:1];
// 插入图片 空白占位符
NSMutableString *imgPlaceholderStr = [[NSMutableString alloc]initWithCapacity:3];
[imgPlaceholderStr appendString:imgReplacementString];
NSRange imgRange = NSMakeRange(0, imgPlaceholderStr.length);
NSMutableAttributedString *imageAttributedString = [[NSMutableAttributedString alloc] initWithString:imgPlaceholderStr];
// 将 CTRunDelegate 记录到 NSMutableAttributedString 的富文本信息中
[imageAttributedString addAttribute:(NSString *)kCTRunDelegateAttributeName value:(__bridge id)runDelegate range:imgRange];
//kCJImageAttributeName 为自定义的记录图片信息的富文本属性
[imageAttributedString addAttribute:kCJImageAttributeName value:imgInfoDic range:imgRange];

看完上面的概念介绍,相信你已经对 iOS 的图文绘制原理有了基础的认识,以上各个类的关联关系如图所示:

深入理解iOS图文混排原理并自定义图文控件

完整的绘制流程如下:

深入理解iOS图文混排原理并自定义图文控件

自定义图文混排控件

自定义图文混排控件,可以基于 UILabel 来实现,UILabel 本身已支持 NSAttributedString 富文本展示,我们只需在原有基础上扩展指定字符区域背景色、插入图片(或自定义 view)展示、指定锚点点击响应事件、点击时候的字符高亮展示等功能即可。

绘制关键点说明

首先在设置 NSAttributedString 富文本属性的时候,增加自定义属性。NSAttributedString 的富文本属性 Attributes 其实就是字典,那么可以在其中添加自定义的 key-value 配置,类似以下说明:

复制代码
/**
删除线宽度。值为 NSNumber。默认 `0.0f`,表示无删除线
*/
extern NSString * const kCJStrikethroughStyleAttributeName;
/**
删除线颜色。值为 UIColor。默认 `[UIColor blackColor]`。
*/
extern NSString * const kCJStrikethroughColorAttributeName;
/**
对 NSAttributedString 文本设置锚点属性时候的唯一标识
*/
extern NSString * const kCJLinkStringIdentifierAttributesName;

第二步就是在遍历获取 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:方法,在其中判断是否需要响应点击事件,否则将响应事件向下传递。

复制代码
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
// ![self linkAtPoint:point extendsLinkTouchArea:NO] 表示不在锚点点击范围内
if (![self linkAtPoint:point extendsLinkTouchArea:NO] || !self.userInteractionEnabled || self.hidden || self.alpha < 0.01) {
// 如果支持选择复制
if (self.enableCopy) {
return [super hitTest:point withEvent:event];
}else{
return nil;
}
}
return self;
}

至于如何判断是否在锚点点击范围内,可参照linkAtPoint: extendsLinkTouchArea:伪函数说明:

复制代码
- (CJGlyphRunStrokeItem *)linkAtPoint:(CGPoint)point extendsLinkTouchArea:(BOOL)extendsLinkTouchArea {
// CJGlyphRunStrokeItem 对应 CTRun,其中记录了字符区域(bounds)的大小
CJGlyphRunStrokeItem *resultItem = nil;
// _linkStrokeItemArray 表示记录了所有锚点信息的数组
for (CJGlyphRunStrokeItem *item in _linkStrokeItemArray) {
if (CGRectContainsPoint(item.bounds, point)) {
resultItem = item;
}
}
return resultItem;
}

重写 touches 系列方法,首先在touchesBegan: withEvent:中判断是否点击了锚点区域,如果是则触发重绘以达到点击高亮效果。

复制代码
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch *touch = [touches anyObject];
CJGlyphRunStrokeItem *item = [self linkAtPoint:[touch locationInView:self] extendsLinkTouchArea:self.extendsLinkTouchArea];
// 点击锚点存在
if (item) {
//TODO: 触发重绘操作,达到点击锚点高亮效果
}else{
[super touchesBegan:touches withEvent:event];
}
}

然后在touchesEnded: withEvent:中处理点击事件的响应操作

复制代码
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
// 如果是长按点击,交由长按点击手势 UILongPressGestureRecognizer 响应
if (_longPress) {
[super touchesEnded:touches withEvent:event];
}else{
if (_currentClickRunStrokeItem) {
// 如果当前是点击锚点事件,抛出点击回调事件
if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didClickLink:)]) {
[self.delegate CJLable:self didClickLink:linkModel];
}
// TODO: 再次重绘,还原点击前的文本显示
}
else {
[super touchesEnded:touches withEvent:event];
}
}
}

这里需要注意一下,如果是双击点击事件或者长按点击事件,那么在 touches 系列的回调方法中是不能处理的,交互处理应该放到UITapGestureRecognizerUILongPressGestureRecognizer的响应方法中判断。另外手势事件的响应方法中是无法得到当前点击位置的点坐标CGPoint的,这里用到了比较取巧的方式(通过 Rumtime 关联属性)达到了判断点击响应的效果。

复制代码
// 在此时刚接收到手势事件的回调中,将 UITouch 关联到 UIGestureRecognizer 实例
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
if (gestureRecognizer == self.longPressGestureRecognizer) {
objc_setAssociatedObject(self.longPressGestureRecognizer, &kAssociatedUITouchKey, touch, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
else if (gestureRecognizer == self.doubleTapGes) {
objc_setAssociatedObject(self.doubleTapGes, &kAssociatedUITouchKey, touch, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
return YES;
}
// 双击点击事件
- (void)tapTwoAct:(UITapGestureRecognizer *)sender {
// Runtime 属性关联,获取到对应的 UITouch,并计算得到点击坐标:[touch locationInView:self]
UITouch *touch = objc_getAssociatedObject(self.doubleTapGes, &kAssociatedUITouchKey);
CJGlyphRunStrokeItem *item = [self linkAtPoint:[touch locationInView:self] extendsLinkTouchArea:self.extendsLinkTouchArea];
if (item) {
//TODO: 如果当前是双击点击锚点事件,抛出点击回调事件
if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didClickLink:)]) {
[self.delegate CJLable:self didClickLink:linkModel];
}
// TODO: 再次重绘,还原点击前的文本显示
}
// 如果不是点击锚点且开启了选择复制功能
else {
if (self.enableCopy) {
//TODO: 显示选择复制提示视图
}
}
}
// 长按点击事件
- (void)longPressGestureDidFire:(UILongPressGestureRecognizer *)sender {
// Runtime 属性关联,获取到对应的 UITouch,并计算得到点击坐标:[touch locationInView:self]
UITouch *touch = objc_getAssociatedObject(self.longPressGestureRecognizer, &kAssociatedUITouchKey);
CGPoint point = [touch locationInView:self];
BOOL isLinkItem = [self containslinkAtPoint:[touch locationInView:self]];
switch (sender.state) {
//TODO: 如果当前是长按锚点事件,抛出长按回调事件
case UIGestureRecognizerStateBegan: {
if (isLinkItem) {
if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didLongPressLink:)]) {
[self.delegate CJLable:self didLongPressLink:linkModel];
}
}
// 如果不是点击锚点且开启了选择复制功能
else {
if (self.enableCopy) {
//TODO: 长按显示放大镜
}
}
}
break;
}
case UIGestureRecognizerStateEnded:{
//TODO: 再次重绘,还原长按前的文本显示
// 如果支持复制
if (self.enableCopy) {
//TODO: 显示选择复制提示视图
}
break;
}
case UIGestureRecognizerStateChanged:
// 如果支持复制
if (self.enableCopy) {
//TODO: 更新放大镜位置,以及更新选择复制区域
}
break;
default:
break;
}
}

来看一下自定义点击控件的效果图

深入理解iOS图文混排原理并自定义图文控件

选择、复制

自定义图文混排控件还可以支持选择复制功能,当然这里说的选择复制不是指点击唤起UIMenuController菜单,然后出现复制剪切选项,点击则只能复制所有文本。那样的例子网上已经有很多,没有必要在这里再大费周章地罗列说明。 其需要具备的是类似于 UITextView 或 UIWebView 那样双击或长按,可出现拷贝、选择、全选选项,同时选中字符左右出现指示大头针,拖动则有放大镜提示当前选中的字符,并且要尽量做到与系统行为一致。

深入理解iOS图文混排原理并自定义图文控件

需求细化后选择复制的要点主要包含以下:

  • 选中字符后出现拷贝、选择 全选菜单,这个使用系统的UIMenuController功能即可实现,不存在难点问题。
  • 对于选中的文字,起始要有大头针标识,中间填充浅蓝色背景,而且这一部分区域会是一块不规则多边形。系统没有提供现成可复用的对应 UI 控件,但只要我们能够判断到选中区域,就能在左右画上大头针,中间填充颜色,所以这一块也不存在问题。
  • 拖动选择的过程中,出现放大镜来提示选中字符的更改。在能够准确获取到当前触摸点坐标的前提下,只需要将触摸点周围区域的图层截取并作CGContextScaleCTM缩放,然后再将放大后的图层显示出来即可,所以这个也是可以实现的。
  • 最后便是重点了,如何判断每一个字符对应的frame坐标位置,并要求在手指移动时能够准确判断选择区域的变化。

前面已经讲到,单行文本中具有相同富文本属性的字符会被绘制到同一个CTRun中,而通过CTRun可以计算得到它的frame大小。那么重点则变成如何使得每一个字符(图片或插入 UIView)对应一个CTRun

解决很简单:只要保证 NSAttributedString 中每一个字符的 Attributes 属性不一样就可以了。我开始的做法是添加一个自定义属性kCJIndexAttributesName,然后给每个字符存储不同的 index 值,并且在全部图文遍历绘制过一次后将kCJIndexAttributesName移除,这样在后续的重绘中就会减少CTRun的拆分数量,提高了效率。

然而,理想很美好,现实很打击。就算自定义属性kCJIndexAttributesName移除了,可CTRun还是会被拆分为单个字符,但是如果使用系统自带的属性则不会如此。无奈只能从系统方法中寻找解决思路,幸好发现了NSLinkAttributeName属性,这是 UITextView 中用来设置 http 链接的扩展属性,存储的对象是NSURLNSString类型,而 UILabel 默认是不支持 http 链点的,使用NSLinkAttributeName属性可以最大限度的降低 UILabel 对默认 NSAttributedString 展示的影响。同时为了更好的判断计算,我将存储的对象改为 NSURL 的子类CJCTRunUrl

复制代码
// 给每一个字符设置 index 值,enableCopy=YES 时生效
__block NSInteger index = 0;
[attText.string enumerateSubstringsInRange:NSMakeRange(0, [attText length]) options:NSStringEnumerationByComposedCharacterSequences usingBlock:
^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
CJCTRunUrl *runUrl = nil;
if (!runUrl) {
NSString *urlStr = [NSString stringWithFormat:@"https://www.CJLabel%@",@(index)];
runUrl = [CJCTRunUrl URLWithString:urlStr];
}
runUrl.index = index;
runUrl.rangeValue = [NSValue valueWithRange:substringRange];
[attText addAttribute:NSLinkAttributeName
value:runUrl
range:substringRange];
index++;
}];

选择复制视图展示的交互逻辑则在双击或长按手势中实现,前面 点击响应 的伪代码示意中已经说明。另外讲解一下选择复制视图的实现细节:

其中的拷贝、选择、全选菜单使用系统提供的UIMenuController实现,在双击或长按时只要将它显示到手指点击对应的位置上就行。

放大镜则是自定义 UIView,并在上面添加一个CALayer,再在CALayer上根据更新的点坐标做放大效果,CALayer的放大境处理逻辑如下:

复制代码
- (void)drawInContext:(CGContextRef)ctx {
CGContextTranslateCTM(ctx, self.frame.size.width/2, self.frame.size.height/2);
CGContextScaleCTM(ctx, 1.40, 1.40);
//self.pointToMagnify 是更新的放大点坐标
CGContextTranslateCTM(ctx, -1 * self.pointToMagnify.x, -1 * self.pointToMagnify.y);
[CJkeyWindow().layer renderInContext:ctx];
CJkeyWindow().layer.contents = (id)nil;
}

大头针包含的提示用户选中区域,同样由自定义 UIView 实现,在自定义 view 上面填充颜色以及在起始结束位置画出大头针,其中的填充颜色区域包含三部分headRect middleRect tailRect

深入理解iOS图文混排原理并自定义图文控件

这三部分存在任意组合的情况,填充颜色的时候要对这三部分区分开来分别进行填充。因为有可能存在只有headRect middleRect 或只有middleRect tailRect,又或者只有 middleRect 的情况,而且填充颜色使用的是CoreGraphics中的绘图 API:

复制代码
CGContextRef ctx = UIGraphicsGetCurrentContext();
// 填充的背景色
UIColor *backColor = CJUIRGBColor(0,84,166,0.2);
[backColor set];
CGContextAddRect(ctx, self.headRect);
CGContextStrokePath(ctx);

接下来便是如何显示这三个选择复制相关的视图了,一开始我只是简单的将它们添加到Label上面来统一管理,但这样会存在一个问题。那就是当页面中存在多个Label,并且对每个Label分别执行选择复制操作时,那么不同的label上都会出现选择复制视图,这是与系统的默认行为是不一致的。权衡之后将以上三个视图的显示作为单例处理,全局只初始化一次,避免了重复初始化的开销。并且将它添加到UIWindow层,这样在不同的Label之间进行选择复制时,也只会显示一个选择复制视图。

写在最后

以上便是 iOS 图文绘制原理以及自定义图文控件的说明,关键点是充分理解你看到的每一个字符在底层绘制显示的时候与CTFrame CTLine CTRun等实体类的对应关系,并借助其计算得到每一个字符的区域frame信息,有了区域frame信息便能够扩展实现各种自定义功能(点击响应、插入图片、选择拷贝等)。

全文完,更多的实现可以查看源码 CJLabel

作者简介:

lele8446 ,iOS 开发深耕者,爱好分享、深入探讨有温度的内容, GitHub 地址。

评论

发布
用户头像
写的不错
2019 年 11 月 05 日 13:37
回复
用户头像
厉害了!!
2019 年 10 月 29 日 19:28
回复
多谢波哥
2019 年 10 月 29 日 21:51
回复
没有更多了