动态计算NSAttributedString的宽高的方法

动态计算NSAttributedString的宽高的方法

最近在复盘之前项目中关于文本宽高计算的实现, 这里简单归纳总结一下.

1. boundingRectWithSize 方法

文本的宽高计算的API主要有如下方法:

// NOTE: All of the following methods will default to drawing on a baseline, limiting drawing to a single line.
// To correctly draw and size multi-line text, pass NSStringDrawingUsesLineFragmentOrigin in the options parameter.
@interface NSString (NSExtendedStringDrawing)
- (void)drawWithRect:(CGRect)rect options:(NSStringDrawingOptions)options attributes:(nullable NSDictionary<NSAttributedStringKey, id> *)attributes context:(nullable NSStringDrawingContext *)context API_AVAILABLE(macos(10.11), ios(7.0));
- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options attributes:(nullable NSDictionary<NSAttributedStringKey, id> *)attributes context:(nullable NSStringDrawingContext *)context API_AVAILABLE(macos(10.11), ios(7.0));
@end

@interface NSAttributedString (NSExtendedStringDrawing)
- (void)drawWithRect:(CGRect)rect options:(NSStringDrawingOptions)options context:(nullable NSStringDrawingContext *)context API_AVAILABLE(macos(10.11), ios(6.0));
- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options context:(nullable NSStringDrawingContext *)context API_AVAILABLE(macos(10.11), ios(6.0));
@end

另外关于配置options:

options表示计算的类型

NSStringDrawingUsesLineFragmentOrigin:绘制文本时使用 line fragement origin 而不是 baseline origin。一般使用这项。
NSStringDrawingUsesFontLeading:根据字体计算高度
NSStringDrawingUsesDeviceMetrics:使用象形文字计算高度
NSStringDrawingTruncatesLastVisibleLine:如果NSStringDrawingUsesLineFragmentOrigin设置,这个选项没有用

官方文档中有部分注释:

This method draws as much of the string as it can inside the specified rectangle, wrapping the string text as needed to make it fit. If the string is too big to fit completely inside the rectangle, the method scales the font or adjusts the letter spacing to make the string fit within the given bounds.
If newline characters are present in the string, those characters are honored and cause subsequent text to be placed on the next line underneath the starting point. To correctly draw and size multi-line text, pass NSStringDrawingUsesLineFragmentOrigin in the options parameter.

因此, 是计算多行文本信息在NSStringDrawingOptions选项, 一般需要添加如下的配置, 不然计算出来的高度不准确:

NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading

另外在计算出CGSize以后, 一般得到的宽高是浮点数, 如果需要用这个Size配置给某个View作为View的Size, 还需要通过ceil 方法向上取整得到更加准确的CGSize, 也就是 ceilf(size.height)

另外计算string size 时, 使用的属性, 一定需要与View中设置文本时, 使用的属性一致!

特殊情况: 使用该方法计算完文本中中有\n或者\r\n , 会导致计算宽高不准确, 这里贴一个网上针对这个问题的解决方法(但是本人并不建议这样做):

由于这个方法计算字符串的大小的通过取得字符串的size来计算, 如果你计算的字符串中包含`\n\r `这样的字符, 也只会把它当成字符来计算。但是在显示的时候就是`\n`是转义字符,那么显示的计算的高度就不一样了, 所以可以采用:

计算的高度 = boundingRectWithSize计算出来的高度 + \n\r转义字符出现的个数 * 单行文本的高度。

2. 使用UILabel的sizeThatFits:方法

这里以指定的UILabel来作为富文本展示示例

    //    useBound label size: (0.0, 0.0, 88.0078125, 76.375)
    func useBound() -> UILabel {
        let label = UILabel()
        let attr: [NSAttributedString.Key : Any] = [
            .font: UIFont.systemFont(ofSize: 16),
            .foregroundColor : UIColor.red.cgColor,
        ]
        let attrStr = NSAttributedString(string: "hello world1\n\n\nhello world2", attributes: attr)
        let rect = attrStr.boundingRect(with: CGSize(width: 100, height: Int.max), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
        print("useBound label size: \(rect.size)")
        label.frame = CGRect(x: 100, y: 100, width: ceil(rect.width), height: ceil(rect.height));
        label.attributedText = attrStr
        label.numberOfLines = 0
        label.backgroundColor = .green
        return label
    }
    
    //   useLabel label size: (88.33333333333333, 76.66666666666667)
    func useLabel() -> UILabel {
        let label = UILabel()
        let attrStr = NSAttributedString(string: "hello world1\n\n\nhello world2", attributes: [
            .font: UIFont.systemFont(ofSize: 16),
            .foregroundColor : UIColor.blue.cgColor,
        ])
        label.attributedText = attrStr
        label.numberOfLines = 0
        let size = label.sizeThatFits(CGSize.init(width: 100, height: Int.max))
        print("useLabel label size: \(size)")
        label.frame = CGRect(x: 0, y: 100, width: size.width, height: size.height);
        label.backgroundColor = .green
        return label
    }

最后展示出来两者大小差不多的, 但是注意第一条中使用ceil向上取整操作!

3. 使用CoreText中的CTFrame计算

网上关于CoreText相关信息非常多, 简单来说就是使用iOS系统的排版, 然后通过排版以后结果信息来获取系统排版渲染以后的结果.

CTFramesetter是CTFrame的创建工厂, NSAttributedString需要通过CTFrame绘制到界面上,得到CTFrameSetter后,创建path(绘制路径), 然后得到CTFrame, 最后通过CTFrameDraw方法绘制到界面上

CTFramesetter关联NSAttributedString, 此时CTTypesetter实例将自动创建, 它管理了字体。然后使用CTFramesetter 创建您要用于渲染文本的一个或多个帧。当创建帧时, 指定一个用于此帧矩形内的子文本范围

每行文本会自动创建成CTLine , 并在CTLine内创建多个 CTRun文本分段, 每个CTRun内的文本有着同样的格式

同时每个 CTRun 对象可以采用不同的属性,所以你可以精确的控制字距,连字,宽度,高度等更多属性

常见使用CTFrame计算文本高度有三种方法, 其中建议使用CTFramesetterSuggestFrameSizeWithConstraints进行计算

3.1 获取每条CTLine信息,累加

CGFloat heightValue = 0;
//string 为要计算高的NSAttributedString
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)string);
  
//这里的高要设置足够大
CGFloat height = 10000;
CGRect drawingRect = CGRectMake(0, 0, width, height);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, drawingRect);
CTFrameRef textFrame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0,0), path, NULL);
CGPathRelease(path);
CFRelease(framesetter);
CFArrayRef lines = CTFrameGetLines(textFrame);
CGPoint lineOrigins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), lineOrigins);

/******************
 * 逐行lineHeight累加
 ******************/
heightValue = 0;
for (int i = 0; i < CFArrayGetCount(lines); i++) {
   CTLineRef line = CFArrayGetValueAtIndex(lines, i);
   CGFloat lineAscent;//上行行高
   CGFloat lineDescent;//下行行高
   CGFloat lineLeading;//行距
   CGFloat lineHeight;//行高
   //获取每行的高度
   CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, &lineLeading);
   lineHeight = lineAscent +  fabs(lineDescent) + lineLeading;
   heightValue = heightValue + lineHeight;
}
heightValue = CGFloat_ceil(heightValue);

3.2 最后一行原点y坐标加最后一行高度:

CGFloat heightValue = 0;
//string 为要计算高的NSAttributedString
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)string);
  
//这里的高要设置足够大
CGFloat height = 10000;
CGRect drawingRect = CGRectMake(0, 0, width, height);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, drawingRect);
CTFrameRef textFrame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0,0), path, NULL);
CGPathRelease(path);
CFRelease(framesetter);
CFArrayRef lines = CTFrameGetLines(textFrame);
CGPoint lineOrigins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), lineOrigins);
/******************
 * 最后一行原点y坐标加最后一行下行行高跟行距
 ******************/
heightValue = 0;
CGFloat line_y = (CGFloat)lineOrigins[CFArrayGetCount(lines)-1].y;  //最后一行line的原点y坐标
CGFloat lastAscent = 0;//上行行高
CGFloat lastDescent = 0;//下行行高
CGFloat lastLeading = 0;//行距
CTLineRef lastLine = CFArrayGetValueAtIndex(lines, CFArrayGetCount(lines)-1);
CTLineGetTypographicBounds(lastLine, &lastAscent, &lastDescent, &lastLeading);
//height - line_y为除去最后一行的字符原点以下的高度,descent + leading为最后一行不包括上行行高的字符高度
heightValue = height - line_y + (CGFloat)(fabs(lastDescent) + lastLeading);
heightValue = CGFloat_ceil(heightValue);

3.3 直接使用CTFramesetterSuggestFrameSizeWithConstraints

- (CGSize)sizeThatFits:(CGSize)size {
    NSAttributedString *drawString = self.data.attributeStringToDraw;
    if (drawString == nil) {
        return CGSizeZero;
    }

    // 通过CTFrame 获取计算的结果
    CFAttributedStringRef attributedStringRef = (__bridge CFAttributedStringRef)drawString;
    // 使用 attr Sttr 创建 FramesetterRef 内容
    // 在 main thread 中运行
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString(attributedStringRef);
    CFRange range = CFRangeMake(0, 0);
    // 渲染问题
    if (_numberOfLines > 0 && framesetter) {
        CGMutablePathRef path = CGPathCreateMutable();
        CGPathAddRect(path, NULL, CGRectMake(0, 0, size.width, size.height));
        CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
        
        // 获取全局的 lines
        CFArrayRef lines = CTFrameGetLines(frame);
        
        // 计算行
        if (nil != lines && CFArrayGetCount(lines) > 0) {
            // 最小的展示行
            NSInteger lastVisibleLineIndex = MIN(_numberOfLines, CFArrayGetCount(lines)) - 1;
            CTLineRef lastVisibleLine = CFArrayGetValueAtIndex(lines, lastVisibleLineIndex);
            
            // 获取最后一行可见行的 rangeToLayout
            CFRange rangeToLayout = CTLineGetStringRange(lastVisibleLine);
            // 搞定range ->  0 到最后一行
            range = CFRangeMake(0, rangeToLayout.location + rangeToLayout.length);
        }
        CFRelease(frame);
        CFRelease(path);
    }
    
    CFRange fitCFRange = CFRangeMake(0, 0);
    // 针对 CTFramesetter 构造一个constriants 信息
    CGSize newSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, range, NULL, size, &fitCFRange);
    if (framesetter) {
        CFRelease(framesetter);
    }
    
    // 计算出来的 newSize
    return newSize;
}

4. 猜测一下使用UILabel的sizeThatFits的原理

网上也有使用intrinsicContentSize结合preferredMaxLayoutWidth计算自适应高度的内容

UIlabel拥有intrinsicContentSize方法调用逻辑可能如下:

- (CGSize)intrinsicContentSize {
    return [self sizeThatFits:CGSizeMake(self.bounds.size.width, MAXFLOAT)];
}

- (CGSize)sizeThatFits:(CGSize)size {
    // 通过计算attrString构造CFTrame
    // 然后通过 CTFrame相关排版服务计算宽高
    // 然后处理UILabel 的 UIRectEdge 信息
}

参考

https://www.jianshu.com/p/6ed98368ceed

https://my.oschina.net/FEEDFACF/blog/1858685

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容