CoreText实现图文混排以及点击事件

CoreText简介

Core Text 是基于 iOS 3.2+ 和 OSX 10.5+ 的一种能够对文本格式和文本布局进行精细控制的文本引擎。
它良好的结合了 UIKit 和 Core Graphics/Quartz:
>

UIKitUILabel允许你通过在IB中简单的拖曳添加文本,但你不能改变文本的颜色和其中的单词。
Core Graphics/Quartz几乎允许你做任何系统允许的事情,但你需要为每个字形计算位置,并画在屏幕上。
Core Text正结合了这两者!你可以完全控制位置、布局、类似文本大小和颜色这样的属性,而Core Text将帮你完善其它的东西——类似文本换行、字体呈现等等。
iOS7新推出的类库Textkit,其实是在之前推出的CoreText上的封装
CoreText的主要作用也是用于文字的排版和渲染,但它是一种先进而又处于底层技术,如果我们需要将文本内容直接渲染到图形上下文(Graphics context)时,从性能和易用性来考虑,最佳方案就是使用CoreText

### 富文本
>
富文本格式(RTF)规范是为了便于在应用程序之间轻松转储格式化文本图形的一种编码方法
现在,用户可以利用特定转换软件,在不同系统如MS-DOSWindowsOS/2MacintoshPower Macintosh的应用程序之间转移字处理文档。
RTF规范提供一种在不同的输出设备、操作环境和操作系统之间交换文本和图形的一种格式。
RTF使用ANSI,PC-8, Macintosh, 或IBM PC字符集控制文档的表示法和格式化,包括屏幕显示和打印。
凭借RTF规范,不同的操作系统和不同的软件程序创建的文档能够在这些操作系统和应用程序之间传递。
将一个格式化的文件转换为RTF文件的软件称为RTF书写器
RTF书写器用于分离现有文本中的程序控制信息,并且生成一个包含文本和与之相关的RTF组的新文件。
RTF文件转换成格式化文件的软件则称为RTF阅读器

简单来说附带有每一个文字属性的字符串,就是富文本。在iOS中,AttributeString专门用来处理富文本。AttributedString也分为NSAttributedStringNSMutableAttributedString两个类。
常用的一些方法

1
2
3
4
5
-initWithString: //以NSString初始化一个富文本对象
-setAttributes:range: //为富文本中的一段范围添加一些属性,第一个参数是个NSDictionary字典,第二个参数是NSRange。
-addAttribute:value:range: //添加一个属性
-addAttributes:range: //添加多个属性
-removeAttribute:range: //移除属性


代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
NSDictionary * dic = @{NSFontAttributeName:[UIFont fontWithName:@"Zapfino" size:20],NSForegroundColorAttributeName:[UIColor redColor],NSUnderlineStyleAttributeName:@(NSUnderlineStyleSingle)};
NSMutableAttributedString * attributeStr = [[NSMutableAttributedString alloc] initWithString:@"0我是一个富文本,9听说我有很多属性,19I will try。32这里清除属性."];
// 设置属性
[attributeStr setAttributes:dic range:NSMakeRange(0, attributeStr.length)];
// 添加属性
[attributeStr addAttribute:NSFontAttributeName value:[UIFont systemFontOfSize:30] range:NSMakeRange(9, 10)];
[attributeStr addAttribute:NSForegroundColorAttributeName value:[UIColor cyanColor] range:NSMakeRange(13, 13)];
// 添加多个属性
NSDictionary * dicAdd = @{NSBackgroundColorAttributeName:[UIColor yellowColor],NSLigatureAttributeName:@1};
[attributeStr addAttributes:dicAdd range:NSMakeRange(19, 13)];
// 移除属性
[attributeStr removeAttribute:NSFontAttributeName range:NSMakeRange(32, 9)];
UILabel * label = [[UILabel alloc] initWithFrame:CGRectMake(100, 100, 200, 400)];
label.numberOfLines = 0;
label.attributedText = attributeStr;


### CoreText坐标系与UIKit坐标系

从图中可看出CoreText坐标系是以左下角为坐标原点,而我们常用的UIKit是以左上角为坐标原点。因此在CoreText中的布局完成后需要对其坐标系进行转换,否则直接绘制出现位置反转的镜像情况。
>
在iOS的不同framework中使用着不同的坐标系:
UIKit - y轴向下
Core Graphics(Quartz) - y轴向上
OpenGL ES- y轴向上
UIKit是iPhone SDK的Cocoa Touch层的核心framework,是iPhone应用程序图形界面和事件驱动的基础,它和传统的windows桌面一样,坐标系是y轴向下的;Core Graphics(Quartz)一个基于2D的图形绘制引擎,它的坐标系则是y轴向上的;而OpenGL ES是iPhone SDK的2D3D绘制引擎,它使用左手坐标系,它的坐标系也是y轴向上的,如果不考虑z轴,在二维下它的坐标系和Quartz是一样的。

### CoreText绘制富文本
CoreText实现图文混排其实就是在富文本中插入一个空白的图片占位符的富文本字符串,通过代理设置相关的图片尺寸信息,根据从富文本得到的frame计算图片绘制的frame再绘制图片这么一个过程。
#### 整体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
-(void)drawRect:(CGRect)rect
{
[super drawRect:rect];
//获取当前绘制上下文
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetTextMatrix(context, CGAffineTransformIdentity);//设置当前文本矩阵为不做图形变换
CGContextTranslateCTM(context, 0, self.bounds.size.height);//平移方法,将context向上平移一个屏幕高
CGContextScaleCTM(context, 1.0, -1.0);//缩放方法,x轴缩放系数为1,则不变,y轴缩放系数为-1,则相当于以x轴为轴旋转180度
NSMutableAttributedString * attributeStr = [[NSMutableAttributedString alloc] initWithString:@"\n这里在测试图文混排,\n我是一个富文本"];
CTRunDelegateCallbacks callBacks;
memset(&callBacks,0,sizeof(CTRunDelegateCallbacks));
callBacks.version = kCTRunDelegateVersion1;
callBacks.getAscent = ascentCallBacks;
callBacks.getDescent = descentCallBacks;
callBacks.getWidth = widthCallBacks;
NSDictionary * dicPic = @{@"height":@129,@"width":@129};
CTRunDelegateRef delegate = CTRunDelegateCreate(& callBacks, (__bridge void *)dicPic);
unichar placeHolder = 0xFFFC;
NSString * placeHolderStr = [NSString stringWithCharacters:&placeHolder length:1];
NSMutableAttributedString * placeHolderAttrStr = [[NSMutableAttributedString alloc] initWithString:placeHolderStr];
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)placeHolderAttrStr, CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);
CFRelease(delegate);
[attributeStr insertAttributedString:placeHolderAttrStr atIndex:12];
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributeStr);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, self.bounds);
NSInteger length = attributeStr.length;
_length = attributeStr.length;
CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, length), path, NULL);
_ctFrame = frame;
CTFrameDraw(frame, context);
UIImage * image = [UIImage imageNamed:@"bd_logo1.jpeg"];
CGRect imgFrm = [self calculateImageRectWithFrame:frame];
self.imgFrm = imgFrm;
CGContextDrawImage(context,imgFrm, image.CGImage);
CFRelease(path);
CFRelease(frameSetter);
}
static CGFloat ascentCallBacks(void * ref)
{
return [(NSNumber *)[(__bridge NSDictionary *)ref valueForKey:@"height"] floatValue];
}
static CGFloat descentCallBacks(void * ref)
{
return 0;
}
static CGFloat widthCallBacks(void * ref)
{
return [(NSNumber *)[(__bridge NSDictionary *)ref valueForKey:@"width"] floatValue];
}
-(CGRect)calculateImageRectWithFrame:(CTFrameRef)frame
{
NSArray * arrLines = (NSArray *)CTFrameGetLines(frame);
NSInteger count = [arrLines count];
CGPoint points[count];
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), points);
for (int i = 0; i < count; i ++) {
CTLineRef line = (__bridge CTLineRef)arrLines[i];
NSArray * arrGlyphRun = (NSArray *)CTLineGetGlyphRuns(line);
for (int j = 0; j < arrGlyphRun.count; j ++) {
CTRunRef run = (__bridge CTRunRef)arrGlyphRun[j];
NSDictionary * attributes = (NSDictionary *)CTRunGetAttributes(run); CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[attributes valueForKey:(id)kCTRunDelegateAttributeName];
if (delegate == nil) {
continue;
}
NSDictionary * dic = CTRunDelegateGetRefCon(delegate);
if (![dic isKindOfClass:[NSDictionary class]]) {
continue;
}
CGPoint point = points[i];
CGFloat ascent;
CGFloat descent;
CGRect boundsRun;
boundsRun.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);
boundsRun.size.height = ascent + descent;
CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
boundsRun.origin.x = point.x + xOffset;
boundsRun.origin.y = point.y - descent;
CGPathRef path = CTFrameGetPath(frame);
CGRect colRect = CGPathGetBoundingBox(path);
CGRect imageBounds = CGRectOffset(boundsRun, colRect.origin.x, colRect.origin.y);
return imageBounds;
}
}
return CGRectZero;
}
- (void)dealloc
{
CFRelease(_ctFrame);
}
```
#### 逐句解释
```objectivec
CGContextRef context = UIGraphicsGetCurrentContext();//获取当前绘制上下文


所有的绘制操作都是在上下文上进行绘制的

1
2
3
4
5
6
//设置当前文本矩阵为不做图形变换
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
//平移方法,将context向上平移一个屏幕高
CGContextTranslateCTM(context, 0, self.bounds.size.height);
//缩放方法,x轴缩放系数为1,则不变,y轴缩放系数为-1,则相当于以x轴为轴旋转180度
CGContextScaleCTM(context, 1.0, -1.0);


coreText 起初是为OSX设计的,而OSX得坐标原点是左下角,y轴正方向朝上。iOS中坐标原点是左上角,y轴正方向向下。若不进行坐标转换,则文字从下开始,还是倒着的。这三句对context的坐标系进行转换
>context说的是绘画人所处的角度上下文,画布无论怎么样都是正对着屏幕的,它不会旋转,或者放大缩小,或者移动,认为context就是画布这种理解是错误的
CTM,Context Translate Matrix。 它是把要绘制的上下文以一个叫做Matrix的东西来表示,可以简单地想作,绘制的上下文的每一个点都映射在Matrix上,你在Matrix上的操作都会使得上下文上的点产生相应的变动。如放大、旋转、移动。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
事实上,图文混排就是在要插入图片的位置插入一个富文本类型的占位符。通过CTRUNDelegate设置图片
设置一个回调结构体,告诉代理该回调那些方法
*/
//创建一个回调结构体,设置相关参数
CTRunDelegateCallbacks callBacks;
//memset将已开辟内存空间 callbacks 的首 n 个字节的值设为值 0, 相当于对CTRunDelegateCallbacks内存空间初始化
memset(&callBacks,0,sizeof(CTRunDelegateCallbacks));
//设置回调版本,默认这个
callBacks.version = kCTRunDelegateVersion1;
//设置图片顶部距离基线的距离
callBacks.getAscent = ascentCallBacks;
//设置图片底部距离基线的距离
callBacks.getDescent = descentCallBacks;
//设置图片宽度
callBacks.getWidth = widthCallBacks;


coreText中大量的调用c的方法。大部分跟系统底层有关的都需要调c的方法。所以设置代理要按照人家的方法来啊。

补充一下知识

这是一个CTRun的尺寸图,我们绘制图片的时候实际上实在一个CTRun中绘制这个图片,那么CTRun绘制的坐标系中,它会以origin点作为原点进行绘制。
基线为过原点的x轴,ascent即为CTRun顶线距基线的距离,descent即为底线距基线的距离。
我们绘制图片应该从原点开始绘制,图片的高度及宽度及CTRun的高度及宽度,我们通过代理设置CTRun的尺寸间接设置图片的尺寸。

1
2
NSDictionary * dicPic = @{@"height":@129,@"width":@129};
CTRunDelegateRef delegate = CTRunDelegateCreate(& callBacks, (__bridge void *)dicPic);

上面只是设置了回调结构体,然而我们还没有告诉这个代理我们要的图片尺寸。
所以这句话就在设置代理的时候绑定了一个返回图片尺寸的字典。
事实上此处你可以绑定任意对象。此处你绑定的对象既是回调方法中的参数ref
三个回调方法代码如下

1
2
3
4
5
6
7
8
9
10
11
12
static CGFloat ascentCallBacks(void * ref)
{
return [(NSNumber *)[(__bridge NSDictionary *)ref valueForKey:@"height"] floatValue];
}
static CGFloat descentCallBacks(void * ref)
{
return 0;
}
static CGFloat widthCallBacks(void * ref)
{
return [(NSNumber *)[(__bridge NSDictionary *)ref valueForKey:@"width"] floatValue];
}

由于是c的方法,所以也没有什么对象的概念。是一个指针类型的数据。不过oc的对象其实也就是c的结构体。我们可以通过类型转换获得oc中的字典。
__bridge既是C的结构体转换成OC对象时需要的一个修饰词。

图片的插入(创建一个富文本类型的图片占位符,绑定我们的代理)

1
2
3
4
5
6
7
8
9
10
//创建空白字符
unichar placeHolder = 0xFFFC;
//已空白字符生成字符串
NSString * placeHolderStr = [NSString stringWithCharacters:&placeHolder length:1];
//用字符串初始化占位符的富文本
NSMutableAttributedString * placeHolderAttrStr = [[NSMutableAttributedString alloc] initWithString:placeHolderStr];
//给字符串中的范围中字符串设置代理
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)placeHolderAttrStr, CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);
//释放(__bridge进行C与OC数据类型的转换,C为非ARC,需要手动管理)
CFRelease(delegate);

C中就是传递指针的数据比如说字符串,数组时转换不需要用__bridge
需要手动释放是因为进行了类型转换之后就不属于对象了,也不再归自动引用计数机制管理了,所以手动管理。

1
2
//将占位符插入原富文本
[attributeStr insertAttributedString:placeHolderAttrStr atIndex:12];

绘制文本

1
2
3
4
5
6
7
8
9
10
11
//一个frame的工厂,负责生成frame
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributeStr);
//创建绘制区域
CGMutablePathRef path = CGPathCreateMutable();
//添加绘制尺寸
CGPathAddRect(path, NULL, self.bounds);
NSInteger length = attributeStr.length;
//工厂根据绘制区域及富文本(可选范围,多次设置)设置frame
CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0,length), path, NULL);
//根据frame绘制文字
CTFrameDraw(frame, context);

frameSetter是根据富文本生成的一个frame生成的工厂,你可以通过framesetter以及你想要绘制的富文本的范围获取该CTRun的frame。
但是你需要注意的是,获取的frame是仅绘制你所需要的那部分富文本的frame。即当前情况下,你绘制范围定为(10,1),那么你得到的尺寸是只绘制(10,1)的尺寸,他应该从屏幕左上角开始(因为你改变了坐标系),而不是当你绘制全部富文本时他该在的位置。

然后建立一会绘制的尺寸,实际上就是在指定你的绘制范围。
接着生成整个富文本绘制所需要的frame。因为范围是全部文本,所以获取的frame即为全部文本的frame(一定要搞清楚全部与指定范围获取的frame他们都是从左上角开始的,否则你会进入一个奇怪的误区,稍后会提到的)。
最后,根据你获得的frame,绘制全部富文本

补充一下知识


CTFrame可以理解为一个整体的画布由很多行(CTLine)组成,而每一行又由一个或者多个小方块(CTRun)组成,我们不需要自己创建CTRun,Core Text将根据NSAttributedString的属性来自动创建CTRun。每个CTRun对象对应不同的属性,正因此,你可以自由的控制字体、颜色、字间距等等信息。

CTFramesetter其实就是CTFrame的工厂方法,通过给定的NSAttributedString,生成CTRrame,同时系统自动的创建了CTTypesetter,CTTypesetter就是管理你的字体的类。

绘制图片
绘制图片用下面这个方法

1
CGContextDrawImage(context,imgFrm, image.CGImage);//绘制图片

有三个参数,分别是context,frame,以及image。context就是当前的上下文image就是要添加的那个图片,不过是CGImage类型,通过UIImage转出CGImage就好了,下面讲一下frame的获取:-(CGRect)calculateImageRectWithFrame:(CTFrameRef)frame

1
2
3
4
5
6
7
8
//根据frame获取需要绘制的线的数组
NSArray * arrLines = (NSArray *)CTFrameGetLines(frame);
//获取线的数量
NSInteger count = [arrLines count];
//建立起点的数组(cgpoint类型为结构体,故用C语言的数组)
CGPoint points[count];
//获取起点
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), points);

因为CTFrameGetLines()返回值是CFArrayRef类型的数据。就是一个c的数组类型吧
每个CTLine都有自己的origin。所以要生成一个相同元素个数的数组去盛放origin对象。
然后用CTFrameGetLineOrigins获取所有原点。

计算frame
遍历frame中的所有CTRun,检查是不是我们绑定图片的那个,如果是,根据该CTRun所在CTLine的origin以及CTRun在CTLine中的横向偏移量计算出CTRun的原点,加上其尺寸即为该CTRun的尺寸。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
for (int i = 0; i < count; i ++) {//遍历线的数组
CTLineRef line = (__bridge CTLineRef)arrLines[i];
NSArray * arrGlyphRun = (NSArray *)CTLineGetGlyphRuns(line);//获取GlyphRun数组(GlyphRun:高效的字符绘制方案)
for (int j = 0; j < arrGlyphRun.count; j ++) {//遍历CTRun数组
CTRunRef run = (__bridge CTRunRef)arrGlyphRun[j];//获取CTRun
NSDictionary * attributes = (NSDictionary *)CTRunGetAttributes(run);//获取CTRun的属性
CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[attributes valueForKey:(id)kCTRunDelegateAttributeName];//获取代理
if (delegate == nil) {//非空
continue;
}
NSDictionary * dic = CTRunDelegateGetRefCon(delegate);//判断代理字典
if (![dic isKindOfClass:[NSDictionary class]]) {
continue;
}
CGPoint point = points[i];//获取一个起点
CGFloat ascent;//获取上距
CGFloat descent;//获取下距
CGRect boundsRun;//创建一个frame
boundsRun.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);
boundsRun.size.height = ascent + descent;//取得高
CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);//获取x偏移量
boundsRun.origin.x = point.x + xOffset;//point是行起点位置,加上每个字的偏移量得到每个字的x
boundsRun.origin.y = point.y - descent;//计算原点
CGPathRef path = CTFrameGetPath(frame);//获取绘制区域
CGRect colRect = CGPathGetBoundingBox(path);//获取剪裁区域边框
CGRect imageBounds = CGRectOffset(boundsRun, colRect.origin.x, colRect.origin.y);
return imageBounds;

手动释放创建的对象

1
2
3
4
5
6
7
CFRelease(path);
CFRelease(frameSetter);
- (void)dealloc
{
CFRelease(_ctFrame);
}

两坐标系中point和frame的变换

point
UIKit坐标系上的point转换为CoreText坐标系上的point,在UIKit坐标系上的一个点为(x,y),其在CoreText坐标系上为(x,self.bounds.size.height - y)
反过来转换也是一样的在CoreText坐标系上的一个点为(x,y),其在UIKit坐标系上为(x,self.bounds.size.height - y)

frame
UIKit坐标系上的frame转换为CoreText坐标系上的frame,在UIKit坐标系上的一个frame为(x,y,width,height),其在CoreText坐标系上为(x,self.bounds.size.height - y - height,width,height)
反过来转换也是一样的在CoreText坐标系上的一个frame为(x,y,width,height),其在UIKit坐标系上为(x,self.bounds.size.height - y - height,width,height)

实现点击事件

通过touchBegan方法拿到当前点击到的点,然后通过坐标判断这个点是否在某段文字上,如果在则触发对应事件。

整体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
UITouch * touch = [touches anyObject];
CGPoint location = [self systemPointFromScreenPoint:[touch locationInView:self]];
if ([self checkIsClickOnImgWithPoint:location])
{
return;
}
[self clickOnStrWithPoint:location];
}
- (BOOL)checkIsClickOnImgWithPoint:(CGPoint)location
{
if ([self isFrame:self.imgFrm containsPoint:location])
{
NSLog(@"您点击到了图片");
return YES;
}
return NO;
}
- (void)clickOnStrWithPoint:(CGPoint)location
{
NSArray * lines = (NSArray *)CTFrameGetLines(_ctFrame);
CFRange ranges[lines.count];
CGPoint origins[lines.count];
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
for (int i = 0; i < lines.count; i ++)
{
CTLineRef line = (__bridge CTLineRef)lines[i];
CFRange range = CTLineGetStringRange(line);
ranges[i] = range;
}
for (int i = 0; i < _length; i ++)
{
long maxLoc = 0;
int lineNum = 0;
for (int j = 0; j < lines.count; j ++)
{
CFRange range = ranges[j];
maxLoc = range.location + range.length -1;
if (i <= maxLoc)
{
lineNum = j;
break;
}
}
CTLineRef line = (__bridge CTLineRef)lines[lineNum];
CGPoint origin = origins[lineNum];
CGRect CTRunFrame = [self frameForCTRunWithIndex:i CTLine:line origin:origin];
if ([self isFrame:CTRunFrame containsPoint:location])
{
NSLog(@"您点击到了第 %d 个字符,位于第 %d 行,然而他没有响应事件。",i,lineNum + 1);//点击到文字,然而没有响应的处理。可以做其他处理
return;
}
}
NSLog(@"您没有点击到文字");
}
- (BOOL)isIndex:(NSInteger)index inRange:(NSRange)range
{
if ((index <= range.location + range.length - 1) && (index >= range.location))
{
return YES;
}
return NO;
}
- (CGPoint)systemPointFromScreenPoint:(CGPoint)origin
{
return CGPointMake(origin.x, self.bounds.size.height - origin.y);
}
- (BOOL)isFrame:(CGRect)frame containsPoint:(CGPoint)point
{
return CGRectContainsPoint(frame, point);
}
- (CGRect)frameForCTRunWithIndex:(NSInteger)index CTLine:(CTLineRef)line origin:(CGPoint)origin
{
CGFloat offsetX = CTLineGetOffsetForStringIndex(line, index, NULL);
CGFloat offsetX2 = CTLineGetOffsetForStringIndex(line, index + 1, NULL);
offsetX += origin.x;
offsetX2 += origin.x;
CGFloat offsetY = origin.y;
CGFloat lineAscent;
CGFloat lineDeScent;
NSArray * runs = (__bridge NSArray *)CTLineGetGlyphRuns(line);
CTRunRef runCurrent;
for (int k = 0; k < runs.count; k ++)
{
CTRunRef run = (__bridge CTRunRef)runs[k];
CFRange range = CTRunGetStringRange(run);
NSRange rangeOC = NSMakeRange(range.location, range.length);
if ([self isIndex:index inRange:rangeOC])
{
runCurrent = run;
break;
}
}
CTRunGetTypographicBounds(runCurrent, CFRangeMake(0, 0), &lineAscent, &lineDeScent, NULL);
CGFloat height = lineAscent + lineDeScent;
return CGRectMake(offsetX, offsetY, offsetX2 - offsetX, height);
}

分段解析

1
2
3
4
5
6
7
8
9
10
11
///点击方法
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
UITouch * touch = [touches anyObject];
//获得点击位置在CoreText坐标系上的坐标
CGPoint location = [self systemPointFromScreenPoint:[touch locationInView:self]];
if ([self checkIsClickOnImgWithPoint:location]) {//检查是否点击在图片上,如果在,优先响应图片事件
return;
}
[self ClickOnStrWithPoint:location];//响应字符串事件
}

坐标转换
因为UIKit坐标系与系统坐标系的不同,我们要将坐标系统一为CoreText坐标

1
2
3
4
5
//坐标转换,将UIkit坐标转换为CoreText坐标
- (CGPoint)systemPointFromScreenPoint:(CGPoint)origin
{
return CGPointMake(origin.x, self.bounds.size.height - origin.y);
}

点击图片判断

1
2
3
4
5
6
7
8
9
10
//遍历图片frame的数组与点击位置比较,如果在范围内则响应数组取出对应响应并执行,返回YES,否则返回NO
- (BOOL)checkIsClickOnImgWithPoint:(CGPoint)location
{
if ([self isFrame:self.imgFrm containsPoint:location])
{
NSLog(@"您点击到了图片");
return YES;
}
return NO;
}
1
2
3
4
5
//检测点是否包含在frame中
- (BOOL)isFrame:(CGRect)frame containsPoint:(CGPoint)point
{
return CGRectContainsPoint(frame, point);
}

由于传入的point是CoreText坐标(本例中),所以frame我们一定要传入CoreText坐标系下的frame才能正确对应。

点击文字判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//字符串点击检查
//将响应字符串的每个字符取出与点击位置比较,若在范围内则点击到响应文字
- (void)clickOnStrWithPoint:(CGPoint)location
{
NSArray * lines = (NSArray *)CTFrameGetLines(_ctFrame);//获取所有CTLine
CFRange ranges[lines.count];//初始化每一CTLine的range数组
CGPoint origins[lines.count];//初始化每一CTLine的原点数组
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);//获取所有CTLine的原点
for (int i = 0; i < lines.count; i ++)
{
CTLineRef line = (__bridge CTLineRef)lines[i];
CFRange range = CTLineGetStringRange(line);
ranges[i] = range;
}//获得所有CTLine的Range
for (int i = 0; i < _length; i ++)
{//逐字检查
long maxLoc = 0;
int lineNum = 0;
for (int j = 0; j < lines.count; j ++)
{//获取对应字符所在CTLine的index
CFRange range = ranges[j];
maxLoc = range.location + range.length -1;
if (i <= maxLoc)
{
lineNum = j;
break;
}
}
CTLineRef line = (__bridge CTLineRef)lines[lineNum];//取到字符对应的CTLine
CGPoint origin = origins[lineNum];//获得字符对应的CTLine的原点
CGRect CTRunFrame = [self frameForCTRunWithIndex:i CTLine:line origin:origin];//计算对应字符的frame
if ([self isFrame:CTRunFrame containsPoint:location])
{//如果点击位置在字符范围内,响应时间,跳出循环
NSLog(@"您点击到了第 %d 个字符,位于第 %d 行,然而他没有响应事件。",i,lineNum + 1);//点击到文字,然而没有响应的处理。可以做其他处理
return;
}
}
NSLog(@"您没有点击到文字");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
///字符frame计算
/*
返回索引字符的frame
index:索引
line:索引字符所在CTLine
origin:line的起点
*/
-(CGRect)frameForCTRunWithIndex:(NSInteger)index
CTLine:(CTLineRef)line
origin:(CGPoint)origin
{
CGFloat offsetX = CTLineGetOffsetForStringIndex(line, index, NULL);//获取字符起点相对于CTLine的原点的偏移量
CGFloat offsexX2 = CTLineGetOffsetForStringIndex(line, index + 1, NULL);//获取下一个字符的偏移量,两者之间即为字符X范围
offsetX += origin.x;
offsexX2 += origin.x;//坐标转换,将点的CTLine坐标转换至CoreText坐标
CGFloat offsetY = origin.y;//取到CTLine的起点Y
CGFloat lineAscent;//初始化上下边距的变量
CGFloat lineDescent;
NSArray * runs = (__bridge NSArray *)CTLineGetGlyphRuns(line);//获取所有CTRun
CTRunRef runCurrent;
for (int k = 0; k < runs.count; k ++) {//获取当前点击的CTRun
CTRunRef run = (__bridge CTRunRef)runs[k];
CFRange range = CTRunGetStringRange(run);//获得CTRun在富文本中的范围
NSRange rangeOC = NSMakeRange(range.location, range.length);
if ([self isIndex:index inRange:rangeOC]) {
runCurrent = run;
break;
}
}
CTRunGetTypographicBounds(runCurrent, CFRangeMake(0, 0), &lineAscent, &lineDescent, NULL);//获得对应CTRun的尺寸信息
offsetY -= lineDescent;
CGFloat height = lineAscent + lineDescent;
return CGRectMake(offsetX, offsetY, offsexX2 - offsetX, height);//返回一个字符的Frame
}
1
CTLineGetOffsetForStringIndex(,,)

获取一行文字中,指定charIndex字符相对x原点的偏移量,返回值与第三个参数同为一个值。如果charIndex超出一行的字符长度则反回最大长度结束位置的偏移量,如一行文字共有17个字符,哪么返回的是第18个字符的起始偏移,即第17个偏移+第17个字符占有的宽度=第18个起始位置的偏移。因此想求一行字符所占的像素长度时,就可以使用此函数,将charIndex设置为大于字符长度即可。

1
2
3
4
5
6
7
8
///范围检测
-(BOOL)isIndex:(NSInteger)index inRange:(NSRange)range
{
if ((index <= range.location + range.length - 1) && (index >= range.location)) {
return YES;
}
return NO;
}

CoreText实现图文混排之文字环绕及点击算法

整体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@interface CoreTextV ()
{
CTFrameRef _frame;
NSInteger _length;
CGRect _imgFrm;
NSMutableArray * arrText;
}
@end
@implementation CoreTextV
- (void)drawRect:(CGRect)rect
{
[super drawRect:rect];
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, self.bounds.size.height);
CGContextScaleCTM(context, 1.0f, -1.0f);
arrText = [NSMutableArray array];
NSMutableAttributedString * attributedStr = [[NSMutableAttributedString alloc] initWithString:@"123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"];
[attributedStr addAttribute:NSForegroundColorAttributeName value:[UIColor whiteColor] range:NSMakeRange(0, attributedStr.length)];
CTRunDelegateCallbacks callBacks;
memset(&callBacks, 0, sizeof(CTRunDelegateCallbacks));
callBacks.version = kCTRunDelegateVersion1;
callBacks.getAscent = ascentCallBacks;
callBacks.getDescent = descentCallBacks;
callBacks.getWidth = widthCallBacks;
NSDictionary * dicPic = @{@"height" : @90, @"width" : @160};
CTRunDelegateRef delegate = CTRunDelegateCreate(&callBacks, (__bridge void *)dicPic);
unichar placeHolder = 0xFFFC;
NSString * placeHolderStr = [NSString stringWithCharacters:&placeHolder length:1];
NSMutableAttributedString * placeHolderAttrStr = [[NSMutableAttributedString alloc] initWithString:placeHolderStr];
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)placeHolderAttrStr, CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);
CFRelease(delegate);
[attributedStr insertAttributedString:placeHolderAttrStr atIndex:300];
NSDictionary * activeAttr = @{NSForegroundColorAttributeName : [UIColor redColor],@"click" : NSStringFromSelector(@selector(click))};
[attributedStr addAttributes:activeAttr range:NSMakeRange(100, 30)];
[attributedStr addAttributes:activeAttr range:NSMakeRange(400, 100)];
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedStr);
//只要在这个地方传入的path中将特殊区域排除我们获得的frame就不包含该区域,从而绘制的文本也不会在该区域中绘制。
UIBezierPath * path = [UIBezierPath bezierPathWithRect:self.bounds];
UIBezierPath * cirP = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(100, 100, 100, 200)];
[path appendPath:cirP];
_length = attributedStr.length;
_frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, _length), path.CGPath, NULL);
CTFrameDraw(_frame, context);
UIImage * image = [UIImage imageNamed:@"bd_logo1.jpeg"];
[self handleActiveRectWithFrame:_frame];
CGContextDrawImage(context, _imgFrm, image.CGImage);
CGContextDrawImage(context, cirP.bounds, [[UIImage imageNamed:@"bd_logo1.jpeg"] dw_ClipImageWithPath:cirP mode:DWContentModeScaleAspectFill].CGImage);
CFRelease(_frame);
CFRelease(frameSetter);
}
static CGFloat ascentCallBacks(void * ref)
{
return [(NSNumber *)[(__bridge NSDictionary *)ref valueForKey:@"height"] floatValue];
}
static CGFloat descentCallBacks(void * ref)
{
return 0;
}
static CGFloat widthCallBacks(void * ref)
{
return [(NSNumber *)[(__bridge NSDictionary *)ref valueForKey:@"width"] floatValue];
}
- (void)handleActiveRectWithFrame:(CTFrameRef)frame
{
NSArray * arrLines = (NSArray *)CTFrameGetLines(frame);
NSInteger count = [arrLines count];
CGPoint points[count];
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), points);
for (int i = 0; i < count; i ++)
{
CTLineRef line = (__bridge CTLineRef)arrLines[i];
NSArray * arrGlyphRun = (NSArray *)CTLineGetGlyphRuns(line);
for (int j = 0; j < arrGlyphRun.count; j ++)
{
CTRunRef run = (__bridge CTRunRef)arrGlyphRun[j];
NSDictionary * attributes = (NSDictionary *)CTRunGetAttributes(run);
CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[attributes valueForKey:(id)kCTRunDelegateAttributeName];
CGPoint point = points[i];
if (delegate == nil)
{
NSString * string = attributes[@"click"];
if (string)
{
[arrText addObject:[NSValue valueWithCGRect:[self getLocWithFrame:frame CTLine:line CTRun:run origin:point]]];
}
continue;
}
NSDictionary * metaDic = CTRunDelegateGetRefCon(delegate);
if (![metaDic isKindOfClass:[NSDictionary class]])
{
continue;
}
_imgFrm = [self getLocWithFrame:frame CTLine:line CTRun:run origin:point];
}
}
}
- (CGRect)getLocWithFrame:(CTFrameRef)frame CTLine:(CTLineRef)line CTRun:(CTRunRef)run origin:(CGPoint)origin
{
CGFloat ascent;
CGFloat descent;
CGRect boundsRun;
boundsRun.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);
boundsRun.size.height = ascent + descent;
CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
boundsRun.origin.x = origin.x + xOffset;
boundsRun.origin.y = origin.y - descent;
CGPathRef path = CTFrameGetPath(frame);
CGRect colRect = CGPathGetBoundingBox(path);
CGRect deleteBounds = CGRectOffset(boundsRun, colRect.origin.x, colRect.origin.y);
return deleteBounds;
}
- (CGRect)convertRectFromLoc:(CGRect)rect
{
return CGRectMake(rect.origin.x, self.bounds.size.height - rect.origin.y - rect.size.height, rect.size.width, rect.size.height);
}
- (CGPoint)systemPointFromScreenPoint:(CGPoint)origin
{
return CGPointMake(origin.x, self.bounds.size.height - origin.y);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
UITouch * touch = [touches anyObject];
CGPoint location = [self systemPointFromScreenPoint:[touch locationInView:self]];
// CGRect imageFrmToScreen = [self convertRectFromLoc:_imgFrm];
if (CGRectContainsPoint(_imgFrm, location))
{
NSLog(@"您点击了图片");
return;
}
[arrText enumerateObjectsUsingBlock:^(NSValue * rectV, NSUInteger idx, BOOL * _Nonnull stop) {
// CGRect textFrmToScreen = [self convertRectFromLoc:[rectV CGRectValue]];
if (CGRectContainsPoint(rectV.CGRectValue, location))
{
[self click];
*stop = YES;
}
}];
}
- (void)click
{
NSLog(@"您点击了文字");
}
@end


为什么cirP的rect是CGRectMake(100, 100, 100, 200),这个排除的区域却在那里?原因就在于UIKit坐标系统跟CoreText坐标系统的区别。

绘制椭圆图片可以借助github上的一个工具库DWImageUtils 贴上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
-(UIImage *)dw_ClipImageWithPath:(UIBezierPath *)path mode:(DWContentMode)mode
{
CGFloat originScale = self.size.width * 1.0 / self.size.height;
CGRect boxBounds = path.bounds;
CGFloat width = boxBounds.size.width;
CGFloat height = width / originScale;
switch (mode) {
case DWContentModeScaleAspectFit:
{
if (height > boxBounds.size.height) {
height = boxBounds.size.height;
width = height * originScale;
}
}
break;
case DWContentModeScaleAspectFill:
{
if (height < boxBounds.size.height) {
height = boxBounds.size.height;
width = height * originScale;
}
}
break;
default:
if (height != boxBounds.size.height) {
height = boxBounds.size.height;
}
break;
}
///开启上下文
UIGraphicsBeginImageContextWithOptions(boxBounds.size, NO, [UIScreen mainScreen].scale);
CGContextRef bitmap = UIGraphicsGetCurrentContext();
///归零path
UIBezierPath * newPath = [path copy];
[newPath applyTransform:CGAffineTransformMakeTranslation(-path.bounds.origin.x, -path.bounds.origin.y)];
[newPath addClip];
///移动原点至图片中心
CGContextTranslateCTM(bitmap, boxBounds.size.width / 2.0, boxBounds.size.height / 2.0);
CGContextScaleCTM(bitmap, 1.0, -1.0);
CGContextDrawImage(bitmap, CGRectMake(-width / 2, -height / 2, width, height), self.CGImage);
///生成图片
UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}

点击事件获取
1、主流方式:CTLineGetStringIndexForPosition

主流方式就是当前大部分基于CoreText封装的富文本展示类(包括TTTAttributedLabel、NIAttributedLabel和FTCoreTextView)中使用的方法 CTLineGetStringIndexForPosition。这个方法是获取当前点在所在文字处于当前绘制文本的索引值。事实上如果没有一些其他因素的话,能使用这个方法是最简便快捷的

但是在实际使用中CTLineGetStringIndexForPosition这个方法获取一个字的index范围是这个字前面大概半个字开始到这个字中间的位置,从这个字的中间到这个字的后半个字就会获得下一个字的index。

CTLineGetStringIndexForPosition这个方法还有另一个作用还是很好用的。这个方法最好的用处就是判断一行CTLine最多容纳多少的字符,只需把这个point的x位置调很大(超过CTFrame path的宽度)就可以了。

2、遍历CTRun比较法 (代码中的写法)
一次遍历中拿到所有活动图片和活动文字的frame,按照点击图片的处理方式处理文字,在添加点击事件的活动文本的特征点中添加加了click(属性名随便写,不要太low就行了)这么一个属性。通过遍历CTRun将活动文本的frame算出来,并存到一个数组里面arrText。

[arrText addObject:[NSValue valueWithCGRect:[self getLocWithFrame:frame CTLine:line CTRun:run origin:point]]];

注意:文字frame不同于获取图片的frame。由于图片是在一个空白占位符上绘制文字,所以一定是以一个CTRun进行绘制的。但是第一篇文章中老司机说过,每个CTRun是所有具有相同属性的连续同行文字的集合。针对CTRun的特性,我们不难想到,文字由于可能出现两行,也有可能会活动文本的字体字号等其他属性不尽相同导致一段文字由两个CTRun进行绘制,所以不能单纯的保存一个frame,而是要以一个数组容纳他。再通过一些逻辑将不同的活动文本区别开来。

参考:
CoreText实现图文混排
CoreText实现图文混排之点击事件
CoreText实现图文混排之文字环绕及点击算法
CoreText中坐标转换的一些理解
CTLineRef详细介绍
CoreText原理及基本使用方法
CoreText 实现图文混排
使用CoreText实现图文混排