美文网首页Objective-C
动态计算NSAttributedString的宽高的方法

动态计算NSAttributedString的宽高的方法

作者: brownfeng | 来源:发表于2021-08-11 18:54 被阅读0次

    动态计算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

    相关文章

      网友评论

        本文标题:动态计算NSAttributedString的宽高的方法

        本文链接:https://www.haomeiwen.com/subject/cowcbltx.html