唐巧原博客地址:
基于 CoreText 的排版引擎
CoreText是相对来说非常底层的框架,在日常的iOS开发过程中遇到诸如大量文本排版、图文混合排版或者文本链接点击等情况,选择用CoreText去做框架底层还是相当优选的。
这些内容在唐巧的博客中都详细的给出了,有兴趣的朋友可以去唐巧的博客里好好学习一下。我这里要写的是,在学习唐巧关于CoreText的文章时遇到的几个问题,结合原作者的文章,做个自我学习总结。
唐巧关于CoreText的介绍是循序渐进的,先介绍的是纯文本的排版,我也从这开始,从不一样的角度去看 CoreText 纯文本排版。
CoreText 纯文本排版
坐标系
在使用CoreText时需要注意坐标系的不同,在CoreText下坐标系的原点为视图的左下角,x轴向右为正方向,y轴向上为正方向。而我们平时的UIKit坐标系原点则是视图的左上角,x轴向右为正方向,y轴向下为正方向。如图所示:
所以在确定绘制位置时,要注意坐标系的转换,比如下面这个黑色圆点的位置,在两个坐标系中是不一样的
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 的排版引擎原文中的架构,下面描述一下具体思路:
-
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)
,共需要两个参数:CTFrame
和CGContext
。CGContext
是前面第一步获取过的参数,下一步重点要说的就是最重要的参数CTFrame
创建CTFrame
创建 CTFrame
需要两个参数:CTFramesetter
和 CGMutablePath
。
创建 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!
所在的白色区域正是绘制区域
然后调用 CGContextSetTextMatrix(context, CGAffineTransformIdentity);
初始化文本矩阵,并且调用CGContextScaleCTM(context, 1.0, -1.0);
将CoreText坐标系的 y轴 反转,会得到下面的图像
可以看到,其实在反转CoreText坐标系的 y轴 后,图像刚刚好被弄到View外面了,也就是说黑色虚线位置就是View,蓝色区域的实际图像我们是看不到的,所以我们一定要把蓝色区域向上平移一整个View的高度,才会回到原位,如下图
牛刀小试
接下来根据上面的思路写一个小 demo,算是练练手。写这个demo暂不考虑代码结构的优化,优化的代码结构在基于 CoreText 的排版引擎可以找到。完全是为了快速记忆刚刚提到的那些逻辑,用最简单的方式全部回顾一遍。
创建一个继承自UIView的类,用于绘制,取名 GCDisplayView
,源代码如下:
头文件
//
// GCDisplayView.h
//
// Created by 崇 on 2018.
// Copyright © 2018 崇. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface GCDisplayView : UIView
@property (nonatomic, assign) CGFloat textHeight;
@end
实现文件
//
// GCDisplayView.m
//
// Created by 崇 on 2018.
// Copyright © 2018 崇. All rights reserved.
//
#import "GCDisplayView.h"
#import <CoreText/CoreText.h>
@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
把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 的排版引擎
唐巧的原文中将纯文本绘制一直写到支持富文本,而且做了很优雅的架构设计,将数据源就是源字符串和字体相关设置都做成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图文混排。
网友评论