CoreText實現圖文混排之文字環繞及點擊算法

CoreText實現圖文混排之文字環繞及點擊算法

系列文章:


終于我來完成我CoreText圖文混排的最后一章了。

先說一下我為什么會來補發這一章呢?

1.老司機最開始沒有留demo,以至于這個博客老司機從發出來到現在整整維護了半年了=。=其實博客里面就是全部代碼,但是寶寶們任性的要demo。

2.時間長了,閱讀量也上去了,老司機覺得自己有必要對粉絲們負責了

3.有很多同學詢問是否能做出文字環繞的效果,老司機之前的確也沒有寫過,這一篇是要補上的。

4.關于點擊事件,老司機在第二篇文章中有提到過一個思路,是每次遍歷所有CTRun去做的。后期老司機考慮到遍歷的實現效率似乎有些低,所以老司機研究了一下,重新整理思路,優化了一下算法。

基于以上原因,以及一個陰謀,老司機又來更文了。

勞資回來了

在這篇文章中你可以看到以下內容:

  • 圖片環繞的實現方式
  • 點擊事件獲取的優化算法

看了本篇博客,老司機能夠幫你實現如下效果

CoreText

這篇博客是以前兩篇博客作為知識鋪墊的,如果沒有看過前兩篇博客的童靴建議你去補票。當然本身你就了解CoreText相關知識的話也可以直接看本篇文章。


全部代碼

優化算法以后,代碼有些許改變,不過主體思路是一致的。下面是全部代碼。

@interface CoreTextV ()
{
    CTFrameRef _frame;
    NSInteger _length;
    CGRect _imgFrm;
    NSMutableArray * arrText;
}
@end
@implementation CoreTextV

-(void)drawRect:(CGRect)rect
{
    [super drawRect:rect];
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetTextMatrix(context, CGAffineTransformIdentity);    CGContextTranslateCTM(context, 0, self.bounds.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    arrText = [NSMutableArray array];
    NSMutableAttributedString * attributeStr = [[NSMutableAttributedString alloc] initWithString:@"123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"];
    [attributeStr addAttribute:NSForegroundColorAttributeName value:[UIColor whiteColor] range:NSMakeRange(0, attributeStr.length)];
    CTRunDelegateCallbacks callBacks;
    memset(&callBacks, 0, sizeof(CTRunDelegateCallbacks));
    callBacks.version = kCTRunDelegateVersion1;      callBacks.getAscent = ascentCallBacks;     callBacks.getDescent = descentCallBacks;        callBacks.getWidth = widthCallBacks;
    NSDictionary * dicPic = @{@"height":@90,@"width":@160};
    CTRunDelegateRef delegate = CTRunDelegateCreate(& callBacks, (__bridge void *)dicPic);
    unichar placeHolder = 0xFFFC;
    NSString * placeHolderStr = [NSString stringWithCharacters:&placeHolder length:1];
    NSMutableAttributedString * placeHolderAttrStr =    [[NSMutableAttributedString alloc] initWithString:placeHolderStr];
    CFAttributedStringSetAttribute((CFMutableAttributedStringRef)placeHolderAttrStr, CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);    CFRelease(delegate);
    [attributeStr insertAttributedString:placeHolderAttrStr atIndex:300];
    NSDictionary * activeAttr = @{NSForegroundColorAttributeName:[UIColor redColor],@"click":NSStringFromSelector(@selector(click))};
    [attributeStr addAttributes:activeAttr range:NSMakeRange(100, 30)];
    
    [attributeStr addAttributes:activeAttr range:NSMakeRange(400, 100)];

    CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributeStr);
    UIBezierPath * path = [UIBezierPath bezierPathWithRect:self.bounds];
    UIBezierPath * cirP = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(100, 100, 100, 200)];
    [path appendPath:cirP];
    _length = attributeStr.length;
    _frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, _length), path.CGPath, NULL);
    CTFrameDraw(_frame, context);
    
    UIImage * image = [UIImage imageNamed:@"1.jpg"];
    [self handleActiveRectWithFrame:_frame];
    CGContextDrawImage(context,_imgFrm, image.CGImage);
    
    CGContextDrawImage(context, cirP.bounds, [[UIImage imageNamed:@"1.jpg"] dw_ClipImageWithPath:cirP mode:(DWContentModeScaleAspectFill)].CGImage);
    CFRelease(_frame);
    CFRelease(frameSetter);
}

static CGFloat ascentCallBacks(void * ref)
{
    return [(NSNumber *)[(__bridge NSDictionary *)ref valueForKey:@"height"] floatValue];
}
static CGFloat descentCallBacks(void * ref)
{
    return 0;
}
static CGFloat widthCallBacks(void * ref)
{
    return [(NSNumber *)[(__bridge NSDictionary *)ref valueForKey:@"width"] floatValue];
}


-(void)handleActiveRectWithFrame:(CTFrameRef)frame
{
    NSArray * arrLines = (NSArray *)CTFrameGetLines(frame);
    NSInteger count = [arrLines count];
    CGPoint points[count];
    CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), points);
    for (int i = 0; i < count; i ++) {
        CTLineRef line = (__bridge CTLineRef)arrLines[i];
        NSArray * arrGlyphRun = (NSArray *)CTLineGetGlyphRuns(line);
        for (int j = 0; j < arrGlyphRun.count; j ++) {
            CTRunRef run = (__bridge CTRunRef)arrGlyphRun[j];
            NSDictionary * attributes = (NSDictionary *)CTRunGetAttributes(run);
            CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[attributes valueForKey:(id)kCTRunDelegateAttributeName];
            CGPoint point = points[i];
            if (delegate == nil) {
                NSString * string = attributes[@"click"];
                if (string) {
                    [arrText addObject:[NSValue valueWithCGRect:[self getLocWithFrame:frame CTLine:line CTRun:run origin:point]]];
                }
                continue;
            }
            NSDictionary * metaDic = CTRunDelegateGetRefCon(delegate);
            if (![metaDic isKindOfClass:[NSDictionary class]]) {
                continue;
            }
            _imgFrm = [self getLocWithFrame:frame CTLine:line CTRun:run origin:point];
        }
    }
}

-(CGRect)getLocWithFrame:(CTFrameRef)frame CTLine:(CTLineRef)line CTRun:(CTRunRef)run origin:(CGPoint)origin
{
    CGFloat ascent;
    CGFloat descent;
    CGRect boundsRun;
    boundsRun.size.width = CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, &descent, NULL);
    boundsRun.size.height = ascent + descent;
    CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
    boundsRun.origin.x = origin.x + xOffset; 
    boundsRun.origin.y = origin.y - descent;
    CGPathRef path = CTFrameGetPath(frame);
    CGRect colRect = CGPathGetBoundingBox(path); 
    CGRect deleteBounds = CGRectOffset(boundsRun, colRect.origin.x, colRect.origin.y);
    return deleteBounds;
}

-(CGRect)convertRectFromLoc:(CGRect)rect
{
    return CGRectMake(rect.origin.x, self.bounds.size.height - rect.origin.y - rect.size.height, rect.size.width, rect.size.height);
}

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    UITouch * touch = [touches anyObject];
    CGPoint location = [touch locationInView:self];
    CGRect imageFrmToScreen = [self convertRectFromLoc:_imgFrm];
    if (CGRectContainsPoint(imageFrmToScreen, location)) {
        [[[UIAlertView alloc] initWithTitle:nil message:@"你點擊了圖片" delegate:nil cancelButtonTitle:@"好的" otherButtonTitles:nil] show];
        return;
    }
    [arrText enumerateObjectsUsingBlock:^(NSValue * rectV, NSUInteger idx, BOOL * _Nonnull stop) {
        CGRect textFrmToScreen = [self convertRectFromLoc:[rectV CGRectValue]];
        if (CGRectContainsPoint(textFrmToScreen, location)) {
            [self click];
            *stop = YES;
        }
    }];
}

-(void)click
{
    [[[UIAlertView alloc] initWithTitle:nil message:@"你點擊了文字" delegate:nil cancelButtonTitle:@"好的" otherButtonTitles:nil] show];
}
@end

只關心結果或者著急寫項目的童靴看到這里就足夠了,因為所有代碼都在,想找demo的話就去文章末尾找吧。因為接下來老司機要開始扯淡了。。。跟你們講講一切的實現思路


圖片環繞的實現方式

由于我只是給個demo,所以一切代碼均從簡寫。實際過程中,代碼應進行封裝分塊。

我們將視線集中到drawRect方法中吧。

之前的文章老司機講過,我們在drawRect中繪制文本的時候主要是根據Path去繪制的。

UIBezierPath * path = [UIBezierPath bezierPathWithRect:self.bounds];
UIBezierPath * cirP = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(100, 100, 100, 200)];
[path appendPath:cirP];
_length = attributeStr.length;
_frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, _length), path.CGPath, NULL);
CTFrameDraw(_frame, context);

我們可以看到,我們是以path和frameSetter去生成我們繪制文本的frame的。所以說,只要在這個地方我們傳入的path中將特殊區域排除我們獲得的frame就不包含該區域,從而繪制的文本也不會在該區域中繪制

所以說上述的代碼你看到的應該是這樣子的文字區域

排除文字區域

這里你可能會有個疑問,問什么我cirP的rect是CGRectMake(100, 100, 100, 200),這個排除的區域卻在那里。這里你還記得老司機在第一篇文章里就說過屏幕坐標系統跟系統坐標系統的區別呢,原因就在這。

也就是說,到了這里,我們只要繪制出這個橢圓形的圖片就可以了。這你可能需要借助老司機之前寫好的工具類,在這個倉庫里的DWImageUtils就是了。如果好用記得給我個star吧。

有了這個工具類,你就可以這樣生成橢圓圖片

[image dw_ClipImageWithPath:cirP mode:(DWContentModeScaleAspectFill)]

有了圖片了,情況基本就變成了我們熟悉的狀況了,繪制圖片

CGContextDrawImage(context, cirP.bounds, [[UIImage imageNamed:@"1.jpg"] dw_ClipImageWithPath:cirP mode:(DWContentModeScaleAspectFill)].CGImage);

至此,我們就繪制出環繞的文本了。也算真正的實現所謂的圖文混排了。


點擊事件獲取的優化算法

首先老司機來講一下目前老司機了解到的幾種獲取點擊事件的方式。

主流方式:CTLineGetStringIndexForPosition

主流方式就是當前大部分基于CoreText封裝的富文本展示類(包括TTTAttributedLabelNIAttributedLabelFTCoreTextView)中使用的方法 CTLineGetStringIndexForPosition。這個方法是獲取當前點在所在文字處于當前繪制文本的索引值。事實上如果沒有一些其他因素的話,能使用這個方法是最簡便快捷的。然而老司機為什么沒有使用這個方法去獲取點擊事件呢?請看下面的動圖??

CTLineGetStringIndexForPosition

這里老司機是以TTTAttributedLabel為樣本做了一個點擊事件的Demo。

先明確一點,有下劃線的區域應該為實際點擊響應區域。可以看到,實際的響應區域相比預期響應區域x坐標會整體向左偏移一定區域

實際使用中CTLineGetStringIndexForPosition這個方法獲取一個字的index范圍是這個字前面大概半個字開始到這個字中間的位置。從這個字的中間到這個字的后半個字就會獲得下一個字的index。舉個例子:(勾選的勾字index應該為0,當點擊勾字左半部分的時候返回0,右半部分返回1)。

老司機查閱了很多資料,有的資料說這個方法在當有段前縮進或者首行縮進的時候,并不準確,不會跟著縮進而進行偏移。然而老司機在將段前縮進設為0仍然有這個問題。老司機也不知道具體問題在哪,然而老司機有強迫癥的不能允許這半個字的誤差,所以老司機當時決定不用這個方法,自己另辟蹊徑。就有了老司機當時的遍歷每個CTRun的算法。

多說一句,CTLineGetStringIndexForPosition這個方法還有另一個作用還是很好用的。這個方法最好的用處就是判斷一行CTLine最多容納多少的字符,只需把這個point的x位置調很大(超過CTFrame path的寬度)就可以了

另辟蹊徑:遍歷CTRun比較法。

老司機當時覺得半個字的誤差實在是難以容忍,所以老司機舍近求遠想出了這套遍歷CTRun的算法。因為執行效率上一個屏幕內能展示的文字所包含的CTRun的數量在遍歷過程中并不會造成過多的性能浪費,所以老司機當時也沒有在意。

直到后來老司機的項目中由于要盡量少的使用三方SDK,所以自告奮勇的把自己寫的coreText的可點擊label引入到工程里面。然而項目經理看了源碼后表示雖然他沒用過CoreText,但是遍歷真的很蛋疼,決定引入一個TTT。老司機的心情瞬間跌入谷底。老司機當即決定,我要優化算法

就在身邊卻一直被我忽略的一個點

之所以說優化算法,沒有說不用遍歷是因為CoreText就那么些東西,獲取圖片的_frame還是需要遍歷整個CTFrame中的所有CTRun的。所以老司機花了整整一個禮拜也沒找到替代遍歷的方法

終于有一天,感受到了月亮的召喚,老司機變!身!了!

變身了

順便想到了一個思路,避免不了遍歷我就只遍歷一次就好了。一次遍歷中拿到所有活動圖片和活動文字的frame,然后事情就簡單多了,按照點擊圖片的處理方式處理文字就好了。

所以老司機就想了一個辦法期望在遍歷的時候可以拿到活動文字的特征點,從而獲取活動范圍。老司機順理成章的就給想要添加點擊事件的活動文本加了click這么一個屬性。(demo中老司機就隨便寫了,實際要慎重考慮叫什么名字不會被覺得太Low??)

其實實現上的思路很簡單,只是之前沒想到,感謝月亮的召喚吧還是。

關我毛事
NSDictionary * activeAttr = @{NSForegroundColorAttributeName:[UIColor redColor],@"click":NSStringFromSelector(@selector(click))};
[attributeStr addAttributes:activeAttr range:NSMakeRange(100, 30)];
[attributeStr addAttributes:activeAttr range:NSMakeRange(400, 100)];

這里老司機很隨意的添加了一個click屬性。

[arrText addObject:[NSValue valueWithCGRect:[self getLocWithFrame:frame CTLine:line CTRun:run origin:point]]];

然后這里老司機就把活動文本的frame計算出來了。

思路就是這么簡單。本著一年內保證售后服務的原則,老司機會給在前兩篇博客中要demo的童鞋再發一份最新的demo通知新算法。

另外老司機這里要提醒你一點的就是,文字frame不同于獲取圖片的frame。由于圖片是在一個空白占位符上繪制文字,所以一定是以一個CTRun進行繪制的。但是第一篇文章中老司機說過,每個CTRun是所有具有相同屬性的連續同行文字的集合。針對CTRun的特性,我們不難想到,文字由于可能出現兩行,也有可能會活動文本的字體字號等其他屬性不盡相同導致一段文字由兩個CTRun進行繪制,所以不能單純的保存一個frame,而是要以一個數組容納他。再通過一些邏輯將不同的活動文本區別開來。由于是demo所以一切從簡老司機沒有說怎么區分活動文本,給個思路,就是你給click屬性綁定的value就可以用作區分(這句好拗口)。至此,全部的文字點擊及圖片點擊事件的大體思路講述完畢。


為了吸取教訓,這次老司機會留下demo。恩,也要去前兩篇通知他們第三篇有demo。

去這里下載demo

然后,

廣告時間了:

痛定思痛的老司機寫了一個真正支持圖文混排點擊事件的Label控件。你可以實現如下的效果。

DWCoreTextLabel

插入圖片、繪制圖片、添加事件統統一句話實現~

一句話實現

盡可能保持系統Label屬性讓你可以無縫過渡使用~

無縫過渡

恩,說了這么多,老司機放一下地址:DWCoreTextLabel,寶寶們給個star吧愛你喲

愛你喲

文章到最后都是一樣的,喜歡點贊吧關注吧話說老司機超愛兔子的

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

推薦閱讀更多精彩內容