闲来无事想着自己搞个富文本的工具库,不至于每次遇见这些东西就用别人的第三方。自己研究研究也有助于自己对这方面的理解。通过查找了相关的调研发现CoreText是一个好的框架,我们系统的UILabel等控件就是基于此框架封装的。由此我也打算搞搞看
一、CoreText框架基础
从此架构图可以看出,CoreText是我们平时使用的UILabel、UITextField更底层的框架。它是基于Core Graphics的,所以性能上更加的快速。
UIWebView也是我们处理复杂的文字排本的方案,那CoreText和基于UIWebView相比有哪些异同呢?
优势:
- CoreText占用的内存更少,渲染的速度更快,UIWebView占用的内存多,渲染速度更慢。
- CoreText 在渲染界面前就可以精确地获得显示内容的高度(只要有了CTFrame就可以),而UIWebView只有渲染出内容后,才能获得内容的高度(通过js代码获取)
- CoreText的CTFrame可以在后台线程渲染,UIWebView的内容只能在主线程(UI线程)渲染。
- 基于CoreText可以做更好的原生交互效果,交互效果可以更细腻。而UIWebView的交互效果都是用JS来实现的,在交互效果上会有一些卡顿情况存在。例如在UIWebView下,一个简单的按钮按下操作,都无法做出原生按钮的即时和细腻效果。
劣势:
- CoreText渲染出来的内容不能像UIWebView那样方便的支持内容的复制。
- 基于CoreText来排版需要自己处理很多复杂逻辑,例如需要自己处理图片与文字混排相关的逻辑,也需要自己实现链接点击操作的支持。
业界很多应用都采用了基于CoreText技术的排版方案,例如:新浪微博客户端、多看阅读客户端。
常用类、属性
- CTFrameRef
- CTFramesetterRef
- CTLineRef
- CTRunRef
- CTTypesetterRef
- CTGlyhInfoRef (NSGlyphInfo)
- CTParagraphStyleRef (NSParapraphStyle)
- CTFontRef (UIFont)
- CFArrayRef (NSArray)
字体结构:
CTFrame、CTRun、CTLine
- CTFrame可以想象成一个画布,画布的大小范围由CGPath决定
- CTFrame由很多CTLine组成,CTLine表示为一行CTLine由多个CTRun组成,CTRun相当于一行中的多个块(格式为一致的字为一个块)
但是CTRun不需要你自己创建,由NSAttributedString的属性决定,系统自动生成。每个CTRun对应不同属性。 - CTFramesetter是一个工厂,创建CTFrame,一个界面上可以有多个CTFrame
- CTFrame就是一个基本画布,然后一行一行绘制。CoreText会自动根据传入的NSAttributedString属性创建CTRun,包括字体样式,颜色,间距等
流程
- 创建AttributedString,定义样式
- 2、通过CFAttributedStringRef生成CTFramesetter
- 通过CTFramesetter得到CTFrame
- 4.绘制(CTFrameDraw)
- 5.如果有图片存在,先在AttributedString对应位置添加占位字符(空字符串),因为CoreText是不支持图片的
- 通过回调函数确定图片的宽高(CTRunDelegateCallbacks)
- 遍历到对应CTRun上、获取对应CGRect、绘制图片(CGContextDrawImage)
- 8.如果想做点击对应的图片的回调可以记录图片的位置,同时确定点击的位置是不是在图片的位置上做处理
- 如果想做链接点击处理,则需要确定此链接上的所有CTRun的位置,然后判断点击的点是不是在此位置上做处理
二、基本的文本样式的具体代码
CoreText是需要我们自己处理绘制,不像UILabel等最上层的控件,我们必须在drawRect中绘制,为了更好的使用,我们稍微封装一哈,自定义一个UIView
我们在使用最上层的控件时,坐标系的原点在左上角,而底层的CoreGraphics的坐标原点则在左下角,(垮平台图形绘制框架OpenGL的坐标系就是如此的)
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
//step 1:获取当前画布的上下文,用于后续将内容绘制在画布上
CGContextRef context = UIGraphicsGetCurrentContext();
//step 2: 创建绘制区域,CoreText本身支持各种文字排版的区域,我们这里使用整个UIView作为排版区域
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, self.bounds);
//step 3: 创建绘制的文字,为NSAttributedString类型
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:@"xXHhofiyYI这是一段中文,前面是大小写"];
//step 4: 通过NSAttributedString转换为CTFramesetterRef,然后通过CTFramesetterRef创建CTFrameRef
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attributedString length]), path, NULL);
//step 5: 开始绘制文字
CTFrameDraw(frame,context);
//step 6: 释放对象
CFRelease(frame);
CFRelease(path);
CFRelease(framesetter);
//使用Create函数建立的对象引用,必须要使用CFRelease掉。
}
此时得到的效果如下:是翻转的
结果分析:发现文案是反的。原因就是因为coreText的坐标系和UIKit的坐标系不一样的。
因此我们需要将坐标系进行翻转
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, self.bounds.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
此时就的得到了一个正常的文字显示。
以上的绘制方式都是基于CTFrame绘制的,还可以按照CTLine和CTRun绘制:
按CTLine绘制
// 通过CTLine
// 1.获得CTLine数组
CFArrayRef lines = CTFrameGetLines(frame);
// 2.获得行数
CFIndex indexCount = CFArrayGetCount(lines);
// 3.获得每一行的origin, CoreText的origin是在字形的baseLine(基准线)处
CGPoint origins[indexCount];
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);
// 4.遍历每一行进行绘制
for (int i = 0; i < indexCount; i++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
CTLineDraw(line, context);
}
// 绘制的文字内容是:Worl按季度交发十大减肥;阿技术点发觉啊;啥的积分;阿斯加德发;安静是的;发jakdfads;fjas;lsd f安静的首付款撒;时间点发;安静都是;发觉啊;是的发;啊打发;
结果如下:
从UIView的底部开始绘制的,且没有绘制安全,还是从基准线分割了文字
按CTRun绘制
用下面函数替换CTLineDraw(line, context)这一句就可以了,
for (int i = 0; i < indexCount; i++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
CFArrayRef runs = CTLineGetGlyphRuns(line);
CFIndex runCount = CFArrayGetCount(runs);
for (int j = 0; j < runCount; j++) {
CTRunRef run = CFArrayGetValueAtIndex(runs, j);
CTRunDraw(run, context, CFRangeMake(0, 0));
}
}
结果如下:
文字叠加了
三、图文混排
CoreText本身是不提供UIImage的绘制的,所以UIImage肯定只能通过Core Graphics绘制,但是绘制时必须要知道绘制单元的长宽,庆幸的是CoreText绘制的最小单元CTRun提供了CTRunDelegate,也就是当设置了kCTRunDelegateAttributedName之后,CTRun的绘制时所需的参考(长宽等)将可以从委托中获取,我们即可通过此方法实现图片的绘。在需要绘制图片的位置,提前预留空白位。
CTRun有几个委托用以实现CTRun的几个参数的获取
以下是CTRunDelegateCallbacks的几个委托代理
typedef struct
{
CFIndex version;
CTRunDelegateDeallocateCallback dealloc;
CTRunDelegateGetAscentCallback getAscent;
CTRunDelegateGetDescentCallback getDescent;
CTRunDelegateGetWidthCallback getWidth;
} CTRunDelegateCallbacks;
以下是基本绘制
/// 固定图文混排
- (void) drawTextAndImg {
// CoreText为了排版,需要将显示的文本内容,位置,字体,字形传递给Quartz
// 步骤1 获取当前画布的上下文,用于后续将内容绘制在画布上
CGContextRef context = UIGraphicsGetCurrentContext();
/*
步骤2
将坐标系上下翻转。对于底层的绘制引擎来说,屏幕的左下角是(0,0)坐标。
而对于上层的UIKit来说,左上角是(0,0)坐标。所以我们为了之后的坐标系描述按UIKit来做,先在这里做一个坐标系的上下翻转操作。
翻转之后,底层和上层的(0,0)坐标就是重合了
*/
CGContextSetTextMatrix(context, CGAffineTransformIdentity); // 设置矩阵(纹理)
CGContextTranslateCTM(context, 0, self.bounds.size.height); // 内容翻转
CGContextScaleCTM(context, 1.0, -1.0); //
/*
步骤3
创建绘制的区域,CoreText本身支持各种文字排版的区域,我们这里简单的将UIView的整个界面作为排版的区域。
为了加深理解,我们可以替换区域为下面的椭圆区域
*/
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, self.bounds);
// CGPathAddEllipseInRect(path, NULL, self.bounds); // 椭圆区域
/**
含有图片的 步骤1
*/
CTRunDelegateCallbacks imageCallBacks;
imageCallBacks.version = kCTRunDelegateCurrentVersion;
imageCallBacks.dealloc = ImgRunDelegateDeallocCallback;
imageCallBacks.getAscent = ImgRunDelegateGetAscentCallback;
imageCallBacks.getDescent = ImgRunDelegateGetDescentCallback;
imageCallBacks.getWidth = ImgRunDelegateGetWidthCallback;
NSString *imgName = @"coretext-image-1.jpg";
CTRunDelegateRef imgRunDelegate = CTRunDelegateCreate(&imageCallBacks, (__bridge void * _Nullable)(imgName)); // 我们也可以传入其他参数
NSMutableAttributedString *imgAttributedStr = [[NSMutableAttributedString alloc] initWithString:@" "];
[imgAttributedStr addAttribute:(NSString *)kCTRunDelegateAttributeName value:(__bridge id)imgRunDelegate range:NSMakeRange(0, 1)];
// 步骤4
NSMutableAttributedString *attString = [[NSMutableAttributedString alloc] initWithString:@" Worl按季度交发十大减肥;阿技术点发觉啊;啥的积分;阿斯加德发;安静是的;发jakdfads;fjas;lsd f安静的首付款撒;时间点发;安静都是;发觉啊;是的发;啊打发; "];
[attString addAttribute:(NSString *)kCTBackgroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 10)];
[attString addAttribute:(NSString *)kCTFontAttributeName value:[UIFont systemFontOfSize:18] range:NSMakeRange(0, 10)];
/**
含有图片的 步骤2
*/
#define kImgName @"imgName"
// 图片占位符添加
[imgAttributedStr addAttribute:kImgName value:imgName range:NSMakeRange(0, 1)];
[attString insertAttributedString:imgAttributedStr atIndex:30];
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attString);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attString length]), path, NULL);
// 步骤5 开始绘制
CTFrameDraw(frame, context);
/**
含有图片的 步骤3 绘制图片
*/
// 通过CTLine
// 1.获得CTLine数组
CFArrayRef lines = CTFrameGetLines(frame);
// 2.获得行数
CFIndex indexCount = CFArrayGetCount(lines);
// 3.获得每一行的origin, CoreText的origin是在字形的baseLine(基准线)处
CGPoint lineOrigins[indexCount];
// 获得第几行的起始点
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), lineOrigins);
for (int i = 0; i < indexCount; i++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
CGFloat lineAscent; // 上缘线
CGFloat lineDescent; // 下缘线
CGFloat lineLeading; // 行间距
// 获取此行的字形参数
CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, &lineLeading);
// 获取此行中每个CTRun
CFArrayRef runs = CTLineGetGlyphRuns(line);
CFIndex runCount = CFArrayGetCount(runs);
for (int j = 0; j < runCount; j++) {
CGFloat runAscent; // 此CTRun上缘线
CGFloat runDescent; // 此CTRun下缘线
CGFloat runLeading; // CTRun间距
CGPoint lineOrigin = lineOrigins[i]; // 此行起点
// 获取此CTRun
CTRunRef run = CFArrayGetValueAtIndex(runs, j);
// 获取该run上的属性特征
NSDictionary *runAttributeds = (NSDictionary *)CTRunGetAttributes(run);
CGRect runRect;
// 获取此CTRun的上缘线、下缘线,并由此获取CTRun和宽
runRect.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &runAscent, &runDescent, &runLeading);
// CTRun的X坐标
CGFloat runOrgX = lineOrigin.x + CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
#warning ---此处的y结果没看懂,高也没看懂---
// 此处的y结果没看懂 高也没看懂
runRect = CGRectMake(runOrgX, lineOrigin.y - runDescent, runRect.size.width, runAscent + runDescent);
// 通过run的属性特征获得图片名称的字符串
NSString *imgName = [runAttributeds objectForKey:kImgName];
NSLog(@"图片名称===%@",imgName);
if (imgName != nil) {
UIImage *image = [UIImage imageNamed:imgName];
if (image) {
#warning ---此处的坐标计算也没看懂---
CGRect imageRect;
imageRect.size = CGSizeMake(40, 20);
imageRect.origin.x = runRect.origin.x + lineOrigin.x;
imageRect.origin.y = lineOrigin.y;
CGContextDrawImage(context, imageRect, image.CGImage);
}
}
}
}
// 步骤6 释放内存
CFRelease(imgRunDelegate);
CFRelease(frame);
CFRelease(path);
CFRelease(framesetter);
}
#pragma mark ---代理函数CTRunDelegateCallbacks---
void ImgRunDelegateDeallocCallback(void *refCon) {
}
/// 通过此函数设置图片处上部高
CGFloat ImgRunDelegateGetAscentCallback(void *refCon) {
NSString *imageName = (__bridge NSString *)refCon;
// return [UIImage imageNamed:imageName].size.height;
return 40;
}
/// 通过此函数设置图片处下部高
CGFloat ImgRunDelegateGetDescentCallback(void *refCon) {
return 0;
}
/// 通过此函数设置图片位置宽度
CGFloat ImgRunDelegateGetWidthCallback(void *refCon) {
NSString *imageName = (__bridge NSString *)refCon;
// return [UIImage imageNamed:imageName].size.width;
return 40;
}
结果如下:
基于以上这个原型,我们可以封装一个比较完整的富文本控件了,比如定义HTML协议或者JSON,然后在内部进行解析,然后根据类型与相应的属性进行绘制。
四、图片点击事件
CoreText就是将内容绘制到画布上,自然没有事件处理,我们要实现图片与链接的点击效果就需要使用触摸事件了。当点击的位置在图片的Rect中,那我们做相应的操作即可,所以基本步骤如下:
- 记录所有图片所在画布中作为一个CTRun的位置
- 获取每个图片所在画布中所占的Rect矩形区域
- 当点击事件发生时,判断点击的点是否在某个需要处理的图片Rect内。
这里为了演示的简单,我们直接在drawRect中记录图片的相应坐标,但是一般我们会在CTDisplayView渲染之前对数据进行相应的处理,比如处理传入的样式数据、记录图片与链接等信息。
用于记录图片信息类
@interface CTImageData : NSObject
@property (nonatomic,strong) NSString *imgHolder;
@property (nonatomic,strong) NSURL *imgPath;
@property (nonatomic) NSInteger idx;
@property (nonatomic) CGRect imageRect;
@end
// 记录图片信息
//以下操作仅仅是演示示例,实战时请在渲染之前处理数据,做到最佳实践。
if(!_imageDataArray){
_imageDataArray = [[NSMutableArray alloc]init];
}
BOOL imgExist = NO;
for (CTImageData *ctImageData in _imageDataArray) {
if (ctImageData.idx == idx) {
imgExist = YES;
break;
}
}
if(!imgExist){
CTImageData *ctImageData = [[CTImageData alloc]init];
ctImageData.imgHolder = imgName;
ctImageData.imageRect = imageRect;
ctImageData.idx = idx;
[_imageDataArray addObject:ctImageData];
}
- (void)setupEvents{
UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(userTapGestureDetected:)];
[self addGestureRecognizer:tapRecognizer];
self.userInteractionEnabled = YES;
}
- (void)userTapGestureDetected:(UIGestureRecognizer *)recognizer{
CGPoint point = [recognizer locationInView:self];
//先判断是否是点击的图片Rect
for(CTImageData *imageData in _imageDataArray){
CGRect imageRect = imageData.imageRect;
CGFloat imageOriginY = self.bounds.size.height - imageRect.origin.y - imageRect.size.height;
CGRect rect = CGRectMake(imageRect.origin.x,imageOriginY, imageRect.size.width, imageRect.size.height);
if(CGRectContainsPoint(rect, point)){
NSLog(@"tap image handle");
return;
}
}
//再判断链接
}
五、链接点击事件
记录链接信息类
@interface CTLinkData : NSObject
@property (nonatomic ,strong) NSString *text;
@property (nonatomic ,strong) NSString *url;
@property (nonatomic ,assign) NSRange range;
@end
记录链接信息
if(!_linkDataArray){
_linkDataArray = [[NSMutableArray alloc]init];
}
CTLinkData *ctLinkData = [[CTLinkData alloc]init];
ctLinkData.text = [attributedString.string substringWithRange:linkRange];
ctLinkData.url = @"http://www.baidu.com";
ctLinkData.range = linkRange;
[_linkDataArray addObject:ctLinkData];
处理链接事件
if(!_linkDataArray){
_linkDataArray = [[NSMutableArray alloc]init];
}
CTLinkData *ctLinkData = [[CTLinkData alloc]init];
ctLinkData.text = [attributedString.string substringWithRange:linkRange];
ctLinkData.url = @"http://www.baidu.com";
ctLinkData.range = linkRange;
[_linkDataArray addObject:ctLinkData];
根据点击点获取字符串偏移
- (CFIndex)touchPointOffset:(CGPoint)point{
//获取所有行
CFArrayRef lines = CTFrameGetLines(_ctFrame);
if(lines == nil){
return -1;
}
CFIndex count = CFArrayGetCount(lines);
//获取每行起点
CGPoint origins[count];
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
//Flip
CGAffineTransform transform = CGAffineTransformMakeTranslation(0, self.bounds.size.height);
transform = CGAffineTransformScale(transform, 1.f, -1.f);
CFIndex idx = -1;
for (int i = 0; i< count; i++) {
CGPoint lineOrigin = origins[i];
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
//获取每一行Rect
CGFloat ascent = 0.0f;
CGFloat descent = 0.0f;
CGFloat leading = 0.0f;
CGFloat width = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
CGRect lineRect = CGRectMake(lineOrigin.x, lineOrigin.y - descent, width, ascent + descent);
lineRect = CGRectApplyAffineTransform(lineRect, transform);
if(CGRectContainsPoint(lineRect,point)){
//将point相对于view的坐标转换为相对于该行的坐标
CGPoint linePoint = CGPointMake(point.x-lineRect.origin.x, point.y-lineRect.origin.y);
//根据当前行的坐标获取相对整个CoreText串的偏移
idx = CTLineGetStringIndexForPosition(line, linePoint);
}
}
return idx;
}
下面是我写的一个demo,封装好的图文混排的,可以看看,实战的话稍微修改修改就好,数据是通过json传入的。## demo链接