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