曲折的“修改 attributeText 的文字”尝试

需求

在商品列表的设计中,很多商品卡片的商品名称需要换行。效果如,

商品图

如“耐穿又耐看, 男式基础休闲牛津纺衬衫”, 用 UILabel 实现。但样式不能用以下代码来实现,

label.textColor = [UIColor gray2Color];
label.font = [UIFont bold14];

因为设计稿中,文字是带有行高、间距、baselineOffset 等信息,所以需要使用 attributedText来实现。举例;

        NSMutableParagraphStyle *style = [NSMutableParagraphStyle new];
        style.minimumLineHeight = height;
        NSDictionary *attribute = @{
            NSFontAttributeName:font,
            NSForegroundColorAttributeName:textColor,
            NSParagraphStyleAttributeName:style,
            NSBaselineOffsetAttributeName:@(baselineOffset)};
//
        NSAttributedString *str = [[NSAttributedString alloc] initWithString:text?:@" " attributes:attribute];
        label.attributedText = str;

上述代码,很常见,很长时间大家都是这么用,或再一步封装。随着开发和视觉同学确认视觉规范后,事情变的不简单起来了。

引入视觉规范

  1. 经过视觉同学梳理,上述的所有样式被归纳为一个 code,即 14_gray2_bold,即设置上述 attributedText 文字时,简化为一行代码;
[StyleSpec setLabelStyle:label withCode:YXCode_14_gray2_bold text:@"耐穿又耐看, 男式基础休闲牛津纺衬衫"];
  1. 开发同学,对上述代码不够满意——因为设置样式和文字内容不一定是一起进行。比较普遍的情况是,在 loadSubview的时候设置样式,在数据返回后设置文字内容,期望的调用方式:
- (void)loadSubView{
  label.styleCode = YXCode_14_gray2_bold;
  label.text = @"占位";// 可有可无,和 label.styleCode 设置顺序无关
}
- (void)fetchData{
  label.text = self.data.userName;
}

上面代码调用对开发很自然、友好,但是实现起来有个难点:
生成 NSAttributedString 时是需要有文字内容的,如果 label.text 为空,这设置 attributes 的属性会丢失。即

 label = [UILabel new];
 label.styleCode = YXCode_14_gray2_bold;

这样设置是无效的,后续设置 label.text = @"some words"会显示默认 17px 黑色 regular 的样式。如果在设置 .styleCode = 之前就有文案,即;

 label = [UILabel new];
 label.text = @"initial";
 label.styleCode = YXCode_14_gray2_bold;

经过测试,后续修改文案可以生效,但这对调用方提出了要求,有两种方式:

  1. 先设置文案,再设置样式 (缺点:开发容易忘记、犯错)
  2. 调用样式的时候同时设置文案(缺点:在更新文案时,很不友好——loadSubview 的时候设置样式,后续修改文案还需要设置样式)

去掉 ”设置 styleCode 时对 text “ 的依赖。

理想的情况是顺序无关,即

 label.text = @"initial";
 label.styleCode = YXCode_14_gray2_bold;
// 等价于
 label.styleCode = YXCode_14_gray2_bold;
 label.text = @"initial";

// 后续有更新内容时,修改文字
 label.text = @"changed";

这样就没有调用顺序的问题,而且后续修改文字,也用最自然的方式,非常棒。
如何实现呢?

+ (void)setLabelStyle:(UILabel *)label withCode:(YXStyleCode *)code text:(NSString *)text{
    UIColor *textColor = [self colorWithCode:code];
    UIFont *font = [self fontWithCode:code];
    
    BOOL readMode = [code hasSuffix:kReadModeSuffix];
    NSDictionary *attrs = [self getAttributes:readMode font:font textColor:textColor];
    if (attrs) {
        // @" " 是为了能够让 attributes 能设置成功
        NSAttributedString *str = [[NSAttributedString alloc] initWithString:text?:@" " attributes:attrs];
        label.attributedText = str;
    } else {
        label.textColor = textColor;
        label.font = font;
        label.text = text;
    }
}

最重要的逻辑:如果设置样式时,没有文字内容,则以 ” “ 空字符串来创建 attributedText , 这样初次渲染时,样式内容都创建了,在界面短暂显示空字符串,对用户无干扰。当需要设置后端返回的数据时,调用label.text = @"服务器返回字段";接口。

样式接口提交后,大家在模拟器开发没什么问题,等我跑 iPhone 6 的适配代码时,我发现 iOS 12 设置的字体显示不对,一个”Pro 会员“ 的商品文字标签,超出了背景色,典型的默认样式—— 上述用 @" " 来占位的方式失效了。解决方案,把 UILabel setText:hook 住;

@implementation UILabel (StyleSpec)

+ (void)load {
    if (SystemVersionHigherThanOrEqualTo(@"13.0")) {
        //
    } else {
        // iOS 13 以下的有问题,需要 hook
        // 交换 spec_setText: 和 setText:
    }
}

- (void)spec_setText:(NSString *)text{
    NSAttributedString *attrStr = self.attributedText;
    BOOL isSingleRangeAttrStr = attrStr.length > 0 && [self isSingleRangeAttrStr];
    if (isSingleRangeAttrStr && text.length > 0) {// 只有简单的 attrbute string 才设置
        NSMutableAttributedString *newAttrStr = [attrStr mutableCopy];
        [newAttrStr.mutableString setString:text];
        self.attributedText = newAttrStr;
    } else {
        [self spec_setText:text];
    }
}

- (BOOL)isSingleRangeAttrStr{
    NSAttributedString *attrStr = self.attributedText;

    NSString *descText = [attrStr description];
    NSUInteger count = 0, length = [descText length];
    NSAssert(length > 0, @"attrStr 的描述为空");
    NSRange range = NSMakeRange(0, length);
    while(length > 0 && range.location != NSNotFound)
    {
        // 解析 attrStr 的描述,如果有多个 字体描述说明是多 range
        range = [descText rangeOfString: @"NSFont = " options:0 range:range];
        if(range.location != NSNotFound)
        {
            range = NSMakeRange(range.location + range.length, length - (range.location + range.length));
            count++;
        }
        if (count > 1) {
            break;
        }
    }

    return count <= 1;
}
@end

上述方案,在上线一个版本后,陆陆续续发现有些用 UILabel 实现的 带背景色的按钮、标签,无法垂直对齐了,如图中的倒计时。

倒计时

而这个问题出现在所有 iOS 版本,包括 iOS 13。所以上述 UILabel setText: hook 方案修改为也包含 iOS 13,解决垂直不对齐的问题。

为什么对不齐

 label.text = @"initial";
 label.styleCode = YXCode_14_gray2_bold;

// 后续有更新内容时,修改文字,此时会出现无法对齐的问题。
 label.text = @"changed";

但是如果第二次修改文字时,同时设置样式:

[StyleSpec setLabelStyle:label withCode:YXCode_14_gray2_bold text:@"changed"];

则不会出现此问题。经过两种方式输出对应的 attributedString 的对象,发现属性全部都一样,只是在渲染时有所不同。

这是为什么呢?有两种猜测;

  1. 使用 label.attributedText = NSAttributedString 设置的文字样式,就不应该使用label.text = @"changed"; 来更新。至于现在 iOS 13 以上,继承了大部分属性貌似是可以,可以理解为是没有特殊处理,导致的现象,不是 Apple 的意图,是个巧合。
  2. iOS 13,苹果对于简单的 attributedText(指单个样式),故意实现了用 .text = 去修改 attributedText = 的功能,只是实现的有些 bug。对于如划线价+原价这种复杂的 attributedText,则使用默认样式渲染"changed"文字。
划线价+促销价格
修改后的效果

总结

使用 .text = 去修改 attributedText = 的功能的最佳实践;

  1. 使用空字符 ” “ 首先设置 styleCode 来设置的样式属性
  2. hook 掉 UILabel setText:在更新的时候,自动获取旧的 attributes 属性,更新文案。
  3. 如果遇到复杂的 attributedText(如划线价+促销价格),还是使用来更新文字内容(如果用 setText 来更新赋值样式,则会用 attributes 里前一组来渲染文案。

欢迎大家勘误。

  1. Changing an Attributed String
  2. 严选的字号 -> 行高、边距的配置
// 普通模式
config = @{@9:@{@"height":@12, @"lineSpace":@1, @"baseline":@0.4},
               @10:@{@"height":@15, @"lineSpace":@2, @"baseline":@0.8},
               @11:@{@"height":@16, @"lineSpace":@2, @"baseline":@0.8},
               @12:@{@"height":@18, @"lineSpace":@2.5, @"baseline":@1},
               @14:@{@"height":@20, @"lineSpace":@3, @"baseline":@0.8},
               @15:@{@"height":@22, @"lineSpace":@3.5, @"baseline":@1.1},
               @16:@{@"height":@24, @"lineSpace":@4, @"baseline":@1.1},
               @18:@{@"height":@26, @"lineSpace":@4, @"baseline":@1.2},
               @22:@{@"height":@32, @"lineSpace":@4.5, @"baseline":@1.5},
               @24:@{@"height":@36, @"lineSpace":@5, @"baseline":@2},
               @27:@{@"height":@40, @"lineSpace":@6, @"baseline":@2},
               };
//阅读模式,如评论中
    readModeConfig = @{@14:@{@"height":@22, @"lineSpace":@4, @"baseline":@1.4}, //阅读模式
                       };
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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