iOS開發之超鏈接富文本

JuliChampio 8年前發布 | 35K 次閱讀 IOS 富文本 iOS開發 移動開發

當喜悅、憤怒、疑惑、懵逼等等這些情緒都能使用表情表達時,我干嘛還要打字

這是一個移動端快速發展的時代,不管你承不承認,作為一個app開發者,社交屬性總是或多或少出現在我們開發的業務需求中,其中作為IM最重要的組成元素——表情,如何進行文字和表情混合編程是一門重要的技術。

本文將使用iOS中的coreText框架來完成我們的圖文混編之旅,除此之外,還實現文本超鏈接效果。在開始本篇的代碼之前,我們先通過iOS框架結構圖來了解CoreText所處的位置:

iOS開發之超鏈接富文本

coreText基礎

首先我們要知道圖文混編的原理 —— 在需要顯示圖片的文本位置使用特殊的字符顯示,然后在繪制這些文本的將圖片直接繪制顯示在這些特殊文本的位置上。因此,圖文混編的任務離不開一個重要的角色——NSAttributedString

這個對比NSString多了各種類似粗斜體、下劃線、背景色等文本屬性的NSAttributedString,每個屬性都有其對應的字符區域。這意味著你可以將前幾個字符設置為粗體,而后面的字符為斜體且帶著下劃線。在iOS6之后已經有能夠設置控件的富文本屬性了,但如果想要實現我們的圖文混編,我們需要使用coreText來對屬性字符串進行繪制。在coreText繪制字符的過程中,最重要的兩個概念是CTFramesetterRefCTFrameRef ,他們的概念如下:

iOS開發之超鏈接富文本

在創建好要繪制的富文本字符串之后,我們用它來創建一個CTFramesetterRef變量,這個變量可以看做是CTFrameRef 的一個工廠,用來輔助我們創建后者。在傳入一個CGPathRef的變量之后我們可以創建相應的CTFrameRef然后將富文本渲染在對應的路徑區域內。這段創建代碼如下(由于coreText基于C語言的庫,所有對象都需要我們手動釋放內存):

CGContextRef ctx = UIGraphicsGetCurrentContext();
NSAttributedString * content = [[NSAttributedString alloc] initWithString: @"這是一個測試的富文本,這是一個測試的富文本"];
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);        //繪制文字

// 釋放內存 CFRelease(paths); CFRelease(frame); CFRelease(framesetter); </code></pre>

除此之外,每一個創建的CTFrameRef中存在一個或者更多的CTLineRef變量,這個變量表示繪制文本中的每一行文本。每個CTLineRef變量中存在一個或者更多個CTRunRef變量,在文本繪制過程中,我們并不關心CTLineRef或者CTRunRef變量具體對應的是什么字符,這些工作在更深層次系統已經幫我們完成了創建。創建過程的圖如下:

iOS開發之超鏈接富文本

通過CTFrameRef獲取文本內容的行以及字符串組的代碼如下:

CFArrayRef lines = CTFrameGetLines(frame);
CGPoint lineOrigins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(_frame, CFRangeMake(0, 0), lineOrigins);
for (int idx = 0; idx < CFArrayGetCount(lines); idx++) {
    NSLog(@"第%d行起始坐標%@", 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個字符組的寬度為%f", idx, runWidth);

} </code></pre>

圖文混編的做法就是在我們需要插入表情的富文本位置插入一個空字符占位,然后實現自定義的CTRunDelegateCallbacks來設置這個占位字符的寬高位置信息等,為占位字符添加一個自定義的文本屬性用來存儲對應的表情圖片名字。接著我們通過CTFrameRef獲取渲染的文本行以及文本字符,判斷是否存在存儲的表情圖片,如果是就將圖片繪制在占位字符的位置上。

iOS開發之超鏈接富文本

下面代碼是創建一個CTRunDelegate的代碼,用來設置這個字符組的大小尺寸:

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, "這是回調函數的參數"); </code></pre>

文字排版

文字排版屬于又臭又長的理論概念,但是對于我們更好的使用coreText框架,這些理論知識卻是不可缺少的。 - 字體 
與我們所認知的字體不同的是,在計算機中字體指的是一系列的相同樣式、相同大小的字形的集合,即14號宋體跟15號宋體在計算機看來是兩種字體。而我們所說的字體指 宋體 / 楷體 這些字體類型

  • 字符與字形
    文本排版的過程實際上是從字符到字形之間的轉換。字符表示的是文字本身的信息意義,而字形表示的是這個文字的圖形表現格式。同一個字符由于大小、體形之間的差別,存在著不同字形。由于連寫的存在,多個字符可能只對應一個字形: iOS開發之超鏈接富文本

  • 字形描述集
    描述了字形表現的多個參數,包括:
    1、邊框(Bounding box):一個假想的邊框,盡可能的將整個字形容納
    2、基線(Baseline):一條假想的參考線,以此為基礎渲染字形。正常來說字母x、m、s最下方的位置就是參考線所在y坐標
    3、基礎原點(Origin):基線最左側的坐標點
    4、行間距(Leading):行與行之間的間距
    5、字間距(Kerning):字與字之間的間距
    6、上行高度(Ascent):字形最高點到基線的距離,正數。同一行取字符最大的上行高度為該行的上行高度
    7、下行高度(Descent):字形最低點到基線的距離,負數。同一行取字符最小的下行高度為該行的下行高度
    iOS開發之超鏈接富文本
    下圖中綠色線條表示基線,黃色線條表示下行高度,綠色線條到紅框最頂部的距離為上行高度,而黃色線條到紅框底部的距離為行間距。因此行高的計算公式是

    lineHeight = Ascent + Descent + Leading


iOS開發之超鏈接富文本

圖文混編

前文講了諸多的理論知識,終于來到了實戰的階段,先放上本文的demo地址和效果圖:

iOS開發之超鏈接富文本

由于富文本的繪制需要用到一個CGContextRef類型的上下文,那么創建一個繼承自UIView的自定義控件并且在drawRect: 方法中完成富文本繪制是最方便的方式,我給自己創建的類命名為LXDTextView

CoreText繪制文本的時候,坐標系的原點位于左下角,因此我們需要在繪制文字之前對坐標系進行一次翻轉。并且在繪制富文本之前,我們需要構建好渲染的富文本并在方法里返回:

- (NSMutableAttributedString )buildAttributedString
{
    //創建富文本,并且將超鏈接文本設置為藍色+下劃線
    NSMutableAttributedString  content = [[NSMutableAttributedString alloc] initWithString: @"這是一個富文本內容"];
    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];

//創建CTRunDelegateRef并設置回調函數
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;

}

@end 現在運行代碼,看看點擊富文本的超鏈接時,控制臺是不是輸出了點擊圖片鏈接了呢? </code></pre>

進一步封裝

我們已經完成了富文本的實現,下一步是思考如何從外界傳入文本內容和對應關系,然后顯示。因此,我們需要在頭文件中提供兩個字典類型的屬性,分別用于使用者傳入文本-超鏈接以及文本-表情圖片的對應關系:

@interface LXDTextView : UIView

/*!

  • @brief 顯示文本(所有的鏈接文本、圖片名稱都應該放到這里面) / @property (nonatomic, copy) NSString text;

/*!

  • @brief 文本-超鏈接映射 / @property (nonatomic, strong) NSDictionary hyperlinkMapper;

/*!

  • @brief 文本-表情映射 / @property (nonatomic, strong) NSDictionary emojiTextMapper;

@end </code></pre>

當然,這時候buildAttributedString也應該進行相應的修改,由于富文本中可能存在多個表情,因此需要把往富文本中插入表情占位符的邏輯封裝出來。另一方面,把富文本對象content作為類成員變量來使用,會讓代碼更方便:

/*!

  • @brief 在富文本中插入表情占位符,然后設置好屬性 *
  • @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); } </code></pre>

      buildAttributedString 方法也增加了根據傳入的兩個字典進行富文本字符替換的邏輯。其中表情文本替換應該放在超鏈接文本替換之前——因為表情文本最終替換成一個空格字符串,但是表情文本的長度往往總是大于1。先替換表情文本就不會導致超鏈接文本查找中的位置出錯:

      - (void)buildAttributedString
      {
      _content = [[NSMutableAttributedString alloc] initWithString: _text attributes: self.textAttributes];
      /*!
    • @brief 獲取所有轉換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 獲取所有轉換超鏈接的文本位置 / 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];
      
      } } } </code></pre>

      當然了,如果你樂意的話,還可以順帶的增加一下文本換行類型等屬性的設置:

      /*!
  • @brief 設置字體屬性 / 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); </code></pre>

    由于富文本的渲染流程不會因為富文本內容變化而變化,所以drawRect:內的邏輯幾乎沒有任何改變。但是同樣的,如果你需要擁有點擊表情的事件,那么我們同樣需要像超鏈接文本實現的方式一樣增加一個用來判斷是否點擊在表情frame里面的工具,將表情的rect作為key,表情圖片名字作為value,我把這個字典命名為emojiTouchMapper。此外,前文說過CoreText的坐標系跟常規坐標系是相反的,即使我們在drawRect:開頭翻轉了坐標系,在獲取這些文本坐標時,仍然是按照左下角坐標系計算的。因此如果不做適當處理,那么在點擊的時候就沒辦法按照正常的frame來判斷是否處于點擊范圍內。因此我們需要在遍歷文本行之前聲明一個存儲文本內容最頂部的y坐標變量,在遍歷完成之后用這個變量依次和存儲的表情視圖進行坐標計算,從而存儲正確的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... } </code></pre>

    寫完上面的代碼之后,自定義的富文本視圖就已經完成了,最后需要實現的是點擊結束時判斷點擊位置是否處在表情視圖或者超鏈接文本上,然后進行相應的回調處理。這里使用的是代理方式回調:

    - (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;

    //查找點擊坐標所在的文本行 for (int idx = 0; idx < CFArrayGetCount(lines); idx++) {

    CGPoint origin = origins[idx];
    CGPathRef path = CTFrameGetPath(_frame);
    CGRect rect = CGPathGetBoundingBox(path);
    
    //轉換點擊坐標
    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(@"點擊第%d行", idx);
        break;
    }
    

    }

    if (line == NULL) { return; } touchPoint.x -= lineOrigin.x; CFIndex index = CTLineGetStringIndexForPosition(line, touchPoint);

    //判斷是否點擊超鏈接文本 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;
    }
    

    }

    //判斷是否點擊表情 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]];
        }
    }
    

    } } </code></pre>

    按照上面的代碼完成之后,我們實現了一個富文本控件,我用下面的代碼測試這個圖文混編:

    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.非死book.com",
                         };
    

    textView.text = @"很久很久以前[emoji],在一個群里,生活著@百度、@騰訊這樣的居民,后來,一個[emoji]叫做@谷歌的人入侵了這個村莊,他的同伙@臉書讓整個群里變得淫蕩無比。從此[emoji],迎來了污妖王的時代。污妖王,我當定了![emoji]"; </code></pre>

    運行效果:

    iOS開發之超鏈接富文本

    ##關于TextKit

    使用CoreText來實現圖文混編十分的強大,但同樣帶來了更多的代碼,更復雜的邏輯。在iOS7之后蘋果推出了TextKit框架,基于CoreText進行的高級封裝無疑帶來了更簡潔的代碼,其中新的NSTextAttachment類能讓我們輕松的將圖片轉換成富文本,我們只需要下面這么幾句代碼就能輕松的創建一個表情富文本:

    - (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; }

 本文由用戶 JuliChampio 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!