制作一個微博文本編輯器

本文章的Demo工程

最近需要制作一個類似發微博的界面,支持“@用戶”以及“#話題#”等格式高亮,并且可輸入展示自定義表情圖片。期間寫了個demo,截圖如下:

demo.png

首先關于這種富文本展示就不造輪子了,直接使用了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

寫本文時,其實還有一種格式需求沒有搞定,如下圖


image.png

微博里非常常見的一種格式,想簡單些就是左邊圖片與“查看圖片”四個字共屬一圖,將圖片url復制給整張圖片,但會造成“查看圖片”四字無法折行的bug(其實算不算bug要看產品),接下來就是解決這個問題了。

Update 17-5-17 上面問題的解決

今天優化這塊代碼,其中上面樣式也做了出來,如下圖:

image&textBinding.png

其中核心實現是將圖片與文字拼接成一個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工程

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,182評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,489評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,290評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,776評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,510評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,866評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,860評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,036評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,585評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,331評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,536評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,058評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,754評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,154評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,469評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,273評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,505評論 2 379

推薦閱讀更多精彩內容

  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,172評論 4 61
  • 標題寫得這么夸張,甚至是撕心裂肺,因為我想表達一個觀點:phonics是輔助閱讀的科學方法論,是為孩子自主閱讀服務...
    兔思思閱讀 414評論 0 0
  • 女兒問,媽媽最喜歡什么,我毫不猶豫地回答:“喜歡睡覺。”小東西也毫不猶豫地回答:“那我就是‘睡覺’?!?/div>
    童心不改閱讀 130評論 0 1