需求
在商品列表的设计中,很多商品卡片的商品名称需要换行。效果如,
如“耐穿又耐看, 男式基础休闲牛津纺衬衫”, 用
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;
上述代码,很常见,很长时间大家都是这么用,或再一步封装。随着开发和视觉同学确认视觉规范后,事情变的不简单起来了。
引入视觉规范
- 经过视觉同学梳理,上述的所有样式被归纳为一个 code,即
14_gray2_bold
,即设置上述attributedText
文字时,简化为一行代码;
[StyleSpec setLabelStyle:label withCode:YXCode_14_gray2_bold text:@"耐穿又耐看, 男式基础休闲牛津纺衬衫"];
- 开发同学,对上述代码不够满意——因为设置样式和文字内容不一定是一起进行。比较普遍的情况是,在
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;
经过测试,后续修改文案可以生效,但这对调用方提出了要求,有两种方式:
- 先设置文案,再设置样式 (缺点:开发容易忘记、犯错)
- 调用样式的时候同时设置文案(缺点:在更新文案时,很不友好——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 的对象,发现属性全部都一样,只是在渲染时有所不同。
这是为什么呢?有两种猜测;
- 使用
label.attributedText = NSAttributedString
设置的文字样式,就不应该使用label.text = @"changed";
来更新。至于现在 iOS 13 以上,继承了大部分属性貌似是可以,可以理解为是没有特殊处理,导致的现象,不是 Apple 的意图,是个巧合。 - iOS 13,苹果对于简单的 attributedText(指单个样式),故意实现了用
.text =
去修改attributedText =
的功能,只是实现的有些 bug。对于如划线价+原价这种复杂的 attributedText,则使用默认样式渲染"changed"
文字。
总结
使用 .text =
去修改 attributedText =
的功能的最佳实践;
- 使用空字符 ” “ 首先设置 styleCode 来设置的样式属性
- hook 掉
UILabel setText:
在更新的时候,自动获取旧的 attributes 属性,更新文案。 - 如果遇到复杂的
attributedText
(如划线价+促销价格),还是使用来更新文字内容(如果用 setText 来更新赋值样式,则会用 attributes 里前一组来渲染文案。
欢迎大家勘误。
附
- Changing an Attributed String
- 严选的字号 -> 行高、边距的配置。
// 普通模式
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}, //阅读模式
};