[iOS] 利用 NSAttributedString 进行富文本处理

时间:2023-03-09 09:59:55
[iOS] 利用 NSAttributedString 进行富文本处理
/iOS /[iOS] 利用 NSAttributedString 进行富文本处理
  • 2016年4月4日
  • 刘小龙
  • iOS

许多时候我们需要以各种灵活的形式展现文本信息,即富文本。普通的 text 属性显然无法满足要求,这时我们需要利用 Foundation 中的 NSAttributedString——属性字符串进行设置。拥有文本显示功能(text 属性)的 UI 控件也都拥有 attributedText 属性。

常用方法

和 NSString 及 Foundation 框架其它集合一样,NSAttributedString 也拥有一个子类 NSMutableAttributedString 执行修改方面的操作。

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
/*
    NSAttributedString
*/
@property (readonly, copy) NSString *string; // 无属性的字符串
@property (readonly) NSUInteger length; // 字符串长度
// 初始化方法
- (instancetype)initWithString:(NSString *)str;
- (instancetype)initWithString:(NSString *)str attributes:(nullable NSDictionary<NSString *, id> *)attrs;
- (instancetype)initWithAttributedString:(NSAttributedString *)attrStr;
// 等同性判断
- (BOOL)isEqualToAttributedString:(NSAttributedString *)other;
// 返回指定范围的属性
- (NSDictionary<NSString *, id> *)attributesAtIndex:(NSUInteger)location longestEffectiveRange:(nullable NSRangePointer)range inRange:(NSRange)rangeLimit;
- (nullable id)attribute:(NSString *)attrName atIndex:(NSUInteger)location longestEffectiveRange:(nullable NSRangePointer)range inRange:(NSRange)rangeLimit;
// 遍历获得符合指定属性或属性字典的区域(range),并在 block 中进行设置
- (void)enumerateAttributesInRange:(NSRange)enumerationRange options:(NSAttributedStringEnumerationOptions)opts usingBlock:(void (^)(NSDictionary<NSString *, id> *attrs, NSRange range, BOOL *stop))block;
- (void)enumerateAttribute:(NSString *)attrName inRange:(NSRange)enumerationRange options:(NSAttributedStringEnumerationOptions)opts usingBlock:(void (^)(id __nullable value, NSRange range, BOOL*stop))block;
/*
    NSMutableAttributedString
*/
// 增加属性
- (void)addAttribute:(NSString *)name value:(id)value range:(NSRange)range;
- (void)addAttributes:(NSDictionary<NSString *, id> *)attrs range:(NSRange)range;
- (void)setAttributedString:(NSAttributedString *)attrString;
- (void)setAttributes:(nullable NSDictionary<NSString *, id> *)attrs range:(NSRange)range;
// 删除属性
- (void)removeAttribute:(NSString *)name range:(NSRange)range;
// 插入 attributedString
- (void)insertAttributedString:(NSAttributedString *)attrString atIndex:(NSUInteger)loc;
- (void)appendAttributedString:(NSAttributedString *)attrString;
// 替换 attributedString
- (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str;
- (void)replaceCharactersInRange:(NSRange)range withAttributedString:(NSAttributedString *)attrString;

基本的使用流程是初始化一个 NSMutableAttributedString 对象并指定源文本,然后进行属性设置操作,最后赋值给目标控件的 attributedText 属性。

注意到方法介绍中 attribute(属性)和 range(范围)出现频次相当高,实际上这也是 NSAttributedString 的核心内容——在正确的范围设置合适的属性。下面我们就从这两方面进行介绍。

attribute (属性)

UIKit 中声明了一个 NSAttributedString 的分类,定义了可用的属性类型。一些普通文本属性和方法也会用到这些属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
NSFontAttributeName // 设置字体属性,UIFont 对象,默认值:字体:Helvetica(Neue) 字号:12
NSParagraphStyleAttributeName // 设置文本段落排版格式,NSParagraphStyle 对象
NSForegroundColorAttributeName // 设置字体颜色,UIColor对象,默认值为黑色
NSBackgroundColorAttributeName // 设置字体所在区域背景颜色,UIColor对象,默认值为 nil, 透明
NSLigatureAttributeName // 设置连体属性,NSNumber 对象(整数),0 表示没有连体字符,1 表示使用默认的连体字符
NSKernAttributeName // 设置字符间距,NSNumber 对象(整数),正值间距加宽,负值间距变窄
NSStrikethroughStyleAttributeName // 设置删除线,NSNumber 对象(整数)
NSStrikethroughColorAttributeName // 设置删除线颜色,UIColor 对象,默认值为黑色
NSUnderlineStyleAttributeName // 设置下划线,NSNumber 对象(整数),枚举常量 NSUnderlineStyle中的值,与删除线类似
NSUnderlineColorAttributeName // 设置下划线颜色,UIColor 对象,默认值为黑色
NSStrokeWidthAttributeName // 设置笔画宽度(粗细),NSNumber 对象(整数),负值填充效果,正值中空效果
NSStrokeColorAttributeName // 填充部分颜色,不是字体颜色,UIColor 对象
NSShadowAttributeName // 设置阴影属性,NSShadow 对象
NSTextEffectAttributeName // 设置文本特殊效果,NSString 对象,目前只有图版印刷效果可用
NSBaselineOffsetAttributeName // 设置基线偏移值,NSNumber (float),正值上偏,负值下偏
NSObliquenessAttributeName // 设置字形倾斜度,NSNumber (float),正值右倾,负值左倾
NSExpansionAttributeName // 设置文本横向拉伸属性,NSNumber (float),正值横向拉伸文本,负值横向压缩文本
NSWritingDirectionAttributeName // 设置文字书写方向,从左向右书写或者从右向左书写
NSVerticalGlyphFormAttributeName // 设置文字排版方向,NSNumber 对象(整数),0 表示横排文本,1 表示竖排文本
NSLinkAttributeName // 设置链接属性,点击后调用浏览器打开指定 URL 地址(注意只有 UITextView 可以通过其代理方法实现操作,其它口渴男关键只能显示样式而无法点击)
NSAttachmentAttributeName // 设置文本附件,NSTextAttachment 对象,常用于文字图片混排

需要注意的是文本显示控件中的 attributedText 属性并不会继承 text 属性中的文本设置,初学者往往会遗漏掉经常在普通文本中设置的诸如字体大小和颜色等基本属性,造成显示效果失常。

这里特别介绍一下 NSTextAttachment(文本附件),对应 NSAttachmentAttributeName 类型。NSTextAttachment 对象中的 image 属性可以为属性文本提供图片,bounds 属性设置图片的尺寸(通常利用 UIFont 的 lineHight 属性使之与文本等高)。

如果用 NSAttachmentAttributeName 类型对象的方式以属性插入附件,会有一个问题是不好确认 range。所幸 NSTextAttachment 类中提供了一个 NSAttributedString 的分类初始化方法:

1
2
+ (NSAttributedString *)attributedStringWithAttachment:(NSTextAttachment *)attachment;

因此对于 NSTextAttachment 对象,最好是以设置为 NSAttributedString 对象的方式插入。

range(范围)

对于一些内容固定的简单文本,我们可以直接设置出固定的 range,但如果是内容未知属性设置需求复杂的不定长文本(比如微博),问题就不小了。为了获得目标 range,我们需要通过 NSRegularExpression 类利用正则表达式进行过滤检索。

正则表达式

这部分内容太广了,可以搜索教程自学,比如这里。花上半天一天的时间,做到熟悉语法和关键词,能自己进行一些中低难度的检索并且看懂大部分表达式就差不多了。

NSRegularExpression

首先通过正则表达式设置过滤规则字符串:

1
2
3
// IP地址 格式:x.x.x.x,x 不超过 255
NSString *pattern = @"^((2[0-4]\\d|25[0-5]|[01]?\\d?\\d)\\.){3}(2[0-4]\\d|25[0-5]|[01]?\\d?\\d)$";

初始化为 NSRegularExpression 对象。初始化方法:

1
2
3
+ (nullable NSRegularExpression *)regularExpressionWithPattern:(NSString *)pattern options:(NSRegularExpressionOptions)options error:(NSError **)error;
- (nullable instancetype)initWithPattern:(NSString *)pattern options:(NSRegularExpressionOptions)options error:(NSError **)error;

搜索目标字符串获得匹配字段:

1
2
3
4
5
6
7
8
9
10
11
// 利用 block 对匹配字段进行设置(NSTextCheckingResult 类中有 range 属性,即匹配字段的范围)
- (void)enumerateMatchesInString:(NSString *)string options:(NSMatchingOptions)options range:(NSRange)range usingBlock:(void (^)(NSTextCheckingResult * __nullable result, NSMatchingFlags flags, BOOL *stop))block;
// 返回匹配字段的 NSTextCheckingResult 对象数组
- (NSArray<NSTextCheckingResult *> *)matchesInString:(NSString *)string options:(NSMatchingOptions)options range:(NSRange)range;
// 匹配字段个数
- (NSUInteger)numberOfMatchesInString:(NSString *)string options:(NSMatchingOptions)options range:(NSRange)range;
// 返回第一个匹配字段的 NSTextCheckingResult 对象
- (nullable NSTextCheckingResult *)firstMatchInString:(NSString *)string options:(NSMatchingOptions)options range:(NSRange)range;
// 返回第一个匹配字段的 range
- (NSRange)rangeOfFirstMatchInString:(NSString *)string options:(NSMatchingOptions)options range:(NSRange)range;

RegEx Categories

GitHub 上的一个高星项目(这是地址),对 NSRegularExpression 进行了格式上的简化和功能上的便利性扩展,推荐使用。

综合实例

将下面这条文本信息按微博样式显示:

1
2
NSString *weibo = @"@用户A:哈哈哈哈哈哈哈哈[doge] #话题话题# //@Tom: @用户B 微博内容微博内容[doge]微博内容微博内容https://www.weibo.com";

难点:表情替换。由于替换表情和原字段的长度不同,如果直接替换,文本长度改变,而后面的匹配字段的 range 是按原文本计算的,这样会造成显示错位。

解决方法是根据过滤条件按顺序获得所有(包括不匹配)字段的 range 数组,然后再拼接出目标属性文本。很可惜,NSRegularExpression 和 RegEx Categories 都没有提供合适的分离方法,需要我们自己实现。

自定义 NSRegularExpression 分类文件:

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
#import "NSRegularExpression+TIM_Extension.h"
@implementation NSRegularExpression (TIM_Extension)
- (NSArray<NSValue *> *)separatedRangesMatchesString:(NSString *)string{
    NSInteger frontLocation = 0;
    NSMutableArray *ranges = [NSMutableArray array];
    NSArray *matches = [self matchesInString:string options:0 range:NSMakeRange(0, string.length)];
    if (matches.count) {
        for (NSTextCheckingResult *result in matches) {
            NSInteger location = result.range.location;
            NSInteger length = result.range.length;
            // 判断匹配字段前部
            if (location > frontLocation) {
                [ranges addObject:[NSValue valueWithRange:NSMakeRange(frontLocation, location - frontLocation)]];
            }
            // 匹配字段
            [ranges addObject:[NSValue valueWithRange:result.range]];
            frontLocation = location + length;
            // 匹配字段后部
            if ([result isEqual:matches.lastObject] && (string.length > (location + length))) {
                NSInteger lastLocation = location + length;
                NSInteger lastLength = string.length - lastLocation;
                [ranges addObject:[NSValue valueWithRange:NSMakeRange(lastLocation, lastLength)]];
            }
        }
    } else {
        [ranges addObject:[NSValue valueWithRange:NSMakeRange(0, string.length)]];
    }
    return ranges;
}
@end

主文件:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
//
//  ViewController.m
//  NSAttributedString
//
#import "ViewController.h"
#import "RegExCategories.h"
#import "NSRegularExpression+TIM_Extension.h"
// 设置过滤正则表达式
static NSString *const kUserPattern = @"\\@\\w+"; // "@用户名"过滤
static NSString *const kTopicPattern = @"\\#\\w+\\#"; // "##"微博话题过滤
static NSString *const kEmotionPattern = @"\\[\\w+\\]"; // "[]"表情字段过滤
static NSString *const kUrlPattern = @"(((ht|f)tp(s?))\\://)?(www.|[a-zA-Z].)[a-zA-Z0-9\\-\\.]+\\.(com|edu|gov|mil|net|org|biz|info|name|museum|us|ca|uk)(\\:[0-9]+)*(/($|[a-zA-Z0-9\\.\\,\\;\\?\\'\\\\\\+&%\\$#\\=~_\\-]+))*"; // URL 过滤
@interface ViewController ()
  // 显示控件
@property (weak, nonatomic) IBOutlet UILabel *weiboLabel;
@property (weak, nonatomic) IBOutlet UITextView *attributedTextView;
@end
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    NSString *weibo = @"@用户A:哈哈哈哈哈哈哈哈[doge] #话题话题# //@Tom: @用户B 微博内容微博内容[doge]微博内容微博内容https://www.weibo.com";
    self.weiboLabel.text = weibo;
    self.attributedTextView.attributedText = [self attributedStringWithText:weibo];
}
// 设置属性文本
- (NSAttributedString *)attributedStringWithText:(NSString *)text{
    // 初始化 NSMutableAttributedString
    NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] init];
    // 统一字体
    UIFont *font = [UIFont systemFontOfSize:18];
    // 按顺序获得所有(包括不匹配)字段的 range 数组以重新拼接
    NSArray *ranges = [[Rx rx:[NSString stringWithFormat:@"%@|%@|%@|%@",kUserPattern, kTopicPattern,kEmotionPattern, kUrlPattern]] separatedRangesMatchesString:text];
    // 按匹配条件进行自定义设置
    for (NSValue *rangeValue in ranges) {
        // 获得字段内容及 range
        NSRange range = [rangeValue rangeValue];
        NSString *subText = [text substringWithRange:range];
        // 初始化字段的 NSMutableAttributedString
        NSMutableAttributedString *subAttributedText = [[NSMutableAttributedString alloc] initWithString:subText attributes:@{NSFontAttributeName: font}];
        // 判断匹配类型
            // 表情
        if ([RX(kEmotionPattern) isMatch:subText]) {
            // 添加图片附件替换原字段
            NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
            attachment.image = [UIImage imageNamed:@"doge"];
            attachment.bounds = CGRectMake(0, -5, font.lineHeight, font.lineHeight);
            [attributedText appendAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]];
            // 可点击字段
        } else if ([[Rx rx:[NSString stringWithFormat:@"%@|%@|%@", kUserPattern, kTopicPattern, kUrlPattern]]isMatch:subText]) {
            // 添加 NSLinkAttributeName 属性
            [subAttributedText addAttribute:NSLinkAttributeName value:[NSURL URLWithString:@"www.baidu.com"]range:NSMakeRange(0, subText.length)];
            [attributedText appendAttributedString:subAttributedText];
            // 普通字段
        } else {
            [attributedText appendAttributedString:subAttributedText];
        }
    }
    return  attributedText;
}
@end