CoreText 学习笔记(上)

唐巧原博客地址:
基于 CoreText 的排版引擎

CoreText 学习笔记(上)_第1张图片

CoreText是相对来说非常底层的框架,在日常的iOS开发过程中遇到诸如大量文本排版、图文混合排版或者文本链接点击等情况,选择用CoreText去做框架底层还是相当优选的。

这些内容在唐巧的博客中都详细的给出了,有兴趣的朋友可以去唐巧的博客里好好学习一下。我这里要写的是,在学习唐巧关于CoreText的文章时遇到的几个问题,结合原作者的文章,做个自我学习总结。

唐巧关于CoreText的介绍是循序渐进的,先介绍的是纯文本的排版,我也从这开始,从不一样的角度去看 CoreText 纯文本排版。

CoreText 纯文本排版

坐标系

在使用CoreText时需要注意坐标系的不同,在CoreText下坐标系的原点为视图的左下角,x轴向右为正方向,y轴向上为正方向。而我们平时的UIKit坐标系原点则是视图的左上角,x轴向右为正方向,y轴向下为正方向。如图所示:


CoreText 学习笔记(上)_第2张图片

所以在确定绘制位置时,要注意坐标系的转换,比如下面这个黑色圆点的位置,在两个坐标系中是不一样的
CoreText 学习笔记(上)_第3张图片

CoreText使用的整体流程

首先,使用CoreText绘制纯文本是在UIView中,整个调用流程的入口是UIView的 drawRect 方法,每次创建一个新的UIView系统都会给你预先写好的那部分代码

/*
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect {
    // Drawing code
}
*/

接下来就是在 drawRect 方法中实现绘制的代码了,总体流程结构如图:

CoreText 学习笔记(上)_第4张图片

图里面总结了基于 CoreText 的排版引擎原文中的架构,下面描述一下具体思路:

  • CoreText排版的入口是 drawRect 方法,所有绘制的代码都要从这里开始

  • 首先第一步要在 drawRect 方法中获取绘制上下文

    CGContextRef context = UIGraphicsGetCurrentContext();
    
  • 第二步要反转坐标系

    CGContextSetTextMatrix(context, CGAffineTransformIdentity);
    CGContextTranslateCTM(context, 0, self.bounds.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    
    • CGContextSetTextMatrix(context, CGAffineTransformIdentity);是初始化文本矩阵 Text Matrix,在绘制之前一定记得初始化文本矩阵 Text Matrix,否则,结果将是不可预测的,就像使用非初始化内存一样

    • CGContextTranslateCTM(context, 0, self.bounds.size.height);向上平移一个View高度

    • CGContextScaleCTM(context, 1.0, -1.0);将CoreText坐标系的 y轴 反转

  • 第三步要在绘制之前要计算出绘制区域的总高度,计算高度可以在下一步创建CTFrame时根据其参数 CTFramesetter 获得

  • 最后第四步要调用 CTFrameDraw() 函数进行绘制,完整的函数描述为 CTFrameDraw(CTFrameRef _Nonnull frame, CGContextRef _Nonnull context),共需要两个参数:CTFrameCGContextCGContext 是前面第一步获取过的参数,下一步重点要说的就是最重要的参数 CTFrame


创建CTFrame

创建 CTFrame 需要两个参数:CTFramesetterCGMutablePath

创建 CTFramesetter 需要富文本字符串(NSAttributedString),这个富文本字符串可以根据我们的需求自行创建所需的文本(NSString)和样式(attributes字典)。

NSDictionary *attributes = @{属性字典};

NSAttributedString *content = [[NSAttributedString alloc] initWithString:@"要显示的文本" attributes:attributes];

CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)content);

这样 CTFramesetter 就创建好了,接下来要用 CTFramesetter 计算出整个绘制区域的高度:

// 获得要绘制的区域的高度
CGSize restrictSize = CGSizeMake(自定义的宽度, CGFLOAT_MAX);
CGSize coreTextSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0,0), nil, restrictSize, nil);
CGFloat textHeight = coreTextSize.height;

绘制区域的总高度就是 textHeight

接下来创建 CGMutablePath,创建 CGMutablePath需要两个参数:自定的宽度和计算好的高度

CGMutablePathRef path = CGPathCreateMutable();

CGPathAddRect(path, NULL, CGRectMake(0, 0, 自定宽度, textHeight));

CGMutablePath也有了,现在可以回头创建 CTFrame

CTFrameRef ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);



有了 CTFrame 后即可以进行 CoreText使用的整体流程 中的第四步:调用 CTFrameDraw() 函数进行绘制。至此绘制纯文本的架构思路全部介绍完。

CTFrameDraw(ctFrame, context);


问题:反转坐标系为什么要向上平移一个View高度?

我画了几张示意图,来说说为什么要平移。

  • 黄色坐标表示CoreText坐标系
  • 红色坐标表示UIKit坐标系
  • 灰色区域是手机屏幕
  • 蓝色区域是自定义的View,就是我们用来绘制的View
  • 文本 Hello World! 所在的白色区域正是绘制区域

首先不反转坐标系的时候,绘制出来的图像是倒转的。
CoreText 学习笔记(上)_第5张图片

然后调用 CGContextSetTextMatrix(context, CGAffineTransformIdentity);初始化文本矩阵,并且调用CGContextScaleCTM(context, 1.0, -1.0);将CoreText坐标系的 y轴 反转,会得到下面的图像

CoreText 学习笔记(上)_第6张图片

可以看到,其实在反转CoreText坐标系的 y轴 后,图像刚刚好被弄到View外面了,也就是说黑色虚线位置就是View,蓝色区域的实际图像我们是看不到的,所以我们一定要把蓝色区域向上平移一整个View的高度,才会回到原位,如下图

CoreText 学习笔记(上)_第7张图片


牛刀小试

接下来根据上面的思路写一个小 demo,算是练练手。写这个demo暂不考虑代码结构的优化,优化的代码结构在基于 CoreText 的排版引擎可以找到。完全是为了快速记忆刚刚提到的那些逻辑,用最简单的方式全部回顾一遍。

创建一个继承自UIView的类,用于绘制,取名 GCDisplayView,源代码如下:

头文件

//
//  GCDisplayView.h
//
//  Created by 崇 on 2018.
//  Copyright © 2018 崇. All rights reserved.
//

#import 

@interface GCDisplayView : UIView

@property (nonatomic, assign) CGFloat textHeight;

@end

实现文件

//
//  GCDisplayView.m
//
//  Created by 崇 on 2018.
//  Copyright © 2018 崇. All rights reserved.
//

#import "GCDisplayView.h"
#import 

@interface GCDisplayView()

@property (nonatomic, assign) CTFramesetterRef framesetter;
@property (nonatomic, assign) CTFrameRef ctFrame;

@end

@implementation GCDisplayView

- (instancetype)initWithCoder:(NSCoder *)aDecoder {
    self = [super initWithCoder:aDecoder];
    if (self) {
        // 创建 CTFrame
        [self createCTFrame];
    }
    return self;
}

- (void)drawRect:(CGRect)rect {
    
    // 获取绘制上下文
    CGContextRef context = UIGraphicsGetCurrentContext();
    
    // 初始化文本矩阵
    CGContextSetTextMatrix(context, CGAffineTransformIdentity);
    
    // 平移一个View高度
    CGContextTranslateCTM(context, 0, self.bounds.size.height);
    
    // 反转 y 轴
    CGContextScaleCTM(context, 1.0, -1.0);
    
    // 绘制
    CTFrameDraw(self.ctFrame, context);
    // 释放
    CFRelease(self.ctFrame);
    CFRelease(self.framesetter);
}

- (void)createCTFrame {
    
    /*
     创建 CTFrame 需要两个参数:CTFramesetter 和 CGMutablePath。
     
     先创建 CTFramesetter,利用 CTFramesetter 计算出绘制区域高度后再创建 CGMutablePath。
     
     创建 CTFramesetter 需要先创建 NSAttributedString。
     */
    
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] init];
    
    NSMutableAttributedString *aStr1 = [[NSMutableAttributedString alloc] initWithString:@"创建 CTFrame 需要两个参数:CTFramesetter 和 CGMutablePath。\n" attributes:@{(id)kCTForegroundColorAttributeName : [UIColor redColor]}];
    
    NSMutableAttributedString *aStr2 = [[NSMutableAttributedString alloc] initWithString:@"先创建 CTFramesetter,利用 CTFramesetter 计算出绘制区域高度后再创建 CGMutablePath。\n" attributes:@{(id)kCTForegroundColorAttributeName : [UIColor blueColor]}];
    
    NSMutableAttributedString *aStr3 = [[NSMutableAttributedString alloc] initWithString:@"创建 CTFramesetter 需要先创建NSAttributedString." attributes:@{(id)kCTForegroundColorAttributeName : [UIColor blackColor]}];
    
    [attributedString appendAttributedString:aStr1];
    [attributedString appendAttributedString:aStr2];
    [attributedString appendAttributedString:aStr3];
    
    // 用创建好的 attString 创建 framesetter
    self.framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
    
    // 获得要绘制的区域的高度
    CGSize restrictSize = CGSizeMake(self.bounds.size.width, CGFLOAT_MAX);
    CGSize coreTextSize = CTFramesetterSuggestFrameSizeWithConstraints(self.framesetter, CFRangeMake(0,0), nil, restrictSize, nil);
    self.textHeight = coreTextSize.height;
    
    // 创建 CGMutablePath
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddRect(path, NULL, CGRectMake(0, 0, self.bounds.size.width, self.textHeight));
    
    // 创建 ctFrame
    self.ctFrame = CTFramesetterCreateFrame(self.framesetter, CFRangeMake(0, 0), path, NULL);
    
    CFRelease(path);
}

@end

在ViewController的StoryBoard中拖入一个UIView,让它继承自 GCDisplayView

CoreText 学习笔记(上)_第8张图片

把StoryBoard中的这个View拖入到ViewController中作为属性,设置它的高度

//
//  ViewController.m
//  CoreTextPureText
//
//  Created by 崇 on 2018/11/8.
//  Copyright © 2018 崇. All rights reserved.
//

#import "ViewController.h"
#import "GCDisplayView.h"

@interface ViewController ()
@property (weak, nonatomic) IBOutlet GCDisplayView *disView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 设置高度
    CGRect frame = CGRectMake(self.disView.frame.origin.x, self.disView.frame.origin.y, self.disView.frame.size.width, self.disView.textHeight);
    self.disView.frame = frame;
}


- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}


@end

运行结果如下图

CoreText 学习笔记(上)_第9张图片




总结

如果没有看过唐巧的原文而是先看我这篇文章,那你肯定会迷糊,因为我去掉了很多的细节,写的都是自己学习过后的心得,这些细节我要是搬过来就有点不摇碧莲了,如果想要了解还是请移步 ==> 基于 CoreText 的排版引擎

唐巧的原文中将纯文本绘制一直写到支持富文本,而且做了很优雅的架构设计,将数据源就是源字符串和字体相关设置都做成JSON格式的文件,方便批量操作。

在 基于 CoreText 的排版引擎中写了几个辅助类,主要就是把我写的 demo 中的 - (void)createCTFrame 方法提出去分别实现。其实CoreText绘制只需要有一个CTFrame就足够了,这个CTFrame可以在本类中实现和保存,也可以像唐巧一样提炼出去,做更好的架构。CTFrame谁都不依赖(比如:drawRect 方法或者 context绘制上下文),而我们需要设置的所有文本的属性又都会包含在CTFrame中,所以CTFrame完全可以拿出去,会显得更加灵活。

另外就是要说一下 drawRect 方法,当时在看 基于 CoreText 的排版引擎的时候就有疑问,那就是代码的执行顺序。由于没怎么用过 drawRect 所以去查了一下。它的调用时机很晚,对于本类而言 drawRect 的调用在初始化完成以后,对于使用这个View的controller而言 drawRect 在viewDidLoad之后,快要显示的时候才会调用。所以你大可以放心把 ctFrame 拿出去做各种设置, drawRect 方法不太可能会比你的方法先执行。如果有对 drawRect 执行顺序感兴趣的朋友,可以到网上搜一搜,一大把有关的文章。

后面还会继续介绍CoreText图文混排。

Coming Soon ~~~

你可能感兴趣的:(CoreText 学习笔记(上))