原文出自 http://blog.qiji.tech/archives/8335#RegEx_Categories
[iOS] 利用 NSAttributedString 進行富文本處理
許多時候我們需要以各種靈活的形式展現文本信息,即富文本。普通的 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
|
顯示效果: