CJLabel 是一個繼承自UILabel的自定義控件,它在支持UILabel全部屬性的基礎上,還提供富文本展現、圖文混排、自定義點擊鏈點設置、長按(雙擊)喚起UIMenuController選擇複製文本等功能。數組
CJLabel
通過若干版本迭代,各個功能已經日趨完善,而且不斷精細,特別是在V4.0.0
版本迎來了重頭戲:新增enableCopy屬性,支持選擇、全選、複製功能,相似UITextView的選擇複製效果。 老規矩,上效果圖: 模塊化
先來回顧一下CJLabel在顯示文本以及響應鏈點點擊的過程當中,底層是怎樣實現的。 函數
首先設置須要顯示的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.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 )
複製代碼
-drawTextInRect:
是真正進行內容繪製的方法,咱們將在這裏獲得全部字符對應的CTFrameRef、CTLineRef以及CTRunRef url
如圖,UILabel顯示的時候,全部內容都由CTFrameRef管理,而後每一行內容是一個CTLineRef,而每一行CTLineRef中包含了若干個CTRunRef。每個CTRunRef對應的可能只是一個字符,也多是整一行文字(連續的具備相同Attributes屬性的字符會包含在同一個CTRunRef中),好比如下例子,CTLineRef中包含三個CTRunRef,分別對應爲:這是
一段
測試數據
三部分。spa
上一步已經獲取獲得了每個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的重繪是全局重繪,沒法作到局部刷新)。
UILongPressGestureRecognizer
監聽,在長按事件中一樣執行與touchesBegan:相似的邏輯判斷,從而使CJLabel具有長按點擊功能。
--------------------------------- 分割線 ---------------------------------
以上即是CJLabel功能的實現原理講解,下面進入本文的重點——如何使UILabel具有選擇複製的能力
固然這裏說的選擇複製不多是指點擊喚起UIMenuController
菜單,而後出現複製剪切選項,點擊只能複製全部文本那樣的功能。那樣的例子網上已經有不少,沒有必要在這裏再大費周章地來羅列說明。 CJLabel須要具有的是相似於UITextView或UIWebView那樣,雙擊或長按,可出現選擇、全選、拷貝
選項,同時選中字符左右出現標示大頭針,拖動則有放大鏡提示當前選中字符,而且儘可能作到與系統行爲一致。
可是UITextView存在若干問題:首先是點擊鏈點的設置不夠靈活,並且鏈點的高亮顏色只能全局設置,不能作到不一樣鏈點分別自定義;再就是UITextView在不一樣的iOS的系統版本下UI層級不一致,並且在觸發點擊、滑動操做時樣式會發生偏移重置。 經歷了初代版本的各類bug填坑,下一次版本迭代時我果斷放棄了UITextView,決定用CJLabel來實現以上的需求。 很明顯,CJLabel自己對於圖文混排、富文本展現部分已經很好的支持了,那麼剩下的就只是怎麼支持選擇複製。
選擇複製的需求主要包含如下幾點
選擇 全選 複製
菜單,這個使用系統的UIMenuController
功能便可實現,不存在難點問題。CGContextRef
上下文作CGContextScaleCTM
縮放,而後再將放大後的CALayer
層顯示出來,因此這個也是能夠實現的。CGRect
座標位置,並在手指移動時準確判斷選擇區域的變化。回顧前面CJLabel圖文顯示的過程當中,其實已經作過了對特定字符的CGRect
座標位置的計算。只不過上面只是對指定鏈點作了判斷記錄,那若是咱們可以對每個字符都作轉換並保存記錄到_allRunItemArray
數組內,那麼後面的全部操做就均可以基於_allRunItemArray
來實現了。 對應到CTFrameRef
層則是須要保證CTLineRef
中的每個CTRunRef
都只包含一個字符,仍是這個例子:
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連接的擴展屬性,存儲的對象是NSURL
或NSString
類型,而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
/**
大頭針的顯示類型
*/
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來進行控制切換。 最終選擇複製相關的層級結構以下
具體的實現能夠查看源碼CJLabel,歡迎star以及issue