前言
當(dāng)喜悅、憤怒、疑惑、懵逼等等這些情緒都能使用表情表達(dá)時(shí),我干嘛還要打字
這是一個(gè)移動(dòng)端快速發(fā)展的時(shí)代,不管你承不承認(rèn),作為一個(gè)app開發(fā)者,社交屬性總是或多或少出現(xiàn)在我們開發(fā)的業(yè)務(wù)需求中,其中作為IM最重要的組成元素——表情,如何進(jìn)行文字和表情混合編程是一門重要的技術(shù)。
本文將使用iOS中的coreText框架來(lái)完成我們的圖文混編之旅,除此之外,還實(shí)現(xiàn)文本超鏈接效果。在開始本篇的代碼之前,我們先通過iOS框架結(jié)構(gòu)圖來(lái)了解CoreText所處的位置:

coreText基礎(chǔ)
首先我們要知道圖文混編的原理 —— 在需要顯示圖片的文本位置使用特殊的字符顯示,然后在繪制這些文本的將圖片直接繪制顯示在這些特殊文本的位置上。因此,圖文混編的任務(wù)離不開一個(gè)重要的角色——NSAttributedString
這個(gè)對(duì)比NSString多了各種類似粗斜體、下劃線、背景色等文本屬性的NSAttributedString,每個(gè)屬性都有其對(duì)應(yīng)的字符區(qū)域。這意味著你可以將前幾個(gè)字符設(shè)置為粗體,而后面的字符為斜體且?guī)е聞澗€。在iOS6之后已經(jīng)有能夠設(shè)置控件的富文本屬性了,但如果想要實(shí)現(xiàn)我們的圖文混編,我們需要使用coreText來(lái)對(duì)屬性字符串進(jìn)行繪制。在coreText繪制字符的過程中,最重要的兩個(gè)概念是CTFramesetterRef跟CTFrameRef,他們的概念如下:

在創(chuàng)建好要繪制的富文本字符串之后,我們用它來(lái)創(chuàng)建一個(gè)
CTFramesetterRef變量,這個(gè)變量可以看做是CTFrameRef的一個(gè)工廠,用來(lái)輔助我們創(chuàng)建后者。在傳入一個(gè)CGPathRef的變量之后我們可以創(chuàng)建相應(yīng)的CTFrameRef然后將富文本渲染在對(duì)應(yīng)的路徑區(qū)域內(nèi)。這段創(chuàng)建代碼如下(由于coreText基于C語(yǔ)言的庫(kù),所有對(duì)象都需要我們手動(dòng)釋放內(nèi)存):
CGContextRef ctx = UIGraphicsGetCurrentContext();
NSAttributedString * content = [[NSAttributedString alloc] initWithString: @"這是一個(gè)測(cè)試的富文本,這是一個(gè)測(cè)試的富文本"];
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)content);
CGMutablePathRef paths = CGPathCreateMutable();
CGPathAddRect(paths, NULL, CGRectMake(0, 0, 100, 100));
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, content), paths, NULL);
CTFrameDraw(frame, ctx); //繪制文字
// 釋放內(nèi)存
CFRelease(paths);
CFRelease(frame);
CFRelease(framesetter);
除此之外,每一個(gè)創(chuàng)建的CTFrameRef中存在一個(gè)或者更多的CTLineRef變量,這個(gè)變量表示繪制文本中的每一行文本。每個(gè)CTLineRef變量中存在一個(gè)或者更多個(gè)CTRunRef變量,在文本繪制過程中,我們并不關(guān)心CTLineRef或者CTRunRef變量具體對(duì)應(yīng)的是什么字符,這些工作在更深層次系統(tǒng)已經(jīng)幫我們完成了創(chuàng)建。創(chuàng)建過程的圖如下:

通過
CTFrameRef獲取文本內(nèi)容的行以及字符串組的代碼如下:
CFArrayRef lines = CTFrameGetLines(frame);
CGPoint lineOrigins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(_frame, CFRangeMake(0, 0), lineOrigins);
for (int idx = 0; idx < CFArrayGetCount(lines); idx++) {
NSLog(@"第%d行起始坐標(biāo)%@", idx, NSStringFromCGPoint(lineOrigins[idx]));
CTLineRef line = CFArrayGetValueAtIndex(lines, idx);
CFArrayRef runs = CTLineGetGlyphRuns(line);
CGFloat runAscent;
CGFloat runDescent;
CGFloat runWidth = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &runAscent, &runDescent, NULL);
NSLog(@"第%d個(gè)字符組的寬度為%f", idx, runWidth);
}
圖文混編的做法就是在我們需要插入表情的富文本位置插入一個(gè)空字符占位,然后實(shí)現(xiàn)自定義的CTRunDelegateCallbacks來(lái)設(shè)置這個(gè)占位字符的寬高位置信息等,為占位字符添加一個(gè)自定義的文本屬性用來(lái)存儲(chǔ)對(duì)應(yīng)的表情圖片名字。接著我們通過CTFrameRef獲取渲染的文本行以及文本字符,判斷是否存在存儲(chǔ)的表情圖片,如果是就將圖片繪制在占位字符的位置上。

下面代碼是創(chuàng)建一個(gè)
CTRunDelegate的代碼,用來(lái)設(shè)置這個(gè)字符組的大小尺寸:
void RunDelegateDeallocCallback(void * refCon) {}
CGFloat RunDelegateGetAscentCallback(void * refCon)
{
return 20;
}
CGFloat RunDelegateGetDescentCallback(void * refCon)
{
return 0;
}
CGFloat RunDelegateGetWidthCallback(void * refCon)
{
return 20;
}
CTRunDelegateCallbacks imageCallbacks;
imageCallbacks.version = kCTRunDelegateVersion1;
imageCallbacks.dealloc = RunDelegateDeallocCallback;
imageCallbacks.getWidth = RunDelegateGetWidthCallback;
imageCallbacks.getAscent = RunDelegateGetAscentCallback;
imageCallbacks.getDescent = RunDelegateGetDescentCallback;
CTRunDelegateRef runDelegate = CTRunDelegateCreate(&imageCallbacks, "這是回調(diào)函數(shù)的參數(shù)");
文字排版
文字排版屬于又臭又長(zhǎng)的理論概念,但是對(duì)于我們更好的使用coreText框架,這些理論知識(shí)卻是不可缺少的。
字體
與我們所認(rèn)知的字體不同的是,在計(jì)算機(jī)中字體指的是一系列的相同樣式、相同大小的字形的集合,即14號(hào)宋體跟15號(hào)宋體在計(jì)算機(jī)看來(lái)是兩種字體。而我們所說的字體指 宋體 / 楷體 這些字體類型-
字符與字形
文本排版的過程實(shí)際上是從字符到字形之間的轉(zhuǎn)換。字符表示的是文字本身的信息意義,而字形表示的是這個(gè)文字的圖形表現(xiàn)格式。同一個(gè)字符由于大小、體形之間的差別,存在著不同字形。由于連寫的存在,多個(gè)字符可能只對(duì)應(yīng)一個(gè)字形:
連寫對(duì)應(yīng)單個(gè)字形 -
字形描述集
描述了字形表現(xiàn)的多個(gè)參數(shù),包括:
1、邊框(Bounding box):一個(gè)假想的邊框,盡可能的將整個(gè)字形容納
2、基線(Baseline):一條假想的參考線,以此為基礎(chǔ)渲染字形。正常來(lái)說字母x、m、s最下方的位置就是參考線所在y坐標(biāo)
3、基礎(chǔ)原點(diǎn)(Origin):基線最左側(cè)的坐標(biāo)點(diǎn)
4、行間距(Leading):行與行之間的間距
5、字間距(Kerning):字與字之間的間距
6、上行高度(Ascent):字形最高點(diǎn)到基線的距離,正數(shù)。同一行取字符最大的上行高度為該行的上行高度
7、下行高度(Descent):字形最低點(diǎn)到基線的距離,負(fù)數(shù)。同一行取字符最小的下行高度為該行的下行高度
字形描述屬性
下圖中綠色線條表示基線,黃色線條表示下行高度,綠色線條到紅框最頂部的距離為上行高度,而黃色線條到紅框底部的距離為行間距。因此行高的計(jì)算公式是lineHeight = Ascent + |Descent| + Leading
字符描述屬性
圖文混編
前文講了諸多的理論知識(shí),終于來(lái)到了實(shí)戰(zhàn)的階段,先放上本文的demo地址和效果圖:

由于富文本的繪制需要用到一個(gè)
CGContextRef類型的上下文,那么創(chuàng)建一個(gè)繼承自UIView的自定義控件并且在drawRect:方法中完成富文本繪制是最方便的方式,我給自己創(chuàng)建的類命名為LXDTextView
在CoreText繪制文本的時(shí)候,坐標(biāo)系的原點(diǎn)位于左下角,因此我們需要在繪制文字之前對(duì)坐標(biāo)系進(jìn)行一次翻轉(zhuǎn)。并且在繪制富文本之前,我們需要構(gòu)建好渲染的富文本并在方法里返回:
- (NSMutableAttributedString *)buildAttributedString
{
//創(chuàng)建富文本,并且將超鏈接文本設(shè)置為藍(lán)色+下劃線
NSMutableAttributedString * content = [[NSMutableAttributedString alloc] initWithString: @"這是一個(gè)富文本內(nèi)容"];
NSString * hyperlinkText = @"@這是鏈接";
NSRange range = NSMakeRange(content.length, hyperlinkText.length);
[content appendAttributedString: [[NSAttributedString alloc] initWithString: hyperlinkText]];
[content addAttributes: @{ NSForegroundColorAttributeName: [UIColor blueColor] } range: range];
[content addAttributes: @{ NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle) } range: range];
//創(chuàng)建CTRunDelegateRef并設(shè)置回調(diào)函數(shù)
CTRunDelegateCallbacks imageCallbacks;
imageCallbacks.version = kCTRunDelegateVersion1;
imageCallbacks.dealloc = RunDelegateDeallocCallback;
imageCallbacks.getWidth = RunDelegateGetWidthCallback;
imageCallbacks.getAscent = RunDelegateGetAscentCallback;
imageCallbacks.getDescent = RunDelegateGetDescentCallback;
NSString * imageName = @"emoji";
CTRunDelegateRef runDelegate = CTRunDelegateCreate(&imageCallbacks, (__bridge void *)imageName);
//插入空白表情占位符
NSMutableAttributedString * imageAttributedString = [[NSMutableAttributedString alloc] initWithString: @" "];
[imageAttributedString addAttribute: (NSString *)kCTRunDelegateAttributeName value: (__bridge id)runDelegate range: NSMakeRange(0, 1)];
[imageAttributedString addAttribute: @"imageNameKey" value: imageName range: NSMakeRange(0, 1)];
[content appendAttributedString: imageAttributedString];
CFRelease(runDelegate);
return content;
}
- (void)drawRect: (CGRect)rect
{
//獲取圖形上下文并且翻轉(zhuǎn)坐標(biāo)系
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGContextSetTextMatrix(ctx, CGAffineTransformIdentity);
CGContextConcatCTM(ctx, CGAffineTransformMake(1, 0, 0, -1, 0, self.bounds.size.height));
NSMutableAttributedString * content = [self buildAttributedString];
//創(chuàng)建CTFramesetterRef和CTFrameRef變量
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)content);
CGMutablePathRef paths = CGPathCreateMutable();
CGPathAddRect(paths, NULL, self.bounds);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, content.length), paths, NULL);
CTFrameDraw(frame, ctx);
//遍歷文本行以及CTRunRef,將表情文本對(duì)應(yīng)的表情圖片繪制到圖形上下文
CFArrayRef lines = CTFrameGetLines(_frame);
CGPoint lineOrigins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), lineOrigins);
for (int idx = 0; idx < CFArrayGetCount(lines); idx++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, idx);
CGPoint lineOrigin = lineOrigins[idx];
CFArrayRef runs = CTLineGetGlyphRuns(line);
//遍歷字符組
for (int index = 0; index < CFArrayGetCount(runs); index++) {
CGFloat runAscent;
CGFloat runDescent;
CGPoint lineOrigin = lineOrigins[idx];
CTRunRef run = CFArrayGetValueAtIndex(runs, index);
CGRect runRect;
runRect.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &runAscent, &runDescent, NULL);
runRect = CGRectMake(lineOrigin.x + CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL), lineOrigin.y - runDescent, runRect.size.width, runAscent + runDescent);
//查找表情文本替換表情視圖
NSDictionary * attributes = (NSDictionary *)CTRunGetAttributes(run);
NSString * imageName = attributes[@"imageNameKey"];
if (imageName) {
UIImage * image = [UIImage imageNamed: imageName];
if (image) {
CGRect imageDrawRect;
CGFloat imageSize = ceil(runRect.size.height);
imageDrawRect.size = CGSizeMake(imageSize, imageSize);
imageDrawRect.origin.x = runRect.origin.x + lineOrigin.x;
imageDrawRect.origin.y = lineOrigin.y;
CGContextDrawImage(ctx, imageDrawRect, image.CGImage);
}
}
}
}
CFRelease(paths);
CFRelease(frame);
CFRelease(framesetter);
}
代碼運(yùn)行之后,代碼的運(yùn)行圖應(yīng)該是這樣:

現(xiàn)在富文本像模像樣了,但我們?cè)鯓硬拍茉邳c(diǎn)擊超鏈接文本的時(shí)候發(fā)生響應(yīng)回調(diào)呢?像圖片那樣判斷點(diǎn)擊是否處在rect范圍內(nèi)的判斷是不可取的,因?yàn)槌溄游谋究赡軇偤锰幵趽Q行的位置,從而存在多個(gè)rect。對(duì)此,
CoreText同樣提供了一個(gè)函數(shù)CTLineGetStringIndexForPosition(CTLineRef, CGPoint)方法來(lái)獲取點(diǎn)擊坐標(biāo)位于文本行的字符的下標(biāo)位置。但在此之前,我們必須先獲取點(diǎn)擊點(diǎn)所在的文本行數(shù)位置,為了達(dá)到獲取文本行的目的,繪制文本的CTFrameRef變量必須保存下來(lái),因此我定義了一個(gè)實(shí)例變量存儲(chǔ)文本渲染中生成的CTFrameRef。同樣的,對(duì)于超鏈接文本所在的位置,我們應(yīng)該把這個(gè)位置轉(zhuǎn)換成字符串作為key值,文本對(duì)應(yīng)的鏈接作為value值存到一個(gè)實(shí)例字典中。
@implementation LXDTextView
{
CTFrameRef _frame;
NSMutableDictionary * _textTouchMapper;
}
- (NSMutableAttributedString *)buildattrinbutedstring
{
//do something...
_textTouchMapper[NSStringFromRange(range)] = @"https://www.baidu.com";
//do something...
}
- (void)drawRect: (CGRect)rect
{
//do something...
_frame = frame;
//do something...
}
- (void)touchesEnded: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event
{
CGPoint touchPoint = [touches.anyObject locationInView: self];
CFArrayRef lines = CTFrameGetLines(_frame);
CGPoint origins[CFArrayGetCount(lines)];
for (int idx = 0; idx < CFArrayGetCount(lines); idx++) {
CGPoint origin = origins[idx];
CGPathRef path = CTFrameGetPath(_frame);
CGRect rect = CGPathGetBoundingBox(path);
//將坐標(biāo)點(diǎn)更改為左上角坐標(biāo)系原點(diǎn)的坐標(biāo)
CGFloat y = rect.origin.y + rect.size.height - origin.y;
if (touchPoint.y <= y && (touchPoint.x >= origin.x && touchPoint.x <= rect.origin.x + rect.size.width)) {
line = CFArrayGetValueAtIndex(lines, idx);
lineOrigin = origin;
NSLog(@"點(diǎn)擊第%d行", idx);
break;
}
}
if (line == NULL) { return; }
touchPoint.x -= lineOrigin.x;
CFIndex index = CTLineGetStringIndexForPosition(line, touchPoint);
for (NSString * textRange in _textTouchMapper) {
NSRange range = NSRangeFromString(textRange);
if (index >= range.location && index <= range.location + range.length) {
NSLog(@"點(diǎn)擊了圖片鏈接:%@", _textTouchMapper[textRange]);
break;
}
}
}
@end
現(xiàn)在運(yùn)行代碼,看看點(diǎn)擊富文本的超鏈接時(shí),控制臺(tái)是不是輸出了點(diǎn)擊圖片鏈接了呢?
進(jìn)一步封裝
我們已經(jīng)完成了富文本的實(shí)現(xiàn),下一步是思考如何從外界傳入文本內(nèi)容和對(duì)應(yīng)關(guān)系,然后顯示。因此,我們需要在頭文件中提供兩個(gè)字典類型的屬性,分別用于使用者傳入文本-超鏈接以及文本-表情圖片的對(duì)應(yīng)關(guān)系:
@interface LXDTextView : UIView
/*!
* @brief 顯示文本(所有的鏈接文本、圖片名稱都應(yīng)該放到這里面)
*/
@property (nonatomic, copy) NSString * text;
/*!
* @brief 文本-超鏈接映射
*/
@property (nonatomic, strong) NSDictionary * hyperlinkMapper;
/*!
* @brief 文本-表情映射
*/
@property (nonatomic, strong) NSDictionary * emojiTextMapper;
@end
當(dāng)然,這時(shí)候buildAttributedString也應(yīng)該進(jìn)行相應(yīng)的修改,由于富文本中可能存在多個(gè)表情,因此需要把往富文本中插入表情占位符的邏輯封裝出來(lái)。另一方面,把富文本對(duì)象content作為類成員變量來(lái)使用,會(huì)讓代碼更方便:
/*!
* @brief 在富文本中插入表情占位符,然后設(shè)置好屬性
*
* @param imageName 表情圖片的名稱
* @param emojiRange 表情文本在富文本中的位置,用于替換富文本
*/
- (void)insertEmojiAttributed: (NSString *)imageName emojiRange: (NSRange)emojiRange
{
CTRunDelegateCallbacks imageCallbacks;
imageCallbacks.version = kCTRunDelegateVersion1;
imageCallbacks.dealloc = RunDelegateDeallocCallback;
imageCallbacks.getWidth = RunDelegateGetWidthCallback;
imageCallbacks.getAscent = RunDelegateGetAscentCallback;
imageCallbacks.getDescent = RunDelegateGetDescentCallback;
/*!
* @brief 插入圖片屬性文本
*/
CTRunDelegateRef runDelegate = CTRunDelegateCreate(&imageCallbacks, (__bridge void *)imageName);
NSMutableAttributedString * imageAttributedString = [[NSMutableAttributedString alloc] initWithString: @" "];
[imageAttributedString addAttribute: (NSString *)kCTRunDelegateAttributeName value: (__bridge id)runDelegate range: NSMakeRange(0, 1)];
[imageAttributedString addAttribute: LXDEmojiImageNameKey value: imageName range: NSMakeRange(0, 1)];
[_content deleteCharactersInRange: emojiRange];
[_content insertAttributedString: imageAttributedString atIndex: emojiRange.location];
CFRelease(runDelegate);
}
在buildAttributedString方法也增加了根據(jù)傳入的兩個(gè)字典進(jìn)行富文本字符替換的邏輯。其中表情文本替換應(yīng)該放在超鏈接文本替換之前——因?yàn)楸砬槲谋咀罱K替換成一個(gè)空格字符串,但是表情文本的長(zhǎng)度往往總是大于1。先替換表情文本就不會(huì)導(dǎo)致超鏈接文本查找中的位置出錯(cuò):
- (void)buildAttributedString
{
_content = [[NSMutableAttributedString alloc] initWithString: _text attributes: self.textAttributes];
/*!
* @brief 獲取所有轉(zhuǎn)換emoji表情的文本位置
*/
for (NSString * emojiText in self.emojiTextMapper) {
NSRange range = [_content.string rangeOfString: emojiText];
while (range.location != NSNotFound) {
[self insertEmojiAttributed: self.emojiTextMapper[emojiText] emojiRange: range];
range = [_content.string rangeOfString: emojiText];
}
}
/*!
* @brief 獲取所有轉(zhuǎn)換超鏈接的文本位置
*/
for (NSString * hyperlinkText in self.hyperlinkMapper) {
NSRange range = [_content.string rangeOfString: hyperlinkText];
while (range.location != NSNotFound) {
[self.textTouchMapper setValue: self.hyperlinkMapper[hyperlinkText] forKey: NSStringFromRange(range)];
[_content addAttributes: @{ NSForegroundColorAttributeName: [UIColor blueColor] } range: range];
[_content addAttributes: @{ NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle) } range: range];
range = [_content.string rangeOfString: hyperlinkText];
}
}
}
當(dāng)然了,如果你樂意的話,還可以順帶的增加一下文本換行類型等屬性的設(shè)置:
/*!
* @brief 設(shè)置字體屬性
*/
CTParagraphStyleSetting styleSetting;
CTLineBreakMode lineBreak = kCTLineBreakByWordWrapping;
styleSetting.spec = kCTParagraphStyleSpecifierLineBreakMode;
styleSetting.value = &lineBreak;
styleSetting.valueSize = sizeof(CTLineBreakMode);
CTParagraphStyleSetting settings[] = { styleSetting };
CTParagraphStyleRef style = CTParagraphStyleCreate(settings, 1);
NSMutableDictionary * attributes = @{
(id)kCTParagraphStyleAttributeName: (id)style
}.mutableCopy;
[_content addAttributes: attributes range: NSMakeRange(0, _content.length)];
CTFontRef font = CTFontCreateWithName((CFStringRef)[UIFont systemFontOfSize: 16].fontName, 16, NULL);
[_content addAttributes: @{ (id)kCTFontAttributeName: (__bridge id)font } range: NSMakeRange(0, _content.length)];
CFRelease(font);
CFRelease(style);
由于富文本的渲染流程不會(huì)因?yàn)楦晃谋緝?nèi)容變化而變化,所以drawRect:內(nèi)的邏輯幾乎沒有任何改變。但是同樣的,如果你需要擁有點(diǎn)擊表情的事件,那么我們同樣需要像超鏈接文本實(shí)現(xiàn)的方式一樣增加一個(gè)用來(lái)判斷是否點(diǎn)擊在表情frame里面的工具,將表情的rect作為key,表情圖片名字作為value,我把這個(gè)字典命名為emojiTouchMapper。此外,前文說過CoreText的坐標(biāo)系跟常規(guī)坐標(biāo)系是相反的,即使我們?cè)?code>drawRect:開頭翻轉(zhuǎn)了坐標(biāo)系,在獲取這些文本坐標(biāo)時(shí),仍然是按照左下角坐標(biāo)系計(jì)算的。因此如果不做適當(dāng)處理,那么在點(diǎn)擊的時(shí)候就沒辦法按照正常的frame來(lái)判斷是否處于點(diǎn)擊范圍內(nèi)。因此我們需要在遍歷文本行之前聲明一個(gè)存儲(chǔ)文本內(nèi)容最頂部的y坐標(biāo)變量,在遍歷完成之后用這個(gè)變量依次和存儲(chǔ)的表情視圖進(jìn)行坐標(biāo)計(jì)算,從而存儲(chǔ)正確的frame
- (void)drawRect: (CGRect)rect
{
//do something...
CGRect imageDrawRect;
CGFloat imageSize = ceil(runRect.size.height);
imageDrawRect.size = CGSizeMake(imageSize, imageSize);
imageDrawRect.origin.x = runRect.origin.x + lineOrigin.x;
imageDrawRect.origin.y = lineOrigin.y - lineDescent;
CGContextDrawImage(ctx, imageDrawRect, image.CGImage);
imageDrawRect.origin.y = topPoint - imageDrawRect.origin.y;
self.emojiTouchMapper[NSStringFromCGRect(imageDrawRect)] = imageName;
//do something...
}
寫完上面的代碼之后,自定義的富文本視圖就已經(jīng)完成了,最后需要實(shí)現(xiàn)的是點(diǎn)擊結(jié)束時(shí)判斷點(diǎn)擊位置是否處在表情視圖或者超鏈接文本上,然后進(jìn)行相應(yīng)的回調(diào)處理。這里使用的是代理方式回調(diào):
- (void)touchesEnded: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event
{
CGPoint touchPoint = [touches.anyObject locationInView: self];
CFArrayRef lines = CTFrameGetLines(_frame);
CGPoint origins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(_frame, CFRangeMake(0, 0), origins);
CTLineRef line = NULL;
CGPoint lineOrigin = CGPointZero;
//查找點(diǎn)擊坐標(biāo)所在的文本行
for (int idx = 0; idx < CFArrayGetCount(lines); idx++) {
CGPoint origin = origins[idx];
CGPathRef path = CTFrameGetPath(_frame);
CGRect rect = CGPathGetBoundingBox(path);
//轉(zhuǎn)換點(diǎn)擊坐標(biāo)
CGFloat y = rect.origin.y + rect.size.height - origin.y;
if (touchPoint.y <= y && (touchPoint.x >= origin.x && touchPoint.x <= rect.origin.x + rect.size.width)) {
line = CFArrayGetValueAtIndex(lines, idx);
lineOrigin = origin;
NSLog(@"點(diǎn)擊第%d行", idx);
break;
}
}
if (line == NULL) { return; }
touchPoint.x -= lineOrigin.x;
CFIndex index = CTLineGetStringIndexForPosition(line, touchPoint);
//判斷是否點(diǎn)擊超鏈接文本
for (NSString * textRange in self.textTouchMapper) {
NSRange range = NSRangeFromString(textRange);
if (index >= range.location && index <= range.location + range.length) {
if ([_delegate respondsToSelector: @selector(textView:didSelectedHyperlink:)]) {
[_delegate textView: self didSelectedHyperlink: self.textTouchMapper[textRange]];
}
return;
}
}
//判斷是否點(diǎn)擊表情
if (!_emojiUserInteractionEnabled) { return; }
for (NSString * rectString in self.emojiTouchMapper) {
CGRect textRect = CGRectFromString(rectString);
if (CGRectContainsPoint(textRect, touchPoint)) {
if ([_delegate respondsToSelector: @selector(textView:didSelectedEmoji:)]) {
[_delegate textView: self didSelectedEmoji: self.emojiTouchMapper[rectString]];
}
}
}
}
按照上面的代碼完成之后,我們實(shí)現(xiàn)了一個(gè)富文本控件,我用下面的代碼測(cè)試這個(gè)圖文混編:
LXDTextView * textView = [[LXDTextView alloc] initWithFrame: CGRectMake(0, 0, 200, 300)];
textView.delegate = self;
[self.view addSubview: textView];
textView.emojiUserInteractionEnabled = YES;
textView.center = self.view.center;
textView.emojiTextMapper = @{
@"[emoji]": @"emoji"
};
textView.hyperlinkMapper = @{
@"@百度": @"https://www.baidu.com",
@"@騰訊": @"https://www.qq.com",
@"@谷歌": @"https://www.google.com",
@"@臉書": @"https://www.facebook.com",
};
textView.text = @"很久很久以前[emoji],在一個(gè)群里,生活著@百度、@騰訊這樣的居民,后來(lái),一個(gè)[emoji]叫做@谷歌的人入侵了這個(gè)村莊,他的同伙@臉書讓整個(gè)群里變得淫蕩無(wú)比。從此[emoji],迎來(lái)了污妖王的時(shí)代。污妖王,我當(dāng)定了![emoji]";
運(yùn)行效果:

關(guān)于TextKit
使用CoreText來(lái)實(shí)現(xiàn)圖文混編十分的強(qiáng)大,但同樣帶來(lái)了更多的代碼,更復(fù)雜的邏輯。在iOS7之后蘋果推出了TextKit框架,基于CoreText進(jìn)行的高級(jí)封裝無(wú)疑帶來(lái)了更簡(jiǎn)潔的代碼,其中新的NSTextAttachment類能讓我們輕松的將圖片轉(zhuǎn)換成富文本,我們只需要下面這么幾句代碼就能輕松的創(chuàng)建一個(gè)表情富文本:
- (NSAttributedString *)attributedStringWithImageName: (NSString *)imageName
{
NSTextAttachment * attachment = [[NSTextAttachment alloc] init];
attachment.image = [UIImage imageNamed: imageName];
attachment.bounds = CGRectMake(0, -5, 20, 20);
NSAttributedString * attributed = [NSAttributedString attributedStringWithAttachment: attachment];
return attributed;
}
上面CoreText中的那段富文本代碼就能改成下面這樣:
- (NSAttributedString *)attributedString: (NSMutableAttributedString *)attributedString replacingEmojiText: (NSString *)emojiText withImageName: (NSString *)imageName
{
NSRange range = [attributedString.string rangeOfString: emojiText];
while (range.location != NSNotFound) {
[attributedString deleteCharactersInRange: range];
NSAttributedString * imageAttributed = [self attributedStringWithImageName: imageName];
[attributedString insertAttributedString: imageAttributed atIndex: range.location];
range = [attributedString.string rangeOfString: emojiText];
}
return attributedString;
}
- (void)viewDidLoad {
[super viewDidLoad];
NSAttributedString * attributed = [self attributedString: [[NSMutableAttributedString alloc] initWithString: content] replacingEmojiText: @"[emoji]" withImageName: @"emoji"];
UILabel * label = [[UILabel alloc] initWithFrame: CGRectMake(0, 0, 200, 250)];
label.attributedText = attributed;
label.center = self.view.center;
label.numberOfLines = 0;
[self.view addSubview: label];
}
關(guān)心TextKit更多使用方式可以閱讀這篇文章:認(rèn)識(shí)TextKit
結(jié)尾
作為實(shí)現(xiàn)圖文混編的兩個(gè)框架,哪個(gè)功能更加強(qiáng)大,就只能見仁見智了。高級(jí)封裝的TextKit帶來(lái)了簡(jiǎn)潔性,CoreText則是更靈活。不過截至文章寫完為止,我還沒有找到通過TextKit實(shí)現(xiàn)超鏈接文本點(diǎn)擊響應(yīng)的功能。而對(duì)于開發(fā)者而言,這兩個(gè)框架都應(yīng)該都去了解,才能更好的開發(fā)。本文demo
文集:iOS開發(fā)
轉(zhuǎn)載請(qǐng)注明作者以及地址


