CJLabel富文本 —— UILabel支持選擇複製以及實現原理

導讀

CJLabel 是一個繼承自UILabel的自定義控件,它在支持UILabel全部屬性的基礎上,還提供富文本展現、圖文混排、自定義點擊鏈點設置、長按(雙擊)喚起UIMenuController選擇複製文本等功能。數組

CJLabel 通過若干版本迭代,各個功能已經日趨完善,而且不斷精細,特別是在V4.0.0版本迎來了重頭戲:新增enableCopy屬性,支持選擇、全選、複製功能,相似UITextView的選擇複製效果。 老規矩,上效果圖: 模塊化

CJLabel

CJLabel1

CJLabel鏈點點擊實現細節

先來回顧一下CJLabel在顯示文本以及響應鏈點點擊的過程當中,底層是怎樣實現的。 函數

CJLabel.png

一. 設置Attributes屬性

首先設置須要顯示的NSAttributedString文本的屬性,除了可設置系統提供的NSFontAttributeName NSForegroundColorAttributeName NSParagraphStyleAttributeName等默認屬性外,還支持CJLabel的若干自定義擴展屬性: kCJBackgroundFillColorAttributeName背景填充顏色 kCJBackgroundStrokeColorAttributeName背景邊框線顏色 kCJBackgroundLineWidthAttributeName背景邊框線寬度 kCJStrikethroughColorAttributeName刪除線顏色 …… CJLabel提供配置管理類CJLabelConfigure,專門用來方便設置指定字符的副文本屬性,同時還提供了對應的API,調用可生成封裝好的NSAttributedString副文本(此處只選取若干方法說明,更多可查看源碼測試

/**
 根據圖片名初始化NSAttributedString

 @param image         圖片名稱,或者UIImage
 @param size          圖片大小(這裏是指顯示圖片等區域大小)
 @param lineAlignment 圖片所在行,圖片與文字在垂直方向的對齊方式(只針對當前行)
 @param configure     鏈點配置
 @return              NSAttributedString
 */
+ (NSMutableAttributedString *)initWithImage:(id)image
                                   imageSize:(CGSize)size
                          imagelineAlignment:(CJLabelVerticalAlignment)lineAlignment
                                   configure:(CJLabelConfigure *)configure;

/**
 根據NSString初始化NSAttributedString
 */
+ (NSMutableAttributedString *)initWithString:(NSString *)string configure:(CJLabelConfigure *)configure;
複製代碼

二. 計算label的CGRect大小

label.attributedText = @"text"優化

UILabel繪製顯示文本,首先會觸發如下方法 -textRectForBounds:limitedToNumberOfLines: -sizeThatFits: 咱們能夠在這兩個方法裏面根據須要顯示的文本內容以及擴展屬性self.textInsets(繪製文本的內邊距,默認UIEdgeInsetsZero),計算當前label的CGRect大小,計算使用的核心函數是:atom

CGSize CTFramesetterSuggestFrameSizeWithConstraints(
	CTFramesetterRef framesetter,
	CFRange stringRange,
	CFDictionaryRef __nullable frameAttributes,
	CGSize constraints,
	CFRange * __nullable fitRange )
複製代碼

三. CTFrameRef

-drawTextInRect:是真正進行內容繪製的方法,咱們將在這裏獲得全部字符對應的CTFrameRef、CTLineRef以及CTRunRef url

CTFrameRef.png

如圖,UILabel顯示的時候,全部內容都由CTFrameRef管理,而後每一行內容是一個CTLineRef,而每一行CTLineRef中包含了若干個CTRunRef。每個CTRunRef對應的可能只是一個字符,也多是整一行文字(連續的具備相同Attributes屬性的字符會包含在同一個CTRunRef中),好比如下例子,CTLineRef中包含三個CTRunRef,分別對應爲:這是 一段 測試數據 三部分。spa

.jpg
獲取到各個字符對應的CTRunRef後,咱們能夠根據CTRunRef進一步判斷獲得這一部分字符在UIlabel中對應的CGRect大小,以及在第一步中對當前字符設置的其餘自定義屬性。這一步也是總體流程中最複雜的部分,涉及到各類座標數值的轉換判斷,最後將CTRunRef對應的信息轉換爲model記錄保存。

四. CTRunDraw

上一步已經獲取獲得了每個CTRunRef的詳細信息,此時咱們能夠執行最後的圖文繪製操做了。首先是繪製自定義背景顏色(即kCJBackgroundFillColorAttributeName相關屬性);而後是繪製圖文,若是是文字執行CTRunDraw(CTRunRef run, CGContextRef context, CFRange range )函數,若是是圖片執行CGContextDrawImage(CGContextRef c, CGRect rect, CGImageRef image)函數;最後填充邊框線以及刪除線(kCJBackgroundStrokeColorAttributeName kCJStrikethroughColorAttributeName)。3d

至此,CJLabel已經完成了顯示部分的全部操做。code

五. 點擊響應

CJLabel默認userInteractionEnabled = YES,如此咱們能夠在touch相關的方法中捕獲到CJLabel的點擊事件,經過判斷點擊觸摸點CGPoint是否在保存記錄的CGRect數組內,若是是則執行對應點擊字符的點擊回調事件,同時觸發點擊字符的高亮重繪(若是存在高亮狀態的話,並且CGContextRef的重繪是全局重繪,沒法作到局部刷新)。

touches.png
另外給CJLabel添加長按手勢 UILongPressGestureRecognizer監聽,在長按事件中一樣執行與touchesBegan:相似的邏輯判斷,從而使CJLabel具有長按點擊功能。

--------------------------------- 分割線 ---------------------------------


以上即是CJLabel功能的實現原理講解,下面進入本文的重點——如何使UILabel具有選擇複製的能力

固然這裏說的選擇複製不多是指點擊喚起UIMenuController菜單,而後出現複製剪切選項,點擊只能複製全部文本那樣的功能。那樣的例子網上已經有不少,沒有必要在這裏再大費周章地來羅列說明。 CJLabel須要具有的是相似於UITextView或UIWebView那樣,雙擊或長按,可出現選擇、全選、拷貝選項,同時選中字符左右出現標示大頭針,拖動則有放大鏡提示當前選中字符,而且儘可能作到與系統行爲一致。

CJLabel.gif
剛開始面對如此需求的時候,感受有點無從下手。查遍資料也沒找到UITextView或UIWebView中有關選擇複製功能的資料說明,更不要說相關的API調用了,很明顯蘋果並無將此類功能封裝成模塊化,就算有那相關的方法也是私有API。 所以在初始的時候,因爲開發時間緊,本人選擇了使用UITextView代替CJLabel做爲顯示控件(產品業務要求支持圖文混排,支持富文本顯示,文本可以自動識別@用戶,可以自動識別網址連接並替換爲規定的圖標展現,文本內容還要支持選擇複製……相似於微博的列表頁面,但卻比它更復雜😓😓😓)

可是UITextView存在若干問題:首先是點擊鏈點的設置不夠靈活,並且鏈點的高亮顏色只能全局設置,不能作到不一樣鏈點分別自定義;再就是UITextView在不一樣的iOS的系統版本下UI層級不一致,並且在觸發點擊、滑動操做時樣式會發生偏移重置。 經歷了初代版本的各類bug填坑,下一次版本迭代時我果斷放棄了UITextView,決定用CJLabel來實現以上的需求。 很明顯,CJLabel自己對於圖文混排、富文本展現部分已經很好的支持了,那麼剩下的就只是怎麼支持選擇複製。

需求細化

選擇複製的需求主要包含如下幾點

  • 選中字符後出現選擇 全選 複製菜單,這個使用系統的UIMenuController功能便可實現,不存在難點問題。
  • 對於選中的文字,起始要有大頭針標識,中間填充淺藍色背景,並且這一部分區域會是一塊不規則多邊形。系統沒有提供現成可複用的對應UI控件,但只要咱們可以判斷到選中區域,想要什麼樣式均可以本身繪製。因此這一塊也不存在問題。
  • 拖動選擇的過程當中,出現放大鏡提示選中字符的更改。在可以獲取到指定觸摸點區域的前提下,只須要將對應區域的CGContextRef上下文作CGContextScaleCTM縮放,而後再將放大後的CALayer層顯示出來,因此這個也是能夠實現的。
  • 最後即是重點了,如何判斷每個字符對應的CGRect座標位置,並在手指移動時準確判斷選擇區域的變化。

實現

回顧前面CJLabel圖文顯示的過程當中,其實已經作過了對特定字符的CGRect座標位置的計算。只不過上面只是對指定鏈點作了判斷記錄,那若是咱們可以對每個字符都作轉換並保存記錄到_allRunItemArray數組內,那麼後面的全部操做就均可以基於_allRunItemArray來實現了。 對應到CTFrameRef層則是須要保證CTLineRef中的每個CTRunRef都只包含一個字符,仍是這個例子:

.jpg
此時它在 CTFrameRef層級應該是 這樣實現,而咱們知道連續的具備相同Attributes屬性的字符會包含在同一個CTRunRef中,那就想辦法讓每個字符都具備不一樣屬性。 我一開始的作法是添加一個自定義屬性 kCJIndexAttributesName,而後給每一個字符存儲不一樣的index值

//給每個字符設置index值,enableCopy=YES時用到
__block NSInteger index = 0;

[attText.string enumerateSubstringsInRange:NSMakeRange(0, [attText length]) options:NSStringEnumerationByComposedCharacterSequences usingBlock:
 ^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
     
     [attText addAttribute:kCJIndexAttributesName
                     value:@(index)
                     range:substringRange];
     index++;
 }];
複製代碼

CTFrameRef的判斷中,確實達到了將每一個字符拆分爲一個對應的CTRunRef要求,但其中卻存在一個難以發覺的bug!!!按照常規思路,對於添加的自定義屬性kCJIndexAttributesName,在遍歷完成後將其移除,那麼以後也就不會再對這個屬性進行判斷。但實際使用中倒是移除並不生效,特別是當頁面內的UITableView存在多個CJLabel,每一個CJLabel都是長文本,滑動的時候會變的愈來愈卡。由於滑動UITableViewCell重置時會對每一個CJLabel的每一個字符的kCJIndexAttributesName作不停的遍歷計算。

然而若是是用系統提供的Attributes相關的屬性設置不一樣值則不會存在以上問題(好不容易纔發現了這個bug,我猜想蘋果對於NSAttributedString的Attributes屬性的管理應該是有一個相似單例的地方統一存儲管理的,並且它對於一些自定義的添加對象不會友好支持)。踩完坑後只好乖乖地從系統方法中尋找解決思路,幸虧發現了NSLinkAttributeName屬性,這是UITextView中用來設置http連接的擴展屬性,存儲的對象是NSURLNSString類型,而UILabel默認是不支持http鏈點的,使用NSLinkAttributeName屬性能夠最大限度的下降UILabel對默認NSAttributedString展現的影響。同時爲了更好的判斷計算,我將存儲的對象改成NSURL的子類CJCTRunUrl,更改後的代碼

//給每個字符設置index值,enableCopy=YES時用到
__block NSInteger index = 0;

[attText.string enumerateSubstringsInRange:NSMakeRange(0, [attText length]) options:NSStringEnumerationByComposedCharacterSequences usingBlock:
 ^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
     
     CJCTRunUrl *runUrl = nil;
     if (!runUrl) {
         NSString *urlStr = [NSString stringWithFormat:@"https://www.CJLabel%@",@(index)];
         runUrl = [CJCTRunUrl URLWithString:urlStr];
     }
     runUrl.index = index;
     runUrl.rangeValue = [NSValue valueWithRange:substringRange];
     [attText addAttribute:NSLinkAttributeName
                     value:runUrl
                     range:substringRange];
     index++;
 }];
複製代碼

初始化的時候給CJLabel新增雙擊手勢UITapGestureRecognizer。 結合前面已經判斷記錄的全部字符的CGRect信息,當發生長按或者雙擊事件的時候,判斷到當前觸摸的字符不是可點擊鏈點時,那麼出現選擇複製視圖。

選擇 複製視圖

選擇複製視圖包含三部分:

  • 選擇、全選、複製 菜單
  • 放大鏡
  • 大頭針包含區域

第一部分直接使用UIMenuController,重點重載如下方法就能夠了

- (BOOL)canBecomeFirstResponder {
    return YES;
}

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
    if ( (action == @selector(select:) && self.attributedText) // 須要有文字才能支持選擇複製
        || (action == @selector(selectAll:) && self.attributedText)
        || (action == @selector(copy:) && self.attributedText))
    {
        return YES;
    }
    return NO;
}
複製代碼

第二部分放大鏡,自定義UIView子類CJMagnifierView,並在CJMagnifierView上添加一個處理放大效果的layer層CJContentLayer

@interface CJContentLayer : CALayer
@property (nonatomic, assign) CGPoint pointToMagnify;//放大點
@end
@implementation CJContentLayer

- (void)drawInContext:(CGContextRef)ctx {
	CGContextTranslateCTM(ctx, self.frame.size.width/2, self.frame.size.height/2);
	CGContextScaleCTM(ctx, 1.40, 1.40);
	CGContextTranslateCTM(ctx, -1 * self.pointToMagnify.x, -1 * self.pointToMagnify.y);
	[CJkeyWindow().layer renderInContext:ctx];
	CJkeyWindow().layer.contents = (id)nil;
}

@end

/**
 長按時候顯示的放大鏡視圖
 */
@interface CJMagnifierView ()
@property (nonatomic, assign) CGPoint pointToMagnify;//放大點
@property (strong, nonatomic) CJContentLayer *contentLayer;//處理放大效果的layer層

- (void)updateMagnifyPoint:(CGPoint)pointToMagnify showMagnifyViewIn:(CGPoint)showPoint;

@end
複製代碼

在更改放大點的時候主動調用[self.contentLayer setNeedsDisplay];那麼就會觸發CJContentLayer-drawInContext:方法,這樣也就達到了更改放大鏡內容的效果。

第三部分大頭針包含區域,一樣自定義UIView的子類CJSelectTextRangeView,而且在其中設定三部分區域headRect middleRect tailRect

CJSelectTextRangeView.png
這三部分存在任意組合的狀況,因此咱們對這三部分區分開來,分別進行顏色填充的操做

/**
 大頭針的顯示類型
 */
typedef NS_ENUM(NSInteger, CJSelectViewAction) {
    ShowAllSelectView    = 0,//顯示大頭針(長按或者雙擊)
    MoveLeftSelectView   = 1,//移動左邊大頭針
    MoveRightSelectView  = 2 //移動右邊大頭針
};
/**
 選中複製填充背景色的view
 */
@interface CJSelectTextRangeView : UIView
/**
 前半部分選中區域
 */
@property (nonatomic, assign) CGRect headRect;
/**
 中間部分選中區域
 */
@property (nonatomic, assign) CGRect middleRect;
/**
 後半部分選中區域
 */
@property (nonatomic, assign) CGRect tailRect;
/**
 選擇內容是否包含不一樣行
 */
@property (nonatomic, assign) BOOL differentLine;
- (void)updateFrame:(CGRect)frame headRect:(CGRect)headRect middleRect:(CGRect)middleRect tailRect:(CGRect)tailRect differentLine:(BOOL)differentLine;
@end
@implementation CJSelectTextRangeView

- (instancetype)init {
    self = [super init];
    if (self) {
        self.backgroundColor = [UIColor clearColor];
        self.opaque = NO;
    }
    return self;
}

- (void)updateFrame:(CGRect)frame headRect:(CGRect)headRect middleRect:(CGRect)middleRect tailRect:(CGRect)tailRect differentLine:(BOOL)differentLine {
    self.differentLine = differentLine;
    self.frame = frame;
    self.headRect = headRect;
    self.middleRect = middleRect;
    self.tailRect = tailRect;
    [self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect {

    CGContextRef ctx = UIGraphicsGetCurrentContext();

    //背景色
    UIColor *backColor = CJUIRGBColor(0,84,166,0.2);
    
    if (self.differentLine) {
        [backColor set];
        CGContextAddRect(ctx, self.headRect);
        if (!CGRectEqualToRect(self.middleRect,CGRectNull)) {
            CGContextAddRect(ctx, self.middleRect);
        }
        CGContextAddRect(ctx, self.tailRect);
        CGContextFillPath(ctx);
        
        [self updatePinLayer:ctx point:CGPointMake(self.headRect.origin.x, self.headRect.origin.y) height:self.headRect.size.height isLeft:YES];
        
        [self updatePinLayer:ctx point:CGPointMake(self.tailRect.origin.x + self.tailRect.size.width, self.tailRect.origin.y) height:self.tailRect.size.height isLeft:NO];
    }else{
        
        [backColor set];
        CGContextAddRect(ctx, self.middleRect);
        CGContextFillPath(ctx);
        
        [self updatePinLayer:ctx point:CGPointMake(self.middleRect.origin.x, self.middleRect.origin.y) height:self.middleRect.size.height isLeft:YES];
        
        [self updatePinLayer:ctx point:CGPointMake(self.middleRect.origin.x + self.middleRect.size.width, self.middleRect.origin.y) height:self.middleRect.size.height isLeft:NO];
    }
    
    CGContextStrokePath(ctx);
}

- (void)updatePinLayer:(CGContextRef)ctx point:(CGPoint)point height:(CGFloat)height isLeft:(BOOL)isLeft {
    UIColor *color = [UIColor colorWithRed:0/255.0 green:128/255.0 blue:255/255.0 alpha:1.0];
    CGRect roundRect = CGRectMake(point.x - 5,
                                  isLeft?(point.y - 10):(point.y + height),
                                  10,
                                  10);
    //畫圓
    CGContextAddEllipseInRect(ctx, roundRect);
    [color set];
    CGContextFillPath(ctx);
    
    CGContextMoveToPoint(ctx, point.x, point.y);
    CGContextAddLineToPoint(ctx, point.x, point.y + height);
    CGContextSetLineWidth(ctx, 2.0);
    CGContextSetStrokeColorWithColor(ctx, color.CGColor);
    
    CGContextStrokePath(ctx);
}

@end
複製代碼

接下來即是顯示這三個選擇複製相關的視圖了,一開始我只是簡單的將它們添加到自定義的CJSelectBackView上面,在將CJSelectBackView add 到CJLabel上面來統一管理的,但這樣會存在一個問題。那就是當頁面中存在多個CJLabel,而且對多個CJLabel分別執行選擇複製操做時,那麼不一樣的label上都會出現選擇複製視圖,這是與系統的默認行爲不一致的。就算是頁面內存在多個不一樣的UITextView,對不一樣的UITextView進行選擇複製,系統給人的感受是隻會有一個選擇控制視圖存在。

權衡以後我選擇將CJSelectBackView做爲單例處理,全局只初始化一次,避免了重複初始化的開銷。而且引入UIWindow層,在不一樣的CJLabel之間進行選擇複製時,藉助UIWindow來進行控制切換。 最終選擇複製相關的層級結構以下

CJLabelSelect.png
最後即是一些優化操做了,好比選擇複製拖動的時候處理UIScrollView滑動的手勢衝突,左右大頭針高度的調整等。

具體的實現能夠查看源碼CJLabel,歡迎star以及issue

相關文章
相關標籤/搜索