图文混排初探

CoreText研究

coreText 官方说明

参考链接

CoreText实现图文混排之点击事件

基于 CoreText 的排版引擎

iOS富文本组件的实现

如何使用Core Text计算一段文本绘制在屏幕上之后的高度


简单绘制

直接上图



核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, self.bounds.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, self.bounds);
NSAttributedString *string = [[NSAttributedString alloc] initWithString:@"我要点击的地方戳一下"];
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef) string);
CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, NULL);
CTFrameDraw(frame, context);
CFRelease(path);
CFRelease(frameSetter);
CFRelease(frame);
}

代码解析:

CGContextRef context = UIGraphicsGetCurrentContext();

简单说就是一个绘画的区域(context)

CGContextSetTextMatrix(context, CGAffineTransformIdentity);

解释看这里,无法通俗友好描绘

CGContextTranslateCTM(context, 0, self.bounds.size.height);
CGContextScaleCTM(context, 1.0, -1.0);

坐标系转换,简单来说平时我们理解的坐标系原点在右下角

CGContextTranslateCTM(context, 0, self.bounds.size.height);

将坐标系(0,0)移动到(0, self.bounds.size.height)位置,

CGContextScaleCTM(context, 1.0, -1.0);

将坐标系沿x翻转

这样就完美转化成我们开发时的坐标系了

CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, self.bounds);

创建绘制区域

CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef) string);

CTFramesetterRef 生成 CTFrame的工厂

CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, NULL);

生成CTFrame

CTFrameCTLineCTRun 关系如下图:(CTLineCTRun后面会遇到)

CTFrameDraw(frame, context); 绘制

以上最简单的绘制结束


添加点击效果

添加之前我们先对绘制逻辑进行梳理

  1. 获取绘制区域、坐标翻转 –> HHHyperLinkLabel : UIView
  2. 生成绘制frame –> HHFrameParser
  3. 绘制 –> HHHyperLinkLabel : UIView

根据逻辑将项目分类:



HHFrameParser新增

1
2
3
4
5
6
7
8
9
10
11
+ (CTFrameRef)frameParserWithAttributedString:(NSAttributedString *)attributedString
rect:(CGRect)rect {
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, rect);
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
CFRelease(framesetter);
CFRelease(path);
return frame;
}

HHHyperLinkLabel变为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextTranslateCTM(context, 0, self.bounds.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
NSAttributedString *string = [[NSAttributedString alloc] initWithString:@"我要点击的地方戳一下" attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:12],
NSForegroundColorAttributeName:[UIColor redColor]}];
CTFrameRef frame = [HHFrameParser frameParserWithAttributedString:string rect:self.bounds];
CTFrameDraw(frame, context);
CFRelease(frame);
}

由此可见生成CTFrameRef时是需要一些参数的,而这些参数目前是从HHHyperLinkLabel中获取的

完全可以参数直接传给parser label仅仅用于展示,这样每一个类承担的功能就很单一

调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (void)viewDidLoad
{
[super viewDidLoad];
HHHyperLinkLabel *linkLabel = [[HHHyperLinkLabel alloc] init];
linkLabel.backgroundColor = [UIColor lightGrayColor];
[self.view addSubview:linkLabel];
[linkLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.view);
make.left.equalTo(self.view).offset(10);
make.right.equalTo(self.view).offset(-10);
make.height.equalTo(@100);
}];
[linkLabel setNeedsLayout];
[linkLabel layoutIfNeeded];
NSAttributedString *string = [[NSAttributedString alloc] initWithString:@"我要点击的地方戳一下" attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:12],
NSForegroundColorAttributeName:[UIColor redColor]}];
CTFrameRef frame = [HHFrameParser frameParserWithAttributedString:string rect:linkLabel.bounds];
linkLabel.frameRef = frame;
}

其次观察到rect参数、大多数时候绘制高度是不确定的所以rect是不固定的、而最大宽度大部分时候是固定的,coretext可以根据给定的宽度来计算高度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+ (CTFrameRef)frameParserWithAttributedString:(NSAttributedString *)attributedString
width:(CGFloat)width; {
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
//高度计算
CGSize restrictSize = CGSizeMake(width, CGFLOAT_MAX);
CGSize coreTextSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0, 0), nil, restrictSize, nil);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0, 0, width, coreTextSize.height));
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
CFRelease(framesetter);
CFRelease(path);
return frame;
}

计算完高度后一般会把高度回传出去来更新view高度,保证view刚好包含文字,此时Parser的输出物不仅仅是frame还有height,为了保证扩展性我们定义一个HHCoreTextData作为Parser输出的数据

HHFrameParser方法变更为

1
2
3
4
5
6
7
8
9
+ (HHCoreTextData *)frameParserWithAttributedString:(NSAttributedString *)attributedString
width:(CGFloat)width {
...
HHCoreTextData *coreTextData = [[HHCoreTextData alloc] init];
coreTextData.frameRef = frame;
coreTextData.height = coreTextSize.height;
...
return coreTextData;
}

调用改为

1
2
3
4
5
6
7
8
9
- (void)viewDidLoad
{
...
HHCoreTextData *data = [HHFrameParser frameParserWithAttributedString:string width:self.view.bounds.size.width - 20];
linkLabel.data = data;
[linkLabel mas_updateConstraints:^(MASConstraintMaker *make) {
make.height.equalTo(@(data.height));
}];
}

此时效果

下面就是添加点击效果了

  1. 获取点击触摸点
  2. 判读触摸点是否在可点击文本范围内
  3. 如果在找到点击的是哪一个可点击文本

新增HHCoreTextDataUtil处理判断逻辑

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
+ (HHCoreTextLinkModel *)linkModelWithPoint:(CGPoint)touchPoint
view:(UIView *)touchView
data:(HHCoreTextData *)data {
CFIndex idx = [self touchContentOffsetInView:touchView
andPoint:touchPoint
data:data];
return [self linkAtIndex:idx linkArray:data.linkArray];
}
+ (CFIndex)touchContentOffsetInView:(UIView *)touchView
andPoint:(CGPoint)touchPoint
data:(HHCoreTextData *)data {
CTFrameRef frame = data.frameRef;
CFArrayRef lines = CTFrameGetLines(frame);
if (!lines) {
return -1;
}
CFIndex count = CFArrayGetCount(lines);
CGPoint origins[count];
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);
CGAffineTransform transform = CGAffineTransformMakeTranslation(0, touchView.bounds.size.height);
transform = CGAffineTransformScale(transform, 1.0, -1.0);
CFIndex idx = -1;
for (int i = 0; i < count; i ++) {
CGPoint origin = origins[i];
CTLineRef lineRef = CFArrayGetValueAtIndex(lines, i);
CGRect flippedRect = [self getLineBounds:lineRef point:origin];
CGRect rect = CGRectApplyAffineTransform(flippedRect, transform);
if (CGRectContainsPoint(rect, touchPoint)) {
// 将点击的坐标转换成相对于当前行的坐标
CGPoint relativePoint = CGPointMake(touchPoint.x-CGRectGetMinX(rect),
touchPoint.y-CGRectGetMinY(rect));
// 获得当前点击坐标对应的字符串偏移
CFIndex idx = CTLineGetStringIndexForPosition(lineRef, relativePoint);
return idx;
}
}
return idx;
}
+ (CGRect)getLineBounds:(CTLineRef)line point:(CGPoint)point {
CGFloat ascent = 0.0f;
CGFloat descent = 0.0f;
CGFloat leading = 0.0f;
CGFloat width = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
CGFloat height = ascent + descent;
return CGRectMake(point.x, point.y - descent, width, height);
}
+ (HHCoreTextData *)linkAtIndex:(CFIndex)i linkArray:(NSArray *)linkArray {
HHCoreTextData *link = nil;
for (HHCoreTextLinkModel *data in linkArray) {
if (NSLocationInRange(i, data.range)) {
link = data;
break;
}
}
return link;
}