iOS文本尺寸自适应异步计算实现

目前市面上的非UI线程文本算高方法或多或少都有一些问题。本文通过逆向和分析UILabel的sizeThatFits方法实现来得到一个最佳的文本算高的精简方法。方法可以运行在任意线程,因此可以有效的应用在那些异步算高或者要求尺寸进行提前计算的场景中。

从iOS官方的实现中可以看出文本算高会考虑简单文本字符串、属性字符串、字体大小、最大显示行数numberOfLines、段落信息、 段落的对齐方式、断字方式、段落的首行缩进、阴影偏移等等因素。下面就是具体的实现代码:

/// 使用此方法时请标明源作者:欧阳大哥2013。本方法符合MIT协议规范。
/// github地址:https://github.com/youngsoft
/// 计算简单文本或者属性字符串的自适应尺寸
/// @param fitsSize 指定限制的尺寸,参考UILabel中的sizeThatFits中的参数的意义。
/// @param text 要计算的简单文本NSString或者属性字符串NSAttributedString对象
/// @param numberOfLines 指定最大显示的行数,如果为0则表示不限制最大行数
/// @param font 指定计算时文本的字体,可以为nil表示使用UILabel控件的默认17号字体
/// @param textAlignment 指定文本对齐方式默认是NSTextAlignmentNatural
/// @param lineBreakMode 指定多行时断字模式,默认可以用UILabel的默认断字模式NSLineBreakByTruncatingTail
/// @param minimumScaleFactor 指定文本的最小缩放因子,默认填写0。这个参数用于那些定宽时可以自动缩小文字字体来自适应显示的场景。
/// @param shadowOffset 指定阴影的偏移位置,需要注意的是这个偏移位置是同时指定了阴影颜色和偏移位置才有效。如果不考虑阴影则请传递CGSizeZero,否则阴影会参与尺寸计算。
/// @return 返回自适应的最合适尺寸
CGSize calcTextSize(CGSize fitsSize, id text, NSInteger numberOfLines, UIFont *font, NSTextAlignment textAlignment, NSLineBreakMode lineBreakMode, CGFloat minimumScaleFactor, CGSize shadowOffset) {
    
    if (text == nil || [text length] <= 0) {
        return CGSizeZero;
    }
    
    NSAttributedString *calcAttributedString = nil;

    //如果不指定字体则用默认的字体。
    if (font == nil) {
        font = [UIFont systemFontOfSize:17];
    }
    
    CGFloat systemVersion = [UIDevice currentDevice].systemVersion.floatValue;
        
    NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
    paragraphStyle.alignment = textAlignment;
    paragraphStyle.lineBreakMode = lineBreakMode;
    //系统大于等于11才设置行断字策略。
    if (systemVersion >= 11.0) {
        @try {
            [paragraphStyle setValue:@(1) forKey:@"lineBreakStrategy"];
        } @catch (NSException *exception) {}
    }
        
    if ([text isKindOfClass:NSString.class]) {
        calcAttributedString = [[NSAttributedString alloc] initWithString:(NSString *)text attributes:@{NSFontAttributeName:font, NSParagraphStyleAttributeName:paragraphStyle}];
    } else {
        NSAttributedString *originAttributedString = (NSAttributedString *)text;
        //对于属性字符串总是加上默认的字体和段落信息。
        NSMutableAttributedString *mutableCalcAttributedString = [[NSMutableAttributedString alloc] initWithString:originAttributedString.string attributes:@{NSFontAttributeName:font, NSParagraphStyleAttributeName:paragraphStyle}];
        
        //再附加上原来的属性。
        [originAttributedString enumerateAttributesInRange:NSMakeRange(0, originAttributedString.string.length) options:0 usingBlock:^(NSDictionary<NSAttributedStringKey,id> * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {
            [mutableCalcAttributedString addAttributes:attrs range:range];
        }];
        
        //这里再次取段落信息,因为有可能属性字符串中就已经包含了段落信息。
        if (systemVersion >= 11.0) {
            NSParagraphStyle *alternativeParagraphStyle = [mutableCalcAttributedString attribute:NSParagraphStyleAttributeName atIndex:0 effectiveRange:NULL];
            if (alternativeParagraphStyle != nil) {
                paragraphStyle = (NSMutableParagraphStyle*)alternativeParagraphStyle;
            }
        }
        
        calcAttributedString = mutableCalcAttributedString;
    }
    
    //调整fitsSize的值, 这里的宽度调整为只要宽度小于等于0或者显示一行都不限制宽度,而高度则总是改为不限制高度。
    fitsSize.height = FLT_MAX;
    if (fitsSize.width <= 0 || numberOfLines == 1) {
        fitsSize.width = FLT_MAX;
    }
        
    //构造出一个NSStringDrawContext
    NSStringDrawingContext *context = [[NSStringDrawingContext alloc] init];
    context.minimumScaleFactor = minimumScaleFactor;
    @try {
        //因为下面几个属性都是未公开的属性,所以我们用KVC的方式来实现。
        [context setValue:@(numberOfLines) forKey:@"maximumNumberOfLines"];
        if (numberOfLines != 1) {
            [context setValue:@(YES) forKey:@"wrapsForTruncationMode"];
        }
        [context setValue:@(YES) forKey:@"wantsNumberOfLineFragments"];
    } @catch (NSException *exception) {}
       

    //计算属性字符串的bounds值。
    CGRect rect = [calcAttributedString boundingRectWithSize:fitsSize options:NSStringDrawingUsesLineFragmentOrigin context:context];
    
    //需要对段落的首行缩进进行特殊处理!
    //如果只有一行则直接添加首行缩进的值,否则进行特殊处理。。
    CGFloat firstLineHeadIndent = paragraphStyle.firstLineHeadIndent;
    if (firstLineHeadIndent != 0.0 && systemVersion >= 11.0) {
        //得到绘制出来的行数
        NSInteger numberOfDrawingLines = [[context valueForKey:@"numberOfLineFragments"] integerValue];
        if (numberOfDrawingLines == 1) {
            rect.size.width += firstLineHeadIndent;
        } else {
            //取内容的行数。
            NSString *string = calcAttributedString.string;
            NSCharacterSet *charset = [NSCharacterSet newlineCharacterSet];
            NSArray *lines = [string componentsSeparatedByCharactersInSet:charset]; //得到文本内容的行数
            NSString *lastLine = lines.lastObject;
            NSInteger numberOfContentLines = lines.count - (NSInteger)(lastLine.length == 0);  //有效的内容行数要减去最后一行为空行的情况。
            if (numberOfLines == 0) {
                numberOfLines = NSIntegerMax;
            }
            if (numberOfLines > numberOfContentLines)
                numberOfLines = numberOfContentLines;
            
            //只有绘制的行数和指定的行数相等时才添加上首行缩进!这段代码根据反汇编来实现,但是不理解为什么相等才设置?
            if (numberOfDrawingLines == numberOfLines) {
                rect.size.width += firstLineHeadIndent;
            }
        }
    }
    
    //取fitsSize和rect中的最小宽度值。
    if (rect.size.width > fitsSize.width) {
        rect.size.width = fitsSize.width;
    }
    
    //加上阴影的偏移
    rect.size.width += fabs(shadowOffset.width);
    rect.size.height += fabs(shadowOffset.height);
       
    //转化为可以有效显示的逻辑点, 这里将原始逻辑点乘以缩放比例得到物理像素点,然后再取整,然后再除以缩放比例得到可以有效显示的逻辑点。
    CGFloat scale = [UIScreen mainScreen].scale;
    rect.size.width = ceil(rect.size.width * scale) / scale;
    rect.size.height = ceil(rect.size.height *scale) / scale;
    
    return rect.size;
}

//上述方法的精简版本
NS_INLINE CGSize calcTextSizeV2(CGSize fitsSize, id text, NSInteger numberOfLines, UIFont *font) {
    return calcTextSize(fitsSize, text, numberOfLines, font, NSTextAlignmentNatural, NSLineBreakByTruncatingTail,0.0, CGSizeZero);
}

下面是具体的验证测试用例(用例在iOS9到iOS13上运行通过):

   CFTimeInterval simpleTextUILabelInterval = 0;
    CFTimeInterval simpleTextNOUILabelInterval = 0;
    CFTimeInterval attributedTextUILabelInterval = 0;
    CFTimeInterval attributedTextNOUILabelInterval = 0;
    NSArray *testStringArray = @[@"您",@"好",@"中",@"国",@"w",@"i",@"d",@"t",@"h",@",",@"。",@"a",@"b",@"c",@"\n", @"1",@"5",@"2",@"j",@"A",@"J",@"0",@"🆚",@"👃",@" "];
    srand(time(NULL));
    for (int i = 0; i < 5000; i++) {
        //随机生成0到100个字符。
        int textLength = rand() % 100;
        NSMutableString *text = [NSMutableString new];
        for (int j = 0; j < textLength; j++) {
            [text appendString:testStringArray[rand()%testStringArray.count]];
        }
        if (text.length == 0)
            continue;
        
        CGSize fitSize = CGSizeMake(rand()%1000, rand()%1000);
        
        //测试简单文本。
        UILabel *label = [UILabel new];
        label.text = text;
        label.numberOfLines = rand() % 100;
        label.textAlignment = rand() % 5;
        label.lineBreakMode = rand() % 7;
        label.font = [UIFont systemFontOfSize:rand()%30 + 5.0];
       
        CFTimeInterval start = CACurrentMediaTime();
        CGSize sz1 = [label sizeThatFits:fitSize];
        simpleTextUILabelInterval += CACurrentMediaTime() - start;
        start = CACurrentMediaTime();
        CGSize sz2 = calcTextSize(fitSize, label.text, label.numberOfLines, label.font, label.textAlignment, label.lineBreakMode, label.minimumScaleFactor, CGSizeZero);
        simpleTextNOUILabelInterval += CACurrentMediaTime() - start;
        NSAssert(CGSizeEqualToSize(sz1, sz2), @"");
        
        //测试富文本
        NSRange range1 = NSMakeRange(0, rand()%text.length);
        NSMutableParagraphStyle *paragraphStyle1 = [[NSMutableParagraphStyle alloc] init];
        paragraphStyle1.lineSpacing = rand() % 20;
        paragraphStyle1.firstLineHeadIndent = rand() %10;
        paragraphStyle1.paragraphSpacing = rand() % 30;
        paragraphStyle1.headIndent = rand() % 10;
        paragraphStyle1.tailIndent = rand() % 10;
        UIFont *font1 = [UIFont systemFontOfSize:rand() % 20 + 3.0];
        
        NSRange range2 = NSMakeRange(range1.length, text.length - range1.length);
        NSMutableParagraphStyle *paragraphStyle2 = [[NSMutableParagraphStyle alloc] init];
        paragraphStyle2.lineSpacing = rand() % 20;
        paragraphStyle2.firstLineHeadIndent = rand() %10;
        paragraphStyle2.paragraphSpacing = rand() % 30;
        paragraphStyle2.headIndent = rand() % 10;
        paragraphStyle2.tailIndent = rand() % 10;
        UIFont *font2 = [UIFont systemFontOfSize:rand() % 20 + 3.0];
        
        NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
        [attributedText addAttributes:@{NSParagraphStyleAttributeName:paragraphStyle1,NSFontAttributeName:font1} range:range1];
        [attributedText addAttributes:@{NSParagraphStyleAttributeName:paragraphStyle2,NSFontAttributeName:font2} range:range2];

        label = [UILabel new];
        label.numberOfLines = rand() % 100;
        label.textAlignment = rand() % 5;
        label.lineBreakMode = rand() % 7;
        label.font = [UIFont systemFontOfSize:rand()%30 + 5.0];
        label.attributedText = attributedText;
        
        start = CACurrentMediaTime();
        CGSize sz3 = [label sizeThatFits:fitSize];
        attributedTextUILabelInterval += CACurrentMediaTime() - start;
        start = CACurrentMediaTime();
        CGSize sz4 = calcTextSize(fitSize, label.attributedText, label.numberOfLines, label.font, label.textAlignment, label.lineBreakMode, 0.0, CGSizeZero);
        attributedTextNOUILabelInterval += CACurrentMediaTime() - start;
        NSAssert(CGSizeEqualToSize(sz3, sz4), @"");
    }
    
    simpleTextUILabelInterval *= 1000;
    simpleTextNOUILabelInterval *= 1000;
    attributedTextUILabelInterval *= 1000;
    attributedTextNOUILabelInterval *= 1000;
    NSLog(@"简单文本计算UILabel总耗时(毫秒):%.3f, 平均耗时:%.3f",simpleTextUILabelInterval, simpleTextUILabelInterval / 5000);
    NSLog(@"简单文本计算非UILabel总耗时(毫秒):%.3f, 平均耗时:%.3f",simpleTextNOUILabelInterval, simpleTextNOUILabelInterval / 5000);
    NSLog(@"富文本计算UILabel总耗时(毫秒):%.3f, 平均耗时:%.3f",attributedTextUILabelInterval, attributedTextUILabelInterval / 5000);
    NSLog(@"富文本计算非UILabel总耗时(毫秒):%.3f, 平均耗时:%.3f",attributedTextNOUILabelInterval, attributedTextNOUILabelInterval / 5000);
        

关注: 欧阳大哥2013简书|欧阳大哥2013掘金|欧阳大哥2013Github

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