作者:陈浩  贝聊科技移动开发部  iOS 工程师

Core Text 是苹果提供的富文本排版技术,可以定制开发图文混排功能,DTCoreText、Nimbus、YYLabel 等优秀的开源库底层都是基于 Core Text 的封装和扩展。本文将介绍 Core Text 的基本用法,逐步讲解我是如何封装一个 AttributedLabel 的。

本文已发表在个人博客。

文本排版简述

文本排版是根据给定的文本(text)、字体(font)、绘制区域(shape)、行高(line height)等相关属性,生成出字形(glyphs)布局在屏幕绘制区的适当位置。排版的核心就是将字符(characters)转换成字形,将字形排列成行(lines),再将行排成段落(paragraphs)。用代码表达就是下边寥寥几行。

    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attributedString length]), path, NULL);CTFrameDraw(frame, context);
复制代码

这里的主要步骤有:

  1. 创建 Attributed String;
  2. 创建 CTFramesetter,这是 Core Text 排版的核心类,它会贯穿整个排版过程;
  3. 创建 CGPath,即绘制的区域;
  4. 通过 CTFramesetter 和 CGPath 创建 CTFrame,然后可将其绘制在当前的 context 上;
  5. 别忘了调用 CFRelease 释放对象。

在继续深入代码之前,先了解以下几个小概念:

字形与字体

简单说字体就是映射到字符的字形集合,以下就是字符 a (ascii 码为 97)的不同字形:

而同一字体下字形也可能会有所不同,在英文中比较常见的如连字,典型的就是fi中 i 的点常与 f 的钩合并:

接下来说说字体,在开发中我们常说同一字体不同字号,比如 [UIFont systemFontOfSize: 16][UIFont systemFontOfSize: 18],或者同一字体但是加粗显示,又如 [UIFont systemFontOfSize: 16][UIFont boldSystemFontOfSize: 16],又或者斜体,然而这对于系统而言是完全不同的字体。这儿想说明的是:不同字号是不同的字体,粗体相对普通也是不同的字体,而给文本添加下划线却是个例外(下划线是系统额外画的一条装饰线)。

有时我们在开发中也会接触到字体的 Ascent 和 Descent,其实就是在于字形度量(Glyph metrics)打交道:

由上图可知,一个字符最高点到基线的偏移叫做 Ascent,最低点到基线的偏移叫做 Descent,单行的行高 Line Height 由 Ascent、Descent 与 Line Gap 相加得出。

文本的绘制

Core Text 需要使用 CTFramesetter 对文本进行布局,位于上图中最顶端的 CTFramesetter,它要求以 Attributed String 和绘制区域的形状(CGPath)作为入参,来创建 CTFrame(可以不止一个 CTFrame) ,顾名思义,这就是文本布局所在的 frame,确定好绘制区域后,framesetter 就能将段落样式(NSParagraphStyle)的 lineBreakMode、lineSpacing 等属性应用于此。 这里有必要提一下 CTRun,从 CTRun 我们可以获取许多重要的属性,这在开发排版功能的时候非常有用,下面这张图有助于我们了解什么是 CTRun:

这一行文本可以认为是一个 CTLine 对象,由从左往右的顺序依次包含了默认字体样式、加粗字体样式、默认字体样式、小字号蓝色样式、正常字号蓝色样式和默认字体样式共 6 种 Attributed。每一种样式的字符则表示一个 CTRun 对象。

了解了这些概念之后,就可以实现排版功能了。

实现一个简单的 AttributedLabel

进入正题之前,再储备些基础知识。

Core Foundation 内存管理规则

Core Text 使用了 Core Foundation 基于 C 语言的 API,所以需要遵循 Core Foundation 的内存管理规则。

  • 创建方法名中含有 “Create” 或 “Copy”,需要调用 CFRelease 释放内存
    CTFramesetterRef CTFramesetterCreateWithAttributedString(
CFAttributedStringRef string )
复制代码
  • 返回 CF 对象方法名中不含 “Create” 和 “Copy”,无需手动释放内存
  CFStringRef CFAttributedStringGetString(CFAttributedStringRef aStr)
复制代码

明白了这点,就对项目中什么时候该调用 CFRelease,什么时候不该调用做到心中有数了。

关于 __bridge 关键字

  • __bridge 只是声明类型转变,但不做内存管理规则的转变。
  • __bridge_retained 表示指针类型转变的同时,将内存管理由原来的 Objective-C 交给 Core Foundation 处理,即 ARC to MRC。
  • __bridge_transfer 表示内存管理由 Core Foundation 交给 Objective-C,即 MRC to ARC。
关于坐标系

另外,Core Text 最初是设计给 mac 的,它的坐标系是 mac 坐标系(原点在左下角),所以通常需要对坐标进行翻转,这也是下文提及为什么需要翻转的缘由。

    CGContextSetTextMatrix(context, CGAffineTransformIdentity);CGContextTranslateCTM(context, 0, size.height);CGContextScaleCTM(context, 1.0, -1.0);
复制代码
借助下面这张类关系图让我们直奔主题。

1.堪当重任的 CALayer

相对于 UIView,CALayer 通常是比较“轻”的,我们在日常开发中接触 layer 比较多的还是设置 cornerRadius、contents、mask 或者做个动画等,而在这个项目中,依靠 layer 的 - (void)display 方法,让其充当了一个 “桥梁” 的作用。

先来了解下 - (void)display 方法,如文档里所说,layer 会在适当的时候调用该方法来更新 layer 的 contents,但是并不建议直接调用该方法,子类化可以重写该方法,并能直接设置 layer 的 contents。文档的最后一句话大大盘活了自定义的 AttributedLabel,当 AttributedLabel 需要改变 text、frame、font、attributedString…时,AttributedLabel 不用关心具体的绘制,只需告知下 layer 需要 display 即可。由于将 AttributedLabel 的 + (Class)layerClass 返回了子类化的 layer。

    + (Class)layerClass {return [ZPLabelLayer class];}
复制代码

layer 的 delegate 对象就是 AttributedLabel,所以 layer 就能通过它的 delegate 属性获取到 AttributedLabel 的上述属性,进一步调用 Core Text 绘制出新的 contents 进行设置。这是做这个项目时最干净利落的一个地方。

2.文本高亮交互的处理

如果无需处理高亮交互等定制(截断、附件)效果,我们在拿到 NSAttributedString 和 CGPath 即可将文本绘制到 context 上。对于链接而言,虽然我们能通过 NSDataDetector 标记出文本中哪些地方需要高亮显示,但是需求往往要能对链接进行点击跳转,在使用 CTFrameDraw 方法绘制文本时,既不知道高亮过的文本位置,更无法谈及对高亮文本的交互响应了。

幸运的是,Core Text 另外还有个稍微复杂点的绘制方法 CTLineDraw,从名字可以得知它是用来绘制 line 的,感观上要比 CTFrameDraw 的确要精细许多。我们先看看添加高亮功能的实现思路。

  • 能响应点击的回调 block
  • 接受高亮颜色、range、backgroundColor 等

假设上述高亮相关属性都由 AttributedLabel 处理,使用者每次添加高亮不仅要让 AttributedLabel 改变内部文本的 Attributed 属性,考虑到一段文本可能有多处高亮,其本身也还需要维护一个处理高亮的数组。然而对设置高亮来说,这本就是 NSAttributedString 能做到的事,若让 UI 层来处理这些逻辑并不是很好。再者,对于调用者来说,虽然可以将上述属性封装成 model 方便 AttributedLabel 使用,但如果想复用 NSAttributedString 就变得不可能了。看来交由 NSAttributedString 来处理高亮相关属性是最合适不过的了,这里通过创建 NSAttributedString 的 category 和 AssociatedObject 满足了需求。

最终从 NSAttributedString 中获取到高亮的 ranges,再配合 CTLineDraw 绘制行的时候获取到 run (文章前面介绍)的 range,先来看看代码:

    self.ranges = attributedStr.highlightRangeArray;  // 获取 ranges...// 遍历行for (CFIndex lineIndex = 0; lineIndex < numberOfLines; lineIndex++) {CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex);...CFArrayRef runs = CTLineGetGlyphRuns(line);// 遍历行的每一个 runfor (int j = 0; j < CFArrayGetCount(runs); j++) {...CFRange range = CTRunGetStringRange(run);for (NSString *rangeString in self.ranges) {NSRange hightlightRange = NSRangeFromString(rangeString);NSRange lineRange = NSMakeRange(range.location, range.length);// 得到属于高亮的 rangeif (NSIntersectionRange(hightlightRange, lineRange).length > 0) {
复制代码

接下来获取具体的 CGRect,注意在获取 CGRect 时还需将坐标翻转:

    CGAffineTransform transform = CGAffineTransformMakeTranslation(0, contentHeight);transform = CGAffineTransformScale(transform, 1.f, -1.f);CGRect flipRect = CGRectApplyAffineTransform(runRect, transform);// 保存链接的CGRectNSRange nRange = NSMakeRange(range.location, range.length);self.framesDict[NSStringFromRange(nRange)] = [NSValue valueWithCGRect:flipRect];
复制代码

到这已经基本获取到高亮文本的位置,为什么说是基本呢?因为漏了个链接换行的问题,当链接换行显示时,就会产生多个 CTRun 对象,这些 CTRun 对应的 CGRect 都会存在 framesDict 中,当用户点击换行的链接某部分(range)时,它只能响应到 framesDict 中的一个 CGRect,而正确的做法是应该响应某个链接在 framesDict 中的所有 CGRect,只有这样才能完整的高亮出一条链接的所有部分,本质就是要将来自同一条链接的若干 CGRect 关联起来。

说了这么多,实现起来却不困难,这里采用了链接的 range 做为 key,CGRect 的数组做为 value,然后判断用户的 range 在不在链接的 range 中,若属于某条链接的 range,通过链接的 range 取出 CGRect 的数组渲染即可。

3.字符串截断的处理

当 UILabel 显示不全字符串的时候,系统会在文本的最后添加“…”。同样,AttributedLabel 也提供了添加“…”的默认处理,并在此基础上提供了让用户自定义截断内容的功能。这里的实现并不难,直接截取最后一行的文本,再不断倒序删除最后一行的字符直到最后一行能容纳得下 TruncationText 为止。

首先我们还是要调用 CoreText 的 API 获取到最后一行的 range:

    CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [self length]), path, NULL);CFArrayRef lines = CTFrameGetLines(frame);NSInteger numberOfLines = CFArrayGetCount(lines);...NSInteger lastLineIndex = numberOfLines - 1 < 0 ? 0 : numberOfLines - 1;CTLineRef line = CFArrayGetValueAtIndex(lines, lastLineIndex);CFRange lastLineRange = CTLineGetStringRange(line);
复制代码

接着使用最后一行的 range 从 AttributedString 中获取到子文本:

    //截到最后一行NSUInteger truncationAttributePosition = lastLineRange.location + lastLineRange.length;NSMutableAttributedString *cutAttributedString = [[self attributedSubstringFromRange:NSMakeRange(0, truncationAttributePosition)] mutableCopy];NSMutableAttributedString *lastLineAttributeString = [[cutAttributedString attributedSubstringFromRange:NSMakeRange(lastLineRange.location, lastLineRange.length)] mutableCopy];
复制代码

递归调用每次删除子文本最后一个字符的方法:

    - (NSMutableAttributedString *)handleLastLineAttributeString:(NSMutableAttributedString *)attributeString withTruncationText:(NSMutableAttributedString *)truncationText width:(CGFloat)width {CTLineRef truncationToken = CTLineCreateWithAttributedString((CFAttributedStringRef)attributeString);CGFloat lastLineWidth = (CGFloat)CTLineGetTypographicBounds(truncationToken, nil, nil,nil);CFRelease(truncationToken);if (lastLineWidth > width) {NSString *lastLineString = attributeString.string;NSRange r = [lastLineString rangeOfComposedCharacterSequencesForRange:NSMakeRange(lastLineString.length - truncationText.string.length - 1, 1)];[attributeString deleteCharactersInRange:r];return [self handleLastLineAttributeString:attributeString withTruncationText:truncationText width:width];} else {return attributeString;}}
复制代码

之所以递归删除是因为试过一下子截取 truncationText 的长度时会有用 CTLineGetTypographicBounds 计算宽度不准确的问题,不清楚这是否与不同字符的高矮胖瘦有关,如果你有更好的方法,欢迎 pr !!!

4.为字符串添加附件

我最初是想用“…查看更多”截断文本,再剔除“…”后,仅把“查看更多”当作可支持高亮点击的文本,然而在实现过程中大大破坏了下边两个方法的通用性,甚至实现的效果还差强人意。

    - (void)zp_highlightColor:(UIColor *)highlightColor backgroundColor:(UIColor *)backgroundColor highlightRange:(NSRange)highlightRange tapAction:(ZPTapHightlightBlock)tapAction;- (NSMutableAttributedString *)zp_joinWithTruncationText:(NSMutableAttributedString *)truncationText textRect:(CGRect)textRect maximumNumberOfRows:(NSInteger)maximumNumberOfRows;
复制代码

通常实现某个功能感到别扭时,往往都是方法没用对。最终通过查询文档及资料发现 Core Text 竟还有个 CTRunDelegate 的对象,CTRunDelegate 是 CTRun 的 delegate,它可被用来修改布局时的字形信息(glyph metrics), 比如控制字符的 ascent、descent、width 等。换句话说,我们可以“撑开”一个字符到我们想要的高宽,在这个占位字符之上就可以添加自定义的视图(比如 UIButton)。unicode 中恰好有空白字符 \uFFFC 的表示,我们在字符串适当的位置插入空白字符来占位,再获取到空白字符的 CGRect 信息,就可以添加子视图在这之上了。

    static void zp_deallocCallback(void *ref) {ZPTextRunDelegate *delegate = (__bridge_transfer ZPTextRunDelegate *)(ref);delegate = nil;}static CGFloat zp_ascentCallback(void *ref) {ZPTextRunDelegate *delegate = (__bridge ZPTextRunDelegate *)(ref);return delegate.ascent;}static CGFloat zp_descentCallback(void *ref) {ZPTextRunDelegate *delegate = (__bridge ZPTextRunDelegate *)(ref);return delegate.descent;}static CGFloat zp_widthCallback(void *ref) {ZPTextRunDelegate *delegate = (__bridge ZPTextRunDelegate *)(ref);return delegate.width;}...CTRunDelegateCallbacks callbacks;callbacks.version = kCTRunDelegateCurrentVersion;callbacks.dealloc = zp_deallocCallback;callbacks.getAscent = zp_ascentCallback;callbacks.getDescent = zp_descentCallback;callbacks.getWidth = zp_widthCallback;
复制代码

最后要注意的是 CTRunDelegate 需要实现代理的委托,在委托方法中,对象并不遵循 ARC 内存管理,这里封装了 ZPTextRunDelegate 来管理属性,使用 __bridge_transfer 进行内存的转换,避免了内存泄露和过早释放的 bug。获取附件的位置和高亮那块的处理类似,就不再赘述。

总结

本文记录了如何造一个 AttributedLabel 的轮子,相信读者结合代码一起看会发现实现简单的 Core Text 排版功能并不难,而笔者在剥离业务代码、实现通用性、封装工具类上还是遇到不少技术挑战。建议大家在平常开发中能多造点轮子锻炼锻炼技术,也能提高 iOS 技术社区的活力。同时希望大家在用惯了业界标准的 YYText 时,顺带了解下 Core Text 的使用流程。

Github 地址:github.com/hawk0620/PY…

[贝聊科技]如何实现一个 AttributedLabel相关推荐

  1. [贝聊科技]如何将 iOS 项目的编译速度提高5倍

    前言 贝聊目前开发的两款App分别是贝聊家长版和贝聊老师版,最近因为在快速迭代开发新功能,项目规模急速增长,单个端业务代码约23万行,私有库约6万行,第三方库代码约15万行,单个客户端的代码行数约60 ...

  2. [贝聊科技]贝聊 iPhone X 适配实战

    @NewPan 贝聊科技 iOS 菜鸟工程师 这款为天猫定制的 iPhone,你买了吗?由于没摸过真机,所以严格意义上来说,这篇文章应该有一个更加接地气的名字:"模拟器适配实战". ...

  3. [贝聊科技]贝聊 IAP 实战之订单绑定

    大家好,我是贝聊科技 的 iOS 工程师 @NewPan. 注意:文章中讨论的 IAP 是指使用苹果内购购买消耗性的项目. 这次为大家带来我司 IAP 的实现过程详解,鉴于支付功能的重要性以及复杂性, ...

  4. [贝聊科技]iOS 代码架构(一)如何创建一个易复用的组件

    前言 贝聊的移动客户端分别有家长端和老师端,一家公司里同时维护多个业务上有关联性的app这种情况其实很常见,例如一些提供 O2O 服务的公司,经常会分用户端和商家端.这些客户端虽然各自负责着一个业务环 ...

  5. [贝聊科技]网页端「应用跳转」技术实现演变

    本文作者:Mr.Luo ,贝聊前端经理.本文同时发布于作者 个人博客 . 由于网页传播的便捷性,从网页向APP导流几乎是所有APP厂商都会采用的推广手段,具体来说就是在网页上提供一些触发点(例如按钮. ...

  6. [贝聊科技]谈谈 iOS 如何动态切换 APP 的主题

    在移动互联网的下半场,越来越多的 APP 更加注重用户体验,以期来打动用户.主题的切换就是可以增强用户体验.结合运营活动的一个点:譬如 QQ 的夜间模式,节日里电商 APP 的皮肤切换等等的这些小细节 ...

  7. [贝聊科技]如何在iOS开发中更好的做假数据?

    当工期比较紧的时候,项目开发中会经常出现移动端等待后端接口数据的情形,不但耽误项目进度,更让人有种无奈的绝望.所以在开发中,我们常常自己做些假数据,以方便开发和UI调试.然而做假数据方法不同,效率和安 ...

  8. [贝聊科技]有关Android应用桌面角标(BadgeNumber)实现的探讨

    作者:小强 贝聊移动开发部 Android工程师 前言:本文主要讲述了以下三方面: 怎么在Android系统下让自家的应用图标像iOS系统那样支持数字角标的显示? 在网上找不到现成的解决方案的情况下, ...

  9. [贝聊科技]小动画大学问

    对于移动端的Web单页应用来说,为了达到媲美原生应用的效果,页面过渡动画是必不可少的.常用的页面过渡动画包括: 位移--当前页向左侧或右侧水平移出可视区,下一页由反方向移入可视区. 不透明度变化--当 ...

最新文章

  1. ZOJ 1292 Integer Inquiry
  2. 在SAP中如何创建权限和权限的设置
  3. Cookie 与Session 的区别
  4. 线性代数问卷调查反馈——Find The Determinant III,Takahashi‘s Basics in Education and Learning
  5. C++常用排序法、随机数
  6. cvtcolor python opencv_13行代码实现:Python实时视频采集(附源码)
  7. C# 文本操作类 Trim() 和Replace()的用法小例子
  8. 2017.0704.《计算机组成原理》-动态RAM
  9. win7x64 连接oracle 客户端 vs 2010调试 提示“ORA-12154: TNS: 无法解析指定的连接标识符 ”
  10. shopify抓单按时间总是少一些的解决
  11. Ubuntu16.04 安装 indicator-sysmonitor
  12. ByPass UAC
  13. 微软语音识别引擎Speech
  14. 通过java解码web前端直接预览海康威视摄像头
  15. CTFshou wed命令执行29~50
  16. 传中芯国际洽购华虹NEC 双方展开初步谈判
  17. Visual Hull基于序列图像的三维重建
  18. 新型肺炎疫情期间,有哪些服务免费开放?
  19. 太阳能光伏发电基础知识科普教学QY-TF11
  20. GIF录屏软件下载及使用

热门文章

  1. 微信公众号上,如何统计文章的访问量
  2. iOS拓展---【转载】iOS客户端节日换肤方案探究
  3. 麦克斯韦妖再现江湖,熵减成真!曾纠缠物理学家一百多年
  4. 小学计算机教案怎么写,小学信息技术四年级教学计划范文
  5. 事务的7种传播机制和演示
  6. 视频配音怎么弄?这3个给视频加配音的软件告诉你
  7. Java学习笔记——正则表达式(Pattern类、Matcher类和PatternSyntaxException)
  8. 【PYTHON的环境搭建和安装(基于vscode下)】
  9. C# 自动按比例裁剪去除图片色边(一般是黑边或者白边)
  10. qt生成库文件之后自动复制到指定目录