本文章的Demo工程
最近需要制作一個類似發微博的界面,支持“@用戶”以及“#話題#”等格式高亮,并且可輸入展示自定義表情圖片。期間寫了個demo,截圖如下:
首先關于這種富文本展示就不造輪子了,直接使用了YYText ,可以省不少時間。
1 分析
首先效果大體效仿微博,"@用戶"與"#熱門話題#"并非綁定字符串,用戶可以隨時在其中插入編輯并高亮展示。
關于圖片樣式,這里隨便用了幾個表情作為示例,實現是利用圖片替換純文本中指定的字符串(比如“[大笑]” ),刪除時圖片整體刪除,復制時復制對應的字符串,粘貼或者用戶打出對應字符自動轉換為表情圖片。
首先很容易想到用正則來匹配關鍵字符串,轉換為attribute string著色展示;利用NSTextAttachment插入圖片并替換字符。如果只是展示已經足夠,但如果展示之后需要再次編輯,那么實現起來會很復雜(可以想到每增刪字符后都要重新匹配全文并重新賦予attribute string)。
其實在YYText的demo中實現的markdown語法解析,和本需求實現如出一轍,其實目的就是做一個“語法解析器” 。所以接下來參照YYTextSimpleMarkdownParser
自定義一個對象實現YYTextParser
協議,并將對象復制給YYTextView的實例即可。
2 實現
首先創建自定義解析器并準守協議
#import <Foundation/Foundation.h>
#import "YYTextParser.h"
@interface TextParser : NSObject<YYTextParser>
@end
關于YYTextView的創建不再贅述,創建完成后賦值textParser
屬性
self.textView.textParser = [[TextParser alloc]init];
接下來就該實現TextParser
的.m文件了,查閱YYTextParser
類發現只有一個require方法:
/**
When text is changed in YYTextView or YYLabel, this method will be called.
@param text The original attributed string. This method may parse the text and
change the text attributes or content.
@param selectedRange Current selected range in `text`.
This method should correct the range if the text content is changed. If there's
no selected range (such as YYLabel), this value is NULL.
@return If the 'text' is modified in this method, returns `YES`, otherwise returns `NO`.
*/
- (BOOL)parseText:(nullable NSMutableAttributedString *)text selectedRange:(nullable NSRangePointer)selectedRange;
這里注釋已比較清楚,需要配合YYTextView/YYLabel來使用,并會在text改變的時候調用,所以在這個方法里面對文本進行解析再合適不過了。
首先在此之前,需要定義對應的正則以及字體顏色等,這里說下正則這部分申明以及賦值:
NSRegularExpression *_regexAt;
NSRegularExpression *_regexPoundSign;
_regexAt = [NSRegularExpression regularExpressionWithPattern:@"@[\u4e00-\u9fa5a-zA-Z0-9_-]{2,30}" options:0 error:NULL];
_regexAt = [NSRegularExpression regularExpressionWithPattern:@"#[^#]+#" options:0 error:NULL];
這里的正則匹配了@之后2~30位長度的字符,以及#之間的字符。
接下來單獨處理圖片相關的匹配
NSRegularExpression *_regexImage;
NSDictionary *_imageMapper;
這里的_imageMapper
字典就是為了保存一份對應關系,就是字符與圖片文件的映射,下面的代碼初始化字典,然后遍歷字典的key構造匹配的正則字符串,最后賦值給_regexImage
,需要注意的是iOS中需要對特殊字符進行轉義。
_imageMapper = @{
@"[嘻嘻]" : [UIImage imageNamed:@"yb001"],
@"[呆]" : [UIImage imageNamed:@"yb002"],
@"[色]" : [UIImage imageNamed:@"yb003"]
};
NSMutableString *pattern = @"(".mutableCopy;
NSArray *allKeys = _imageMapper.allKeys;
NSCharacterSet *charset = [NSCharacterSet characterSetWithCharactersInString:@"$^?+*.,#|{}[]()\\"];
for (NSUInteger i = 0, max = allKeys.count; i < max; i++) {
NSMutableString *one = [allKeys[i] mutableCopy];
// escape regex characters
for (NSUInteger ci = 0, cmax = one.length; ci < cmax; ci++) {
unichar c = [one characterAtIndex:ci];
if ([charset characterIsMember:c]) {
[one insertString:@"\\" atIndex:ci];
ci++;
cmax++;
}
}
[pattern appendString:one];
if (i != max - 1) [pattern appendString:@"|"];
}
[pattern appendString:@")"];
_regexImage = [[NSRegularExpression alloc] initWithPattern:pattern options:kNilOptions error:nil];
剩下的就是協議方法的實現了
- (BOOL)parseText:(NSMutableAttributedString *)text selectedRange:(NSRangePointer)range {
__block BOOL changed = NO;
if (text.length == 0) { return NO; }
text.yy_font = _normalFont;
text.yy_color = _normalColor;
// @用戶
[_regexAt enumerateMatchesInString:text.string options:NSMatchingWithoutAnchoringBounds range:text.yy_rangeOfAll usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) {
NSRange range = result.range;
[text yy_setColor:_atTextColor range:range];
changed = YES;
}];
// #話題#
[_regexPoundSign enumerateMatchesInString:text.string options:NSMatchingWithoutAnchoringBounds range:text.yy_rangeOfAll usingBlock:^(NSTextCheckingResult * _Nullable result, NSMatchingFlags flags, BOOL * _Nonnull stop) {
NSRange range = result.range;
[text yy_setColor:_atTextColor range:range];
changed = YES;
}];
// 圖片
if (_imageMapper.count){
NSArray *matches = [_regexImage matchesInString:text.string options:kNilOptions range:NSMakeRange(0, text.length)];
if (matches.count) {
NSRange selectedRange = range ? *range : NSMakeRange(0, 0);
NSUInteger cutLength = 0;
for (NSUInteger i = 0, max = matches.count; i < max; i++) {
NSTextCheckingResult *one = matches[i];
NSRange oneRange = one.range;
if (oneRange.length == 0) continue;
oneRange.location -= cutLength;
NSString *subStr = [text.string substringWithRange:oneRange];
UIImage *emoticon = _imageMapper[subStr];
if (!emoticon) continue;
CGFloat fontSize = kNormalFontSize;
CTFontRef font = (__bridge CTFontRef)([text yy_attribute:NSFontAttributeName atIndex:oneRange.location]);
if (font) fontSize = CTFontGetSize(font);
NSMutableAttributedString *atr = [NSAttributedString yy_attachmentStringWithEmojiImage:emoticon fontSize:fontSize];
[atr yy_setTextBackedString:[YYTextBackedString stringWithString:subStr] range:NSMakeRange(0, atr.length)];
[text replaceCharactersInRange:oneRange withString:atr.string];
[text yy_removeDiscontinuousAttributesInRange:NSMakeRange(oneRange.location, atr.length)];
[text addAttributes:atr.yy_attributes range:NSMakeRange(oneRange.location, atr.length)];
selectedRange = [self _replaceTextInRange:oneRange withLength:atr.length selectedRange:selectedRange];
cutLength += oneRange.length - 1;
}
if (range) *range = selectedRange;
changed = YES;
}
}
return changed;
}
上面有關圖片的代碼由于發生了文本替換會比較長,替換前后的range、光標位置等內容的重設,需要把控細節有很多,可以下載demo來看(_replaceTextInRange方法就是重新設置選擇區域(光標位置) 包含在demo中)。
3 其他細節
3.1 上面的圖片實現:
NSMutableAttributedString *atr = [NSAttributedString yy_attachmentStringWithEmojiImage:emoticon fontSize:fontSize];
其實這代碼設置的是表情圖片,其尺寸是通過傳入的fontSize計算而成,倘若圖片并非整套表情而是一個長寬不等的矩形圖片,則需要修改下這段代碼:
NSMutableAttributedString *atr = [NSAttributedString yy_attachmentStringWithContent:emoticon contentMode:UIViewContentModeCenter attachmentSize:emoticon.size alignToFont:_normalFont alignment:YYTextVerticalAlignmentCenter];
這樣就可以將不規則圖片插入文本中了。
3.2 關于YYTextBackedString
默認情況下AttributedString中插入圖片然后log是沒有任何圖片的文本信息的,復制圖片后粘貼也是空字符串,但如果設置了YYTextBackedString就可以賦值文本屬性給圖片了。
[atr yy_setTextBackedString:[YYTextBackedString stringWithString:subStr] range:NSMakeRange(0, atr.length)];
3.3 本文為了盡量少貼不必要的代碼,去掉了一些判空判斷,字符串數組字典的值使用前盡量判斷是否為空。
3.4有關線程安全,本文用到的存有字符與圖片映射的字典_imageMapper
由于非線程安全,在頻繁的編輯下是容易發生同時讀寫的情況,假如是固定的表情自初始化完畢后不會增刪就沒啥問題,但非這種情況就需要聲明”鎖“(NSLock, 信號量等控制均可) 來防止多線程同時訪問,并設置為默認的atomic屬性,這里可以參照YYText中的YYTextParser.m
文件有關實現。
4 To do
寫本文時,其實還有一種格式需求沒有搞定,如下圖
微博里非常常見的一種格式,想簡單些就是左邊圖片與“查看圖片”四個字共屬一圖,將圖片url復制給整張圖片,但會造成“查看圖片”四字無法折行的bug(其實算不算bug要看產品),接下來就是解決這個問題了。
Update 17-5-17 上面問題的解決
今天優化這塊代碼,其中上面樣式也做了出來,如下圖:
其中核心實現是將圖片與文字拼接成一個NSAttributedString,之后對拼接結果進行binding與backed處理。
NSMutableAttributedString *atr = [NSMutableAttributedString yy_attachmentStringWithEmojiImage:emoticon fontSize:fontSize];
NSAttributedString *checkText = [[NSAttributedString alloc] initWithString:@"查看圖片"
attributes:@{
NSFontAttributeName: _normalFont,
NSForegroundColorAttributeName :_atTextColor }];
[atr appendAttributedString:checkText];
YYTextBinding *binding = [YYTextBinding bindingWithDeleteConfirm:YES];
[atr yy_setTextBinding:binding range:NSMakeRange(0, atr.length)];
[atr yy_setTextBackedString:[YYTextBackedString stringWithString:subStr] range:NSMakeRange(0, atr.length)];
[text replaceCharactersInRange:oneRange withAttributedString:atr];
這里還有一步,和之前只加圖片不同,當協議方法調用并替換后,再次調用協議方法會發現傳入的text變成了\U0000fffc查看圖片
并非是賦值的YYTextBackedString,而在控制器打印textView.text沒問題,所以這里會造成“查看圖片”這四個字失去了設置的顏色,而被設置上了普通文字的顏色。
不過解決起來很簡單,像匹配@標記一樣增加一條正則:
_regexImageCheck = regexp("\U0000fffc查看圖片", 0);
然后再次匹配重設顏色即可。這里會有一個小bug,YYText會把圖片統一處理成\U0000fffc字符所以任意圖片后的“查看圖片”四字都會被重設高亮,目前還沒有更好的方案,未來再做優化。
本文章的Demo工程