需求

在商品列表的设计中,很多商品卡片的商品名称需要换行。效果如,商品图 如“耐穿又耐看, 男式基础休闲牛津纺衬衫”, 用 UILabel 实现。但样式不能用以下代码来实现,

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
        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 文字时,简化为一行代码;
1
[StyleSpec setLabelStyle:label withCode:YXCode_14_gray2_bold text:@"耐穿又耐看, 男式基础休闲牛津纺衬衫"];
  1. 开发同学,对上述代码不够满意——因为设置样式和文字内容不一定是一起进行。比较普遍的情况是,在 loadSubview的时候设置样式,在数据返回后设置文字内容,期望的调用方式:
1
2
3
4
5
6
7
- (void)loadSubView{
  label.styleCode = YXCode_14_gray2_bold;
  label.text = @"占位";// 可有可无,和 label.styleCode 设置顺序无关
}
- (void)fetchData{
  label.text = self.data.userName;
}

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
 label.text = @"initial";
 label.styleCode = YXCode_14_gray2_bold;
// 等价于
 label.styleCode = YXCode_14_gray2_bold;
 label.text = @"initial";

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
+ (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 住;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@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,解决垂直不对齐的问题。

为什么对不齐

1
2
3
4
5
 label.text = @"initial";
 label.styleCode = YXCode_14_gray2_bold;

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

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

1
[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. 严选的字号 -> 行高、边距的配置
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 普通模式
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}, //阅读模式
                       };