CoreText实现图文混排之尺寸估算及文本选择

2字数 3089阅读 1886
CoreText实现图文混排之尺寸估算及文本选择

系列文章:


回头看看,距离CoreText系列首发过去一年也多了,看到第一篇文章即将超越1.3W的点击量老司机也是压力越来越大,毕竟作为瞎逼逼杰出代表的老司机偶尔也要正经一下

一脸正经

这篇文章的主要目的是回答童靴们的问题,因为有童靴问过我尺寸估算和文本选择的问题,当时由于的确没有研究那方面内容所以回答的时候均没有给出解决方案,只提供思路。后来闲下来就研究了一下这两方面内容,研究后就把研究结果放出来,也算给后来的童靴们一个思路吧。至于问我的那两个童靴,我实在想不起来你俩是谁了,没法私信你俩了,抱歉。

废话这么多,在这进入主题,所以今天的博客中你将会看到如下内容:

  • CoreText做排版时如何进行尺寸估算
  • 如何实现TextView中类似的文本选择效果
  • CoreText一些API中一些已知bug

尺寸估算

说到尺寸估算,事实上同学们应该记得老司机在第一篇科普中提到过CoreText提供的一个尺寸估算的函数CTFramesetterSuggestFrameSizeWithConstraints
那么老司机再次介绍一下这个函数:

这个函数需要传入一下参数:

  • framesetter : 需要进行尺寸估算的framesetter(即绘制工厂)对象,此对象仅由需要绘制的富文本即可生成。
  • stringRange : 需要参与计算尺寸的文本范围。(比如长度为200的字符串,而你仅想计算前100个字的估算尺寸的话,可以通过这个参数调整)。
  • frameAttributes : 富文本的一些其他属性,这些属性将会影响排版效果,这个参数稍后会细讲。
  • constraints : 尺寸约束,就是尺寸估算的最大边界,其使用方法类似于[UIView sizeThatFits:size] 中size的用法。
  • * fitRange : 约束内的文本范围。及文本长度很长,在约束尺寸内无法完整绘制时,fitRange会被赋值为约束内可展示的范围。

所以说通过这个方法,我们可以像使用[UIView sizeThatFits:size]这个方法一样计算出一段文本的预估尺寸,但是问题还没有这么简单的到此结束:

如果想要绘制的文本中,存在排除区域的话,只能通过frameAttributes参数进行配置。

这个属性配置排除区域是这个样子的:

///返回排除区域字典
NSDictionary * getExclusionDic(NSArray * paths) {
    if (paths.count == 0) {
        return NULL;
    }
    NSMutableArray *pathsArray = [[NSMutableArray alloc] init];
    [paths enumerateObjectsUsingBlock:^(UIBezierPath * obj, NSUInteger idx, BOOL * _Nonnull stop) {
        NSDictionary *clippingPathDictionary = [NSDictionary dictionaryWithObject:(__bridge id)(obj.CGPath) forKey:(__bridge NSString *)kCTFramePathClippingPathAttributeName];
        [pathsArray addObject:clippingPathDictionary];
    }];
    return [NSDictionary dictionaryWithObjectsAndKeys:pathsArray,kCTFrameClippingPathsAttributeName, nil];
}

上面是老司机写的返回排除区域对应的frameAttributes的函数,其中paths是一个装有想要排除区域的路径的数组(别忘了这个路径需要是坐标转换后的路径)。

但是问题就在这个排除区域上。frameAttributes可以传入一切富文本所需要的属性,但是如果此处传入的frameAttributes排除区域数组的确含有需要排除的区域时,计算出来的尺寸高度将会为0。而此函数如果传入的frameAttributes没有要排除的区域则计算出来的尺寸则是准确的。对于这个问题产生的原因,老司机并没有找到相关资料,只会在Stack Overflow上看到别人提过这么一句:

This seems to me like an iOS7 bug. I have been tinkering around, and under iOS6, CTFramesetterSuggestFrameSizeWithConstraints returns a size with height larger than 0. Same code under iOS7 returns a height of 0.

-----出自关于CTFramesetterSuggestFrameSizeWithConstraints的讨论

大概的意思就是,这是iOS7之后的bug,iOS6及之前这个API倒是没什么问题。

所以我们现在要考虑的就应该是,如果真有排除区域的话,我们要如何计算预估尺寸呢?

老司机的想法是取每个CTLine的尺寸后,取并集即为所有CTLine所需尺寸。然后再对所有排除区域的尺寸取并集,即为绘制区域的尺寸

以上就是老司机对有排除区域的预估尺寸的思路,以下则是代码(这个没有专门写demo,截取自老司机的DWCoreTextLabel中对-sizeThatFits:方法的重写实现):

-(CGSize)sizeThatFits:(CGSize)size {
    ///计算绘制尺寸限制
    CGFloat limitWidth = (size.width - self.textInsets.left - self.textInsets.right) > 0 ? (size.width - self.textInsets.left - self.textInsets.right) : 0;
    CGFloat limitHeight = (size.height - self.textInsets.top - self.textInsets.bottom) > 0 ? (size.height - self.textInsets.top - self.textInsets.bottom) : 0;
    
    ///获取排除区域(考虑偏移矫正,保证正确绘制)
    NSArray * exclusionPaths = [self handleExclusionPathsWithOffset:self.textInsets.bottom - self.textInsets.top];
    CGRect frame = CGRectMake(self.textInsets.left, self.textInsets.bottom, limitWidth, limitHeight);
    NSDictionary * exclusionConfig = getExclusionDic(exclusionPaths, frame);
    BOOL needDrawString = self.attributedText.length || self.text.length;
    
    NSMutableAttributedString * mAStr = nil;
    if (needDrawString) {
        ///获取要绘制的文本(初步处理,未处理插入图片、句尾省略号、高亮)
        mAStr = getMAStr(self,limitWidth,exclusionPaths);
    }
    CTFramesetterRef frameSetter4Cal = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)mAStr);
    CTFrameRef frame4Cal = CTFramesetterCreateFrame(frameSetter4Cal, CFRangeMake(0, 0), [UIBezierPath bezierPathWithRect:frame].CGPath, (__bridge_retained CFDictionaryRef)exclusionConfig);
    
    CFRange visibleRange = getRangeToDrawForVisibleString(frame4Cal);
    
    ///处理插入图片
    if (needDrawString) {
        NSMutableArray * arrInsert = self.insertImageArr.copy;
        if (arrInsert.count) {
            ///富文本插入图片占位符
            [self handleStr:mAStr withInsertImageArr:arrInsert arrLocationImgHasAdd:[NSMutableArray array]];
            ///插入图片后重新处理工厂及frame,添加插入图片后的字符串,消除插入图片影响
            CFSAFERELEASE(frameSetter4Cal)
            CFSAFERELEASE(frame4Cal)
            frameSetter4Cal = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)mAStr);
            frame4Cal = CTFramesetterCreateFrame(frameSetter4Cal, CFRangeMake(0, 0), [UIBezierPath bezierPathWithRect:frame].CGPath, (__bridge_retained CFDictionaryRef)exclusionConfig);
            visibleRange = getRangeToDrawForVisibleString(frame4Cal);
        }
    }
    
    if (exclusionPaths.count == 0) {///如果没有排除区域则使用系统计算函数
        CGSize restrictSize = CGSizeMake(limitWidth, MAXFLOAT);
        if (self.numberOfLines == 1) {
            restrictSize = CGSizeMake(MAXFLOAT, MAXFLOAT);
        }
        CGSize suggestSize = CTFramesetterSuggestFrameSizeWithConstraints(frameSetter4Cal, visibleRange, nil, restrictSize, nil);
        CFSAFERELEASE(frameSetter4Cal);
        CFSAFERELEASE(frame4Cal);
        return CGSizeMake(suggestSize.width + self.textInsets.left + self.textInsets.right, suggestSize.height + self.textInsets.top + self.textInsets.bottom);
    }
    
    ///计算drawFrame及drawPath
    UIBezierPath * drawP = [self handleDrawFrameAndPathWithLimitWidth:limitWidth limitHeight:limitHeight frameSetter:frameSetter4Cal rangeToDraw:visibleRange exclusionPaths:exclusionPaths];
    
    CFSAFERELEASE(frameSetter4Cal)
    CFSAFERELEASE(frame4Cal)
    
    ///绘制的工厂
    CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)mAStr);
    ///绘制范围为可见范围加1,防止末尾省略号失效(由于path为可见尺寸,故仅绘制可见范围时有的时候末尾的省略号会失效,同时不可超过字符串本身长度)
    CFRange drawRange = CFRangeMake(0, visibleRange.length < mAStr.length ? visibleRange.length + 1 : mAStr.length);
    CTFrameRef visibleFrame = CTFramesetterCreateFrame(frameSetter, drawRange, drawP.CGPath, (__bridge_retained CFDictionaryRef)exclusionConfig);
    
    __block CGRect desFrame = CGRectZero;
    
    DWCoreTextLayout * layout = [DWCoreTextLayout layoutWithCTFrame:visibleFrame convertHeight:size.height considerGlyphs:NO];
    
    [layout.lines enumerateObjectsUsingBlock:^(DWCTLineWrapper * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        CGRect temp = obj.frame;
        desFrame = CGRectUnion(temp, desFrame);
    }];
    
    CFSAFERELEASE(frameSetter)
    CFSAFERELEASE(visibleFrame)
    
    desFrame = CGRectMake(0, 0, ceil(desFrame.origin.x + desFrame.size.width + self.textInsets.right), ceil(desFrame.origin.y + desFrame.size.height + self.textInsets.bottom));
    
    ///重新获取为矫正偏移的图片实际绘制Path
    exclusionPaths = [self handleExclusionPathsWithOffset:0];
    [exclusionPaths enumerateObjectsUsingBlock:^(UIBezierPath * obj, NSUInteger idx, BOOL * _Nonnull stop) {
        desFrame = CGRectUnion(obj.bounds, desFrame);
    }];
    
    CGRect limitRect = CGRectMake(0, 0, size.width, size.height);
    desFrame = CGRectIntersection(limitRect, desFrame);
    return desFrame.size;
}

方法中可能有些方法是老司机的工具方法,其实都不重要,只要看注释就可以了。想知道每个方法的具体实现,你还是需要去他的出处去看看DWCoreTextLabel

顺带一提的是,老司机在查找这个CTFramesetterSuggestFrameSizeWithConstraints函数的时候刚好对排除区域绘制时的实现有了新的思路。第三篇文章中,老司机介绍的是,在计算出的绘制区域drawPath中直接拼接上需要排除的区域的路径,那么根据奇偶原则自然就排除了所选区域。而这次老司机的新思路则是,在生成CTFrame的函CTFramesetterCreateFrame中做手脚。此函数也有frameAttributes这个参数,传入上文中提到的排除区域字典的话也可达到排除区域的效果。

不过这两个实现方式在效果上还有一点小区别:

drawPath拼接的思路中,如果两个排除区域有交集,根据奇偶原则,交集则会被认为是非排除区域。而frameAttributes传入配置字典这种方式中,则交集仍为排除区域。

两种方案效果对比

根据这个特点结合老司机的DWCoreTextLabel中的相关需求,老司机选择了更为合适的第二种思路并在DWCoreTextLabel中做了相关修改。不过这两种思路并没有优劣之分,还是要根据具体的需求来选择。


选中效果

这个也是当时有童靴问我的,当时也是一脸懵逼。因为这个东西我在研究TextView的时候的确是想研究过得,不过系统这部分内容并没有公开,也只有通过runtime追踪到TextView是借助UITextSelectionView这么一个私有类完成的,更多的资料也不是很多。

既然不能通过系统固有类实现相关需求,那么我们还是自己分析一下需求。事实上我们只需要拿到每个字形的尺寸,然后在上方覆盖一个淡蓝色的覆盖层即可模拟出选中效果。至于拿到每个字形的尺寸,这里我们借助CoreText还是可以做到的。

老司机在DWCoreTextLabel中做了如下的一层逻辑封装:

DWCoreTextLayout

DWCoreTextLayout对象以CTFrame对象进行初始化,后自动解析出全部CTLine、CTRun及每一个字形,并获取尺寸等其相关信息。并且每个包装类包装类之间是一个类似于链表的结构,用于快速获取上一个或下一个对应的Line、Run或者Glyph。并且Line以数组形式持有者Run,而Run又以弱引用形式引用着Line,Run与Glyph之间也保持着同样的关系。有这这样的关系存在,就可以迅速的把屏幕中的点转换为对应的字。

有了DWCoreTextLayout对象的存在,我们能获取每个字形对应的尺寸,也就能获取一段文字所对应的尺寸,只要在对应尺寸出覆盖淡蓝色选择遮罩层即可。具体实现代码也不少,老司机在此只提供思路,想看实现的话还是去DWCoreTextLayout.m中看具体代码吧。

我想覆盖遮罩层各位童靴应该不在话下,然而此处还有一定啊就是如何进入选中状态。TextView中是当我们在文字上双击文字后进入选择状态,那我们捕捉双击状态的时候要么是双击手势,要么是touchBegan方法处理。我们知道我们的Label控件中为文字添加点击事件时借助的是touchBegan系列方法,不论是那种方法我们都要处理好相关逻辑,否则会存在冲突。这里老司机的建议是以双击手势唤起选择状态,并且将Label的点击事件至于touchEnd方法中触发。这是因为双击手势如果生效取消touchesEnd的回调,这样就避免了冲突。


CoreText提供的一些函数的bug

  • CTFramesetterSuggestFrameSizeWithConstraints 上文中提到过这个函数在传入排除区域时是有bug的。
  • CTRunGetStringRange 这个函数在当前文字如果在某位为不能完全展示的文字添加省略模式的时候,最后一个CTRun的计算range会计算错误。
  • CTLineGetOffsetForStringIndex 这个函数同样是针对省略号模式下会错误的返回0。
  • 以及排除区域时,我们讨论了两种排除区域的方案,但是这两种方案都不能避免一个问题,那就是当排除区域非矩形区域且与绘制区域间距小于一个字形宽度时,CoreText绘制的文字会有一部分与排除区域重合。这个问题我们可以通过修正排除区域的位置或形状来避免。暂未找到完美的解决方案。
没招

参考资料:


本期并没有写Demo,毕竟代码量有点大,而且相互间依赖性大,demo几乎就是DWCoreTextLabel全部代码。

DWCoreTextLabel

不过第三篇中留有Demo地址,目的就是想方便童鞋们直接下载demo,不要再问我要了,所以在前两篇中明显的位置都有声明demo在第三篇文章中。但是还是有童靴直接留言要demo的,想必一定是老司机口水话太多没有耐心看下去吧。但是老司机真的觉得你连原理看都不看一眼的话要demo你也看不懂,所以在这声明一下吧,今天往后再要demo的童靴,不好意思哟,我不会回复的。

我有特权

我是广告

DWCoreTextLabel我已经升级到了v1.2.3版本,这个版本中我添加了预估尺寸的计算方案以及文本选择的API。废话不说,看效果吧:

DWCoreTextLabel
DWCoreTextLabel

你以为这就结束了?太天真了!
这期有两个广告!!!
老司机最近为了方便公司测试调试,以为我们程序员追踪问题,写了一个小东西,放图:

DWLogger仓库

DWLogger
DWLogger

这是一个日志助手类,他可以帮助你在App中直接查看输出的日志,同时不影响电脑端的日志输出。

更多情况下他可以让你在未连接电脑的情况下同样可以查看输出的日志,这将会解救你的测试妹妹,发生问题他也有了一定查看问题的方式。同时他将自动备份日志至磁盘,以帮助你分析数据的时候使用,当然,他也可以自动收集崩溃日志,当测试妹妹崩溃后,你可以直接查看日志和截图而不是苦逼的去复现。他还可以帮助你为日志划分等级,以方便你分等级查看日志,同时你也可以使用搜索功能来查找特定日志。

喜欢的话,给我个Star吧~

推荐阅读更多精彩内容