CJLabel富文本三 —— UILabel支持选择复制以及实现原理

CJLabel经过若干版本迭代,各个功能已经日趋完善,并且不断精细,特别是在V4.0.0版本迎来了重头戏:新增enableCopy属性,支持选择、全选、复制功能,类似UITextView的选择复制效果。
老规矩,上效果图:

CJLabel

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大小,计算使用的核心函数是:

CGSize CTFramesetterSuggestFrameSizeWithConstraints(
    CTFramesetterRef framesetter,
    CFRange stringRange,
    CFDictionaryRef __nullable frameAttributes,
    CGSize constraints,
    CFRange * __nullable fitRange )
三. CTFrameRef

-drawTextInRect:是真正进行内容绘制的方法,我们将在这里得到所有字符对应的CTFrameRef、CTLineRef以及CTRunRef

CTFrameRef.png

如图,UILabel显示的时候,所有内容都由CTFrameRef管理,然后每一行内容是一个CTLineRef,而每一行CTLineRef中包含了若干个CTRunRef。每一个CTRunRef对应的可能只是一个字符,也可能是整一行文字(连续的具有相同Attributes属性的字符会包含在同一个CTRunRef中),比如以下例子,CTLineRef中包含三个CTRunRef,分别对应为:这是 一段 测试数据三部分。

.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)。

至此,CJLabel已经完成了显示部分的所有操作。

五. 点击响应

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,864评论 6 494
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,175评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,401评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,170评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,276评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,364评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,401评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,179评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,604评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,902评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,070评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,751评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,380评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,077评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,312评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,924评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,957评论 2 351