CoreText 图文混排第一篇

最近重温YYKit框架的时候,发现布局那里面的代码使用了大量的CoreText,这点是以前没有留意到的,看到我一脸懵逼,为了看懂里面的布局代码,只能先从简单的CoreText基础开始搞起

CoreText的介绍

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

UIKit 的UILabel 允许你通过拖拽的方式直接在IB或者SB中添加文本,但你不能改变文本的颜色和其中的单词,Core Graphics和Quartz允许你做任何系统允许的事情,但你需要为每个字形计算位置,并画在屏幕上,Core Text正结合了这两者,你可以拥有完全控制权

苹果iOS7 新推出的类库TextKit,其实就是CoreText上的封装

苹果引入TextKit的目的并非需要取代已有的CoreText框架,虽然CoreText的主要作用也是用于文字的排版和渲染,但它是一种先进而又处于底层技术,如果我们需要将文本内容直接渲染到图形上下文(Graphics context),从性能和易用性考虑,最佳方案就是使用CoreText!

富文本

简单来说富文本就是附带有每一个文字属性的字符串,在iOS中,我们有一个专门的类来处理富文本AttributeString

富文本的基本使用方法

ArributedString 也氛围NSAttributedString和NSMutableAttributedString两个类

  • -initWithString:以NSString初始化一个富文本对象
  • -setAttributes:range:为富文本中的一段范围添加一些属性,第一个参数是个NSDictionary字典,第二个参数是NSRange。
  • -addAttribute:value:range:添加一个属性
  • -addAttributes:range:添加多个属性
  • -removeAttribute:range:移除属性
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 redColor] range:NSMakeRange(13, 13)];
//    添加多个属性
    NSDictionary * dicAdd = @{NSBackgroundColorAttributeName:[UIColor yellowColor],NSLigatureAttributeName:@1};
    [attributeStr addAttributes:dicAdd range:NSMakeRange(19, 13)];
//    移除属性
    [attributeStr removeAttribute:NSFontAttributeName range:NSMakeRange(30, 9)];
    UILabel * label = [[UILabel alloc] initWithFrame:CGRectMake(200, 100, 100, 300)];
    label.numberOfLines = 0;
    label.attributedText = attributeStr;

CoreText 绘制富文本

先上代码

CoreTextDemoView


-(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);
    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":@246,@"width":@276};
    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:11];
    CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributeStr);
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddRect(path, NULL, self.bounds);
    NSInteger length = attributeStr.length;
    CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, length), path, NULL);
    CTFrameDraw(frame, context);
    
    UIImage * image = [UIImage imageNamed:@"head_sample_bg"];
    CGRect imgFrm = [self calculateImageRectWithFrame:frame];
    CGContextDrawImage(context,imgFrm, image.CGImage);
    CFRelease(frame);
    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;
}

CoreText 阶段代码解释
  • 获取上下文
CGContextRef context = UIGraphicsGetCurrentContext();//获取当前绘制上下文
  • 坐标转换
//设置字形的变换矩阵为不做图形变换 
CGContextSetTextMatrix(context, CGAffineTransformIdentity);    
//平移方法,将画布向上平移一个屏幕高
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轴正方向向下,如果注释上面的代码,可以看到效果如下


Snip20200809_7.png

,这个也就是需要加上坐标转换的原因了

  • 图片的代理的设置
/*
  事实上,图文混排就是在要插入图片的位置插入一个富文本类型的占位符。通过CTRUNDelegate设置图片
*/
 NSMutableAttributedString * attributeStr = [[NSMutableAttributedString alloc] initWithString:@"\n这里在测试图文混排,\n我是一个富文本"];
/*
 设置一个回调结构体,告诉代理该回调那些方法
 */
    CTRunDelegateCallbacks callBacks;//创建一个回调结构体,设置相关参数
    memset(&callBacks,0,sizeof(CTRunDelegateCallbacks));//memset将已开辟内存空间 callbacks 的首 n 个字节的值设为值 0, 相当于对CTRunDelegateCallbacks内存空间初始化
    callBacks.version = kCTRunDelegateVersion1;//设置回调版本,默认这个
    callBacks.getAscent = ascentCallBacks;//设置图片顶部距离基线的距离
    callBacks.getDescent = descentCallBacks;//设置图片底部距离基线的距离
    callBacks.getWidth = widthCallBacks;//设置图片宽度
  • ascent 是什么?
  • descent 又是什么?


    Snip20200809_1.png
Snip20200809_2.png

这个是一个CTRun的尺寸图,后面会说CTRun这个对象是啥,这个时候只要明白绘制图片的时候就是绘制在CTRun上面这个概念就可以了,这里先简单介绍一下CTRun绘制的坐标系中,会以origin点作为原点进行绘制,基线为过原点的x轴,ascent为CTRun顶线距基线的距离,descent即为底线基线的距离,我们绘制图片应该从原点开始绘制,图片的高度及宽度及CTRun的高度及宽度,可以通过代理设置CTRun的尺寸间接设置图片的尺寸。

  • 创建一个代理
    NSDictionary * dicPic = @{@"height":@246,@"width":@276};//创建一个图片尺寸的字典,初始化代理对象
    CTRunDelegateRef delegate = CTRunDelegateCreate(& callBacks, (__bridge void *)dicPic);//创建代理

通过代理告诉需要的图片尺寸,这里我设置代理绑定的时候绑定了一个返回图片尺寸的字典,事实上可以绑定任意对象。

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];
}

上面三个就是回调方法

  • 图片的插入
    创建一个富文本类型的图片占位符,绑定我们的代理
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);//释放(__bridge进行C与OC数据类型的转换,C为非ARC,需要手动管理)
[attributeStr insertAttributedString:placeHolderAttrStr atIndex:11];然后将占位符插入到我们的富文本中
  • 绘制文本
    绘制分为两部分,绘制文本和绘制图片,目前我们代码中的做法是在富文本中添加的图片是一个带有图片尺寸的空白占位符,绘制的时候,拿到占位符的坐标,然后在占位符的地方绘制相应大小的图片就好了
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributeStr);//一个frame的工厂,负责生成frame
CGMutablePathRef path = CGPathCreateMutable();//创建绘制区域
CGPathAddRect(path, NULL, self.bounds);//添加绘制尺寸
NSInteger length = attributeStr.length;
CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0,length), path, NULL);//工厂根据绘制区域及富文本(可选范围,多次设置)设置frame
CTFrameDraw(frame, context);//根据frame绘制文字

frameSetter 是根据富文本生成的一个frame生成的工厂,你可以通过frameseter以及你想要绘制的富文本的范围获取该CTRun的frame,但是需要注意的是获取的frame是仅仅绘制你所需要的那部分富文本的frame,可以通过打印查看frame信息

CTFrame: 0x60000394ae60>{visible string range = (0, 36), path = , attributes = (null), lines = (
    "{run count = 1, string range = (0, 1), width = 0, A/D/L = 9.24023/2.75977/0, glyph count = 1, runs = (\n\n{string range = (0, 1), string = \"\\n\", attributes = {\n    NSFont = \" font-family: \\\"Helvetica\\\"; font-weight: normal; font-style: normal; font-size: 12.00pt\";\n}}\n\n)\n}",
    "{run count = 1, string range = (1, 19), width = 228, A/D/L = 12.72/4.08/0, glyph count = 19, runs = (\n\n{string range = (1, 19), string = \"\\u8FD9\\u91CC\\u5728\\u6D4B\\u8BD5\\u56FE\\u6587\\u6DF7\\u6392\\u91CC\\u5728\\u6D4B\\u8BD5\\u56FE\\u6587\\u6DF7\\u6392\\u91CC\\u5728\", attributes = {\n    NSFont = \" font-family: \\\"PingFangSC-Regular\\\"; font-weight: normal; font-style: normal; font-size: 12.00pt\";\n}}\n\n)\n}",
    "{run count = 3, string range = (20, 9), width = 360, A/D/L = 246/4.08/0, glyph count = 9, runs = (\n\n{string range = (20, 1), string = \"\\uFFFC\", attributes = {\n    CTRunDelegate = \"\";\n    NSFont = \" font-family: \\\"Helvetica\\\"; font-weight: normal; font-style: normal; font-size: 12.00pt\";\n}}\n\n\n{string range = (21, 7), string = \"\\u6D4B\\u8BD5\\u56FE\\u6587\\u6DF7\\u6392\\uFF0C\", attributes = {\n    NSFont = \" font-family: \\\"PingFangSC-Regular\\\"; font-weight: normal; font-style: normal; font-size: 12.00pt\";\n}}\n\n\n{string range = (28, 1), string = \"\\n\", attributes = {\n    NSFont = \" font-family: \\\"Helvetica\\\"; font-weight: normal; font-style: normal; font-size: 12.00pt\";\n}}\n\n)\n}",
    "{run count = 1, string range = (29, 7), width = 84, A/D/L = 12.72/4.08/0, glyph count = 7, runs = (\n\n{string range = (29, 7), string = \"\\u6211\\u662F\\u4E00\\u4E2A\\u5BCC\\u6587\\u672C\", attributes = {\n    NSFont = \" font-family: \\\"PingFangSC-Regular\\\"; font-weight: normal; font-style: normal; font-size: 12.00pt\";\n}}\n\n)\n}"
)}

也就是说,你绘制的范围定位(0,36),那么得到的尺寸是只会绘制(0,36)的尺寸,可以通过更改self.bounds测试查看效果

  • frame 的获取
-(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;
}

在这里我们需要知道一个概念先,就是
Snip20200810_3.png

CTFrame绘制的原理

  • CTLine可以认为是CoreText绘制中的行对象,通过当前line对象可以获取相关的ascent,line,line leading,Glyph Runs
  • CTRun(Glyph Run)是一组共享相同attributes(属性)的字形的集合体
它们之间的关系可以简单的理解为CTFrame有多个CTLine组成,CTLine又包含多个CTRun,而CTRun就是富文本的基本绘制单元
 NSArray * arrLines = (NSArray *)CTFrameGetLines(frame);
    NSInteger count = [arrLines count];
    CGPoint points[count];
    CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), points);

获取数组CTLine的个数,然后通过CTFrameGetLineOrigins 获取所有的原点
然后就是查找图片所在的CTLine,获取其相应的CTRun(在CTLine对象中通过CTLine的orgin加上相应的偏移量获取CTRun的原点x,过CTLine的orgin - descent获取CTRun的原点y)

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;
}

外层for循环就是获取CTFrame中的所有CTLine,然后通过CTLineGetGlyphRuns获取一个CTLine中的所有CTRun,内层for循环就是检查每个CTRun,通过CTRunGetAttributes获取属性,通过kvc获取代理delegate,判断delegate是否为空,由于前面我们图片是绑定了代理的,所以当判断是文字的时候只会continue,通过方法CTRunGetTypographicBounds 传入ascent和descent,获取宽度的同时也获取了ascent和descent,获取X偏移量CTLineGetOffsetForStringIndex,通过CTLine的原点Y,减去图片的下面距descent,从上面的图,可以知道为什么要减去才是CTRun的原点y值,计算完成之后,直接return图片的frame值即可

最终效果如下:
Snip20200810_4.png

你可能感兴趣的:(CoreText 图文混排第一篇)