[贝聊科技]如何实现一个 AttributedLabel

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

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

本文已发表在我的博客数组

文本排版简述

文本排版是根据给定的文本(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 的钩合并:ui

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

有时咱们在开发中也会接触到字体的 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);
        // 遍历行的每个 run
            for (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);
                // 获得属于高亮的 range
                        if (NSIntersectionRange(hightlightRange, lineRange).length > 0) {
复制代码

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

CGAffineTransform transform = CGAffineTransformMakeTranslation(0, contentHeight);
    transform = CGAffineTransformScale(transform, 1.f, -1.f);
    CGRect flipRect = CGRectApplyAffineTransform(runRect, transform);
 
    // 保存连接的CGRect
    NSRange 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…

相关文章
相关标签/搜索