唐巧原博客地址:
基于 CoreText 的排版引擎:进阶 | 唐巧的博客
前篇写了CoreText绘制纯文本的学习心得,本篇将继续记录学习图文混排的心得。与前篇一样,本文还是主要记录一下宏观大纲,不会把细节的内容搬过来。如果各位同学有兴趣,可以去 基于 CoreText 的排版引擎:进阶 | 唐巧的博客。
CoreText图文混排
首先了解一个概念,什么是 CTLine
与 CTRun
。
在
CTFrame
内部,是由多个CTLine
来组成的,每个CTLine
代表一行,每个CTLine
又是由多个CTRun
来组成,每个CTRun
代表一组显示风格一致的文本。我们不用手工管理CTLine
和CTRun
的创建过程。
下图是一个CTLine
和CTRun
的示意图,可以看到,第三行的CTLine
是由 2 个CTRun
构成的,第一个CTRun
为红色大字号的左边部分,第二个CTRun
为右边字体较小的部分。
图文混排具体思路
图文混排的思路与纯文本排版的思路大体一致。与前篇一样,前三步仍然是:
获取绘制上下文context
反转坐标系
获取绘制区域高度
由于前篇说过了前三步,完全一样,就不再说了。从第四步开始有变化,并增加了第五步绘制图片,请看下面脑图
第四步:创建富文本字符串(NSAttributedString
)
与前篇的第四步相比只有创建富文本字符串(NSAttributedString
)这里不一样,所以只说这里。
可以把图文混排看作是绘制了两次,说到底第一次绘制的都是纯文本,与前篇基本一致。CoreText并不支持绘制图片,所以在第一次绘制时是用一个特殊字符作为占位符,给图片预留出绘制位置。第二次绘制图片,把对应的图片绘制到对应的占位符位置上。
下面是创建第一次绘制所需富文本的源码:
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] init];
NSMutableAttributedString *aStr1 = [[NSMutableAttributedString alloc] initWithString:@"图文混排需要绘制两遍,第一遍绘制文本,但是要在应该绘制图片的位置留下占位符" attributes:@{(id)kCTForegroundColorAttributeName : [UIColor redColor]}];
NSMutableAttributedString *imgPlh1 = [self makePlaceholder:self.imageArray.firstObject];
NSMutableAttributedString *aStr2 = [[NSMutableAttributedString alloc] initWithString:@"给占位符设置代理,代理包括了占位符的宽高" attributes:@{(id)kCTForegroundColorAttributeName : [UIColor blueColor]}];
NSMutableAttributedString *imgPlh2 = [self makePlaceholder:self.imageArray.lastObject];
NSMutableAttributedString *aStr3 = [[NSMutableAttributedString alloc] initWithString:@"最后需要确定图片的绘制位置,CTRunGetTypographicBounds()获取图片占位符的宽高,CTLineGetOffsetForStringIndex()获取占位符在这一行中x方向偏移量" attributes:@{(id)kCTForegroundColorAttributeName : [UIColor blackColor]}];
[attributedString appendAttributedString:aStr1];
[attributedString appendAttributedString:imgPlh1];
[attributedString appendAttributedString:aStr2];
[attributedString appendAttributedString:imgPlh2];
[attributedString appendAttributedString:aStr3];
在构造富文本的时候代码中调用了 makePlaceholder:
方法,传进去的参数是包含图片信息的字典。
方法一进来就先构造了图片占位符的代理,代理通过三个回调函数保存了占位符的宽高。widthCallback
返回的是宽度,ascentCallback
+ descentCallback
就是高度。ascentCallback
返回的是文字底线向上的距离, descentCallback
返回的是文字底线向下的距离,这两者之和构成了占位符的高度。三个回调函数的参数 void *ref
是函数 CTRunDelegateCreate();
传进去的 imgDict
。
接着使用 0xFFFC
作为空白占位符,构造富文本(NSMutableAttributedString
)。最后调用 CFAttributedStringSetAttribute()
函数设置占位符的代理。
需要注意的地方是:三个回调函数中的参数是 void *
类型,不是OC指针,所以在调用函数 CTRunDelegateCreate();
时传参数先要将OC对象 imgDict
转换成 void *
类型。在转换的时候要注意 imgDict
出了 makePlaceholder:
函数作用域后的引用计数,如果 imgDict
是在 makePlaceholder:
函数作用域内初始化的参数,出了作用域就会被销毁,在转换类型时可以使用 __bridge_retained
,具体原因可以去看我写的另一篇博客 iOS __bridge那些事
makePlaceholder:
方法源码
#pragma mark - 生成图片空白的占位符,并且设置其CTRunDelegate信息。
static CGFloat ascentCallback(void *ref){ // 从文字底线开始向上的边距
return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"height"] floatValue];
}
static CGFloat descentCallback(void *ref){ // 从文字底线开始向下的边距
return 0;
}
static CGFloat widthCallback(void* ref){
return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"width"] floatValue];
}
- (NSMutableAttributedString *)makePlaceholder:(NSDictionary *)imgDict {
CTRunDelegateCallbacks callbacks;
memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks)); // 将callbacks内存清零
callbacks.version = kCTRunDelegateVersion1;
callbacks.getAscent = ascentCallback;
callbacks.getDescent = descentCallback;
callbacks.getWidth = widthCallback;
// 通过 ascentCallback + descentCallback + widthCallback 就使得delegate拥有了图片位置的宽高
CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(imgDict));
// 使用 0xFFFC 作为空白的占位符
unichar objectReplacementChar = 0xFFFC;
NSString * content = [NSString stringWithCharacters:&objectReplacementChar length:1];
NSMutableAttributedString * space =
[[NSMutableAttributedString alloc] initWithString:content
attributes:@{}];
// 给 AttributedString 设置代理,代理包括了图片占位符的宽高
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space,
CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);
CFRelease(delegate);
return space;
}
第五步:绘制图片
绘制图片的函数是 CGContextDrawImage(CGContextRef c, CGRect rect, CGImageRef image)
。
CGContextDrawImage
函数需要三个参数,第一个 CGContext c
绘制上下文在第一步时已获取过。
第二个参数 CGRect rect
是图片绘制的具体位置,我们会在接下来专门说一说怎么样确定图片绘制的具体位置。
第三个参数 CGImageRef image
是你想想要绘制的图片。可以是本地图片,也可以是网络图片,总之这个参数是已知的。
先说第三个参数,我使用的懒加载构造的数组包含字典的数据结构:
#pragma mark - 图片数据源
- (NSMutableArray *)imageArray {
if (_imageArray == nil) {
_imageArray = [NSMutableArray array];
NSMutableDictionary *dict1 = [NSMutableDictionary dictionary];
dict1[@"height"] = @50;
dict1[@"width"] = @50;
dict1[@"name"] = @"wxz1.jpeg";
NSMutableDictionary *dict2 = [NSMutableDictionary dictionary];
dict2[@"height"] = @220;
dict2[@"width"] = @240;
dict2[@"name"] = @"wxz2.jpg";
[_imageArray addObject:dict1];
[_imageArray addObject:dict2];
}
return _imageArray;
}
图片资源有了,然后就需要计算图片绘制的具体位置了。捋一捋下面源码的思路:最终目的是找到占位符所在的几个 run ,通过函数 CTRunGetTypographicBounds()
可以从 run 中获取占位符的宽和高,通过函数 CTLineGetOffsetForStringIndex()
可以从 run 中获取其在本行中 x 方向的偏移量,加上行原点坐标的 x 值就是占位符的实际 x 值。
#pragma mark - 确定图片绘制位置
- (void)confirmImagePosition {
if (self.imageArray.count == 0) {
return;
}
// 从ctFrame里拿出所有“行”
NSArray *lines = (NSArray *)CTFrameGetLines(self.ctFrame);
// 总“行”数
NSUInteger lineCount = [lines count];
// 这个 lineOrigins 数组用来保存所有“行”的原点坐标
CGPoint lineOrigins[lineCount];
// 取出所有“行”的原点坐标,保存到 lineOrigins 数组中去
CTFrameGetLineOrigins(self.ctFrame, CFRangeMake(0, 0), lineOrigins);
int imgIndex = 0;
NSMutableDictionary *imageData = self.imageArray[0];
for (int i = 0; i < lineCount; ++i) {
if (imageData == nil) {
break;
}
// 循环取出每一“行”
CTLineRef line = (__bridge CTLineRef)lines[i];
// 从“行”里取出所有 run
NSArray * runObjArray = (NSArray *)CTLineGetGlyphRuns(line);
for (id runObj in runObjArray) {
// 循环取出每一个 run
CTRunRef run = (__bridge CTRunRef)runObj;
// 从 run 里取出 Attributes
NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run);
// 从 Attributes 里取出 CTRunDelegate
CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName];
// delegate为空则跳过这一个 run ,因为我们要做的是设置图片的imagePosition,只有图片设置了delegate
if (delegate == nil) {
continue;
}
/*
* RefCon 是给图片设置代理时传进去的参数 imgDict
*
* CTRunDelegateCreate(&callbacks, (__bridge void *)(imgDict));
*/
NSDictionary * metaDic = CTRunDelegateGetRefCon(delegate);
if (![metaDic isKindOfClass:[NSDictionary class]]) {
continue;
}
CGRect runBounds;
CGFloat ascent;
CGFloat descent;
// 从 run 里获取占位符的宽高,即图片应有的宽高
runBounds.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);
runBounds.size.height = ascent + descent;
// 获取这个 run 在这一“行”中 x轴方向的偏移量
CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
// 确定 run 的绘制坐标
runBounds.origin.x = lineOrigins[i].x + xOffset;
runBounds.origin.y = lineOrigins[i].y;
runBounds.origin.y -= descent;
imageData[@"imagePosition"] = @(runBounds);
imgIndex++;
if (imgIndex == self.imageArray.count) {
imageData = nil;
break;
} else {
imageData = self.imageArray[imgIndex];
}
}
}
}
至此图文混排思路已经全部说完了,不喜欢剧透的同学可以去自己尝试一下,做一个图文混排的demo了。
牛刀小试
按照上面的思路,我写了一个小demo,依然只是为了快速记忆上面的逻辑,不考虑代码的结构优化。
//
// GCDisplayView.h
// CoreTextImageText
//
// Created by 崇 on 2018.
// Copyright © 2018 崇. All rights reserved.
//
#import
@interface GCDisplayView : UIView
@property (nonatomic, assign) CGFloat textHeight;
@end
//
// GCDisplayView.m
// CoreTextImageText
//
// 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;
@property (nonatomic, strong) NSMutableArray *imageArray;
@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);
// 绘制图片
for (NSDictionary *imageDict in self.imageArray) {
UIImage *image = [UIImage imageNamed:imageDict[@"name"]];
if (image) {
CGContextDrawImage(context, [imageDict[@"imagePosition"] CGRectValue], image.CGImage);
}
}
// 释放
CFRelease(self.ctFrame);
CFRelease(self.framesetter);
}
#pragma mark - 创建 CTFrame
- (void)createCTFrame {
/*
创建 CTFrame 需要两个参数:CTFramesetter 和 CGMutablePath。
先创建 CTFramesetter,利用 CTFramesetter 计算出绘制区域高度后再创建 CGMutablePath。
创建 CTFramesetter 需要先创建 NSAttributedString。
*/
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] init];
NSMutableAttributedString *aStr1 = [[NSMutableAttributedString alloc] initWithString:@"图文混排需要绘制两遍,第一遍绘制文本,但是要在应该绘制图片的位置留下占位符" attributes:@{(id)kCTForegroundColorAttributeName : [UIColor redColor]}];
NSMutableAttributedString *imgPlh1 = [self makePlaceholder:self.imageArray.firstObject];
NSMutableAttributedString *aStr2 = [[NSMutableAttributedString alloc] initWithString:@"给占位符设置代理,代理包括了占位符的宽高" attributes:@{(id)kCTForegroundColorAttributeName : [UIColor blueColor]}];
NSMutableAttributedString *imgPlh2 = [self makePlaceholder:self.imageArray.lastObject];
NSMutableAttributedString *aStr3 = [[NSMutableAttributedString alloc] initWithString:@"最后需要确定图片的绘制位置,CTRunGetTypographicBounds()获取图片占位符的宽高,CTLineGetOffsetForStringIndex()获取占位符在这一行中x方向偏移量" attributes:@{(id)kCTForegroundColorAttributeName : [UIColor blackColor]}];
[attributedString appendAttributedString:aStr1];
[attributedString appendAttributedString:imgPlh1];
[attributedString appendAttributedString:aStr2];
[attributedString appendAttributedString:imgPlh2];
[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);
// 确定图片绘制位置
[self confirmImagePosition];
CFRelease(path);
}
#pragma mark - 生成图片空白的占位符,并且设置其CTRunDelegate信息。
static CGFloat ascentCallback(void *ref){ // 从文字底线开始向上的边距
return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"height"] floatValue];
}
static CGFloat descentCallback(void *ref){ // 从文字底线开始向下的边距
return 1;
}
static CGFloat widthCallback(void* ref){
return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"width"] floatValue];
}
- (NSMutableAttributedString *)makePlaceholder:(NSDictionary *)imgDict {
CTRunDelegateCallbacks callbacks;
memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks)); // 将callbacks内存清零
callbacks.version = kCTRunDelegateVersion1;
callbacks.getAscent = ascentCallback;
callbacks.getDescent = descentCallback;
callbacks.getWidth = widthCallback;
// 通过 ascentCallback + descentCallback + widthCallback 就使得delegate拥有了图片位置的宽高
CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(imgDict));
// 使用 0xFFFC 作为空白的占位符
unichar objectReplacementChar = 0xFFFC;
NSString * content = [NSString stringWithCharacters:&objectReplacementChar length:1];
NSMutableAttributedString * space =
[[NSMutableAttributedString alloc] initWithString:content
attributes:@{}];
// 给 AttributedString 设置代理,代理包括了图片占位符的宽高
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space,
CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);
CFRelease(delegate);
return space;
}
#pragma mark - 确定图片绘制位置
- (void)confirmImagePosition {
if (self.imageArray.count == 0) {
return;
}
// 从ctFrame里拿出所有“行”
NSArray *lines = (NSArray *)CTFrameGetLines(self.ctFrame);
// 总“行”数
NSUInteger lineCount = [lines count];
// 这个 lineOrigins 数组用来保存所有“行”的原点坐标
CGPoint lineOrigins[lineCount];
// 取出所有“行”的原点坐标,保存到 lineOrigins 数组中去
CTFrameGetLineOrigins(self.ctFrame, CFRangeMake(0, 0), lineOrigins);
int imgIndex = 0;
NSMutableDictionary *imageData = self.imageArray[0];
for (int i = 0; i < lineCount; ++i) {
if (imageData == nil) {
break;
}
// 循环取出每一“行”
CTLineRef line = (__bridge CTLineRef)lines[i];
// 从“行”里取出所有 run
NSArray * runObjArray = (NSArray *)CTLineGetGlyphRuns(line);
for (id runObj in runObjArray) {
// 循环取出每一个 run
CTRunRef run = (__bridge CTRunRef)runObj;
// 从 run 里取出 Attributes
NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run);
// 从 Attributes 里取出 CTRunDelegate
CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName];
// delegate为空则跳过这一个 run ,因为我们要做的是设置图片的imagePosition,只有图片设置了delegate
if (delegate == nil) {
continue;
}
/*
* RefCon 是给图片设置代理时传进去的参数 imgDict
*
* CTRunDelegateCreate(&callbacks, (__bridge void *)(imgDict));
*/
NSDictionary * metaDic = CTRunDelegateGetRefCon(delegate);
if (![metaDic isKindOfClass:[NSDictionary class]]) {
continue;
}
CGRect runBounds;
CGFloat ascent;
CGFloat descent;
// 从 run 里获取占位符的宽高,即图片应有的宽高
runBounds.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);
runBounds.size.height = ascent + descent;
// 获取这个 run 在这一“行”中 x轴方向的偏移量
CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
// 确定 run 的绘制坐标
runBounds.origin.x = lineOrigins[i].x + xOffset;
runBounds.origin.y = lineOrigins[i].y;
runBounds.origin.y -= descent;
imageData[@"imagePosition"] = @(runBounds);
imgIndex++;
if (imgIndex == self.imageArray.count) {
imageData = nil;
break;
} else {
imageData = self.imageArray[imgIndex];
}
}
}
}
#pragma mark - 图片数据源
- (NSMutableArray *)imageArray {
if (_imageArray == nil) {
_imageArray = [NSMutableArray array];
NSMutableDictionary *dict1 = [NSMutableDictionary dictionary];
dict1[@"height"] = @50;
dict1[@"width"] = @50;
dict1[@"name"] = @"wxz1.jpeg";
NSMutableDictionary *dict2 = [NSMutableDictionary dictionary];
dict2[@"height"] = @220;
dict2[@"width"] = @240;
dict2[@"name"] = @"wxz2.jpg";
[_imageArray addObject:dict1];
[_imageArray addObject:dict2];
}
return _imageArray;
}
@end