iOS 中 UILabel 文字部分点击,部分下划线

项目中突然有一个需求:一段文字中包含下划线的文字,并且包含点击事件。网上已经有 iOS实现一段文字中部分有下划线,并且可以点击 这个实现方法啦。但是想到 UILabel 的也可以实现的。

一、 TYAttributedLabel 小试

记得之前搜罗过 github 上的富文本Label , 然后对比了下,用 TYAttributedLabel 小试了一下,实现下面这中类似效果:

效果图
#import "ViewController.h"
#import "TYAttributedLabel.h"

@interface ViewController ()<TYAttributedLabelDelegate>

@property (nonatomic, strong) TYAttributedLabel *testLabel;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.edgesForExtendedLayout = UIRectEdgeNone;
    [self addTextAttributed];
}

- (void)addTextAttributed {
    [self.view addSubview:self.testLabel];
    // 规则声明
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc]initWithString:@"I agree to myApp "];
    [attributedString addAttributeTextColor:[UIColor blackColor]];
    [attributedString addAttributeFont:[UIFont systemFontOfSize:15]];
    [self.testLabel appendTextAttributedString:attributedString];
    
    // 增加链接 Terms and Conditions
    [self.testLabel appendLinkWithText:@"Terms and Conditions" linkFont:[UIFont systemFontOfSize:15] linkColor:[UIColor blueColor] linkData:@"https://www.baidu.com"];
    // And
    NSMutableAttributedString *attributedAndString = [[NSMutableAttributedString alloc]initWithString:@" and "];
    [attributedAndString addAttributeTextColor:[UIColor blackColor]];
    [attributedAndString addAttributeFont:[UIFont systemFontOfSize:15]];
    [self.testLabel appendTextAttributedString:attributedAndString];
    
    // 增加链接 Privacy Polices
    [self.testLabel appendLinkWithText:@"Privacy Polices" linkFont:[UIFont systemFontOfSize:15] linkColor:[UIColor blueColor] linkData:@"https://www.google.com"];
    
    [self.testLabel sizeToFit];
}

#pragma mark - Delegate
//TYAttributedLabelDelegate
- (void)attributedLabel:(TYAttributedLabel *)attributedLabel textStorageClicked:(id<TYTextStorageProtocol>)TextRun atPoint:(CGPoint)point {
    if ([TextRun isKindOfClass:[TYLinkTextStorage class]]) {
        NSString *linkStr = ((TYLinkTextStorage*)TextRun).linkData;
        NSLog(@"linkStr === %@",linkStr);
    }
}

#pragma mark - Getter
- (TYAttributedLabel *)testLabel {
    if (!_testLabel) {
        _testLabel = [[TYAttributedLabel alloc] initWithFrame:CGRectMake(20, 20, [UIScreen mainScreen].bounds.size.width - 40, 0)];
        _testLabel.delegate = self;
        _testLabel.highlightedLinkColor = [UIColor orangeColor];
    }
    return _testLabel;
}

@end

二、 TYAttributedLabel 学习

  • 如何确定高度
  • 如何画下滑线
  • 如何确定点击事件
2-1、如何确定高度

通过调用 sizeToFit, 再重调用 sizeThatFits, 然后获取想要的高度:

- (void)sizeToFit
{
    [super sizeToFit];
}

- (CGSize)sizeThatFits:(CGSize)size
{
    return [self getSizeWithWidth:CGRectGetWidth(self.frame)];
}

这里要特别看下获取高度的方法,它这边也是从 TTTAttributedLabel 引用的。

// this code quote TTTAttributedLabel
static inline CGSize CTFramesetterSuggestFrameSizeForAttributedStringWithConstraints(CTFramesetterRef framesetter, NSAttributedString *attributedString, CGSize size, NSUInteger numberOfLines) {
    CFRange rangeToSize = CFRangeMake(0, (CFIndex)[attributedString length]);
    CGSize constraints = CGSizeMake(size.width, MAXFLOAT);
    
    if (numberOfLines > 0) {
        // If the line count of the label more than 1, limit the range to size to the number of lines that have been set
        CGMutablePathRef path = CGPathCreateMutable();
        CGPathAddRect(path, NULL, CGRectMake(0.0f, 0.0f, constraints.width, MAXFLOAT));
        CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
        CFArrayRef lines = CTFrameGetLines(frame);
        
        if (CFArrayGetCount(lines) > 0) {
            NSInteger lastVisibleLineIndex = MIN((CFIndex)numberOfLines, CFArrayGetCount(lines)) - 1;
            CTLineRef lastVisibleLine = CFArrayGetValueAtIndex(lines, lastVisibleLineIndex);
            
            CFRange rangeToLayout = CTLineGetStringRange(lastVisibleLine);
            rangeToSize = CFRangeMake(0, rangeToLayout.location + rangeToLayout.length);
        }
        
        CFRelease(frame);
        CFRelease(path);
    }
    
    CGSize suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, rangeToSize, NULL, constraints, NULL);
    
    return CGSizeMake(ceil(suggestedSize.width), ceil(suggestedSize.height));
}

这一块具体的计算还有点懵,简单的来说就是对于 NSAttributedString 通过CTFramesetterRef 的相关换算来计算所占的 size, 此处有待挖掘。

2-2、如何画下滑线

此处是直接通过 NSMutableAttributedString 的系统方法,只是感觉平常对CoreText 对这块了解太少了。

- (void)addAttribute:(NSString *)name value:(id)value range:(NSRange)range

[attributedString addAttribute:(NSString *)kCTUnderlineStyleAttributeName
             value:[NSNumber numberWithInt:(kCTUnderlineStyleSingle|kCTUnderlinePatternSolid)]
             range:NSMakeRange(0, attributedString.length)];

注意系统自带的:

  • kCTUnderlineStyleAttributeName
    字体下划线样式属性 必须是CFNumberRef对象,默为kCTUnderlineStyleNone 可以通过CTUnderlineStypleModifiers 进行修改下划线风格

  • kCTUnderlineStyleSingle

typedef CF_OPTIONS(int32_t, CTUnderlineStyle) {
    kCTUnderlineStyleNone           = 0x00,
    kCTUnderlineStyleSingle         = 0x01,
    kCTUnderlineStyleThick          = 0x02,
    kCTUnderlineStyleDouble         = 0x09
};
CTUnderlineStyle
  • kCTUnderlinePatternSolid
typedef CF_OPTIONS(int32_t, CTUnderlineStyleModifiers) {
    kCTUnderlinePatternSolid        = 0x0000,  
    kCTUnderlinePatternDot          = 0x0100, 
    kCTUnderlinePatternDash         = 0x0200, 
    kCTUnderlinePatternDashDot      = 0x0300, 
    kCTUnderlinePatternDashDotDot   = 0x0400 
};
CTUnderlineStyleModifiers
2-3、 如何确定点击事件

再次细分,可以理解为:

  • 如何确定点击的点是否有反应的
  • 如何找到点击的位置属于哪一块
  • 如何将之前值通过代理传值过去

此处主要的是如何确定点击的点,是否我们需要的位置。此处添加点击事件之后,获取其位置,然后通过 Ponit判断对应的 attributedStringRect,

CG_EXTERN bool CGRectContainsPoint(CGRect rect, CGPoint point)

从而做出响应判断。

下面是我用 伪代码 大致实现其思路,假装只有一行的实现

伪代码所实现的效果
  • 注意是伪代码式例
- (void)addLineTestLabel {
    [self.view addSubview:self.testLineLabel];
    self.testLineLabel.userInteractionEnabled = YES;
    [self.testLineLabel addGestureRecognizer:self.tabGesture];
    
    // 总的 attributedString
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc]init];
    
    // 实现 One 下划线
    NSMutableAttributedString *attributedOneString = [[NSMutableAttributedString alloc]initWithString:@"One String"];
    [attributedOneString addAttribute:(NSString *)kCTUnderlineStyleAttributeName
                                value:[NSNumber numberWithInt:(kCTUnderlineStyleSingle|kCTUnderlinePatternSolid)]
                                range:NSMakeRange(0, attributedOneString.length)];
    
    [attributedOneString addAttribute:(NSString *)kCTUnderlineColorAttributeName
                                value:(id)[UIColor redColor].CGColor
                                range:NSMakeRange(0, attributedOneString.length)];
    // 记录 One 位置,此处是模拟, 60 是指attributedOneString 的长度
    CGRect runOneRect = CGRectMake(0, 0, 60,self.testLineLabel.frame.size.height);
    [self.testDic setObject:@"https://www.testOne.com" forKey:[NSValue valueWithCGRect:runOneRect]];
    
    
    // 实现 And
    NSMutableAttributedString *attributedAndString = [[NSMutableAttributedString alloc]initWithString:@" And "];

    
    // 实现 Two 下划线
    NSMutableAttributedString *attributedTwoString = [[NSMutableAttributedString alloc]initWithString:@"Two String"];
    [attributedTwoString addAttribute:(NSString *)kCTUnderlineStyleAttributeName
                                value:[NSNumber numberWithInt:(kCTUnderlineStyleSingle|kCTUnderlinePatternDot)]
                                range:NSMakeRange(0, attributedTwoString.length)];
    // 记录 Two 位置,此处是模拟, 60 是指 attributedTwoString 的长度
    CGRect runTwoRect = CGRectMake(self.testLineLabel.frame.size.width - 60, 0, attributedTwoString.length,self.testLineLabel.frame.size.height);
    [self.testDic setObject:@"https://www.testTwo.com" forKey:[NSValue valueWithCGRect:runTwoRect]];
    
    // 拼接起来
    [attributedString appendAttributedString:attributedOneString];
    [attributedString appendAttributedString:attributedAndString];
    [attributedString appendAttributedString:attributedTwoString];
    
    [attributedString setAttributes:@{NSForegroundColorAttributeName : [UIColor orangeColor]} range:NSMakeRange(attributedOneString.length, attributedAndString.length)];

    self.testLineLabel.attributedText = attributedString;

}

- (void)tapGestureAction:(UITapGestureRecognizer *)tap {
    CGPoint point = [tap locationInView:self.testLineLabel];
    [self enumerateRunRectWithContainPoint:point successBlock:^(NSString *linkStr) {
        NSLog(@"testLinkStr === %@",linkStr);
    }];
}

- (BOOL)enumerateRunRectWithContainPoint:(CGPoint)point successBlock:(void (^)(NSString *linkStr))successBlock {
    if (self.testDic.count < 1) {
        return NO;
    }
    __block BOOL find = NO;
    [self.testDic enumerateKeysAndObjectsUsingBlock:^(NSValue *keyRectValue, NSString *linkStr, BOOL * _Nonnull stop) {
        CGRect rect = [keyRectValue CGRectValue];
        if(CGRectContainsPoint(rect, point)){
            find = YES;
            *stop = YES;
            if (successBlock) {
                successBlock(linkStr);
            }
        }
    }];
    return find;
}

当然里面实现是复杂很多,考虑了各种情况的,其中最主要的是 如何获取 该attributedString 对应的位置:

- (void)saveTextRectWithAttributedString:(NSMutableAttributedString *)attributedString {
    // 创建CTFramesetter
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
    // 创建CTFrameRef
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddRect(path, NULL, CGRectMake(0, 0, self.testLineLabel.frame.size.width, self.testLineLabel.frame.size.height));
    
    CTFrameRef frameRef = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedString.length), path, NULL);
    CFRelease(path);
    // 释放内存
    CFRelease(framesetter);
    
    // 保存 run rect
    [self getTextRectWithFrame:frameRef];
}

- (CGRect)getTextRectWithFrame:(CTFrameRef)frame
{
    // 获取每行
    CFArrayRef lines = CTFrameGetLines(frame);
    CGPoint lineOrigins[CFArrayGetCount(lines)];
    CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), lineOrigins);
    CGFloat viewWidth = self.testLineLabel.frame.size.width;
    
    NSInteger numberOfLines = self.testLineLabel.numberOfLines > 0 ? MIN(self.testLineLabel.numberOfLines, CFArrayGetCount(lines)) : CFArrayGetCount(lines);;
    CGRect runRect = CGRectZero;
    // 获取每行有多少run
    for (int i = 0; i < numberOfLines; i++) {
        CTLineRef line = CFArrayGetValueAtIndex(lines, i);
        CGFloat lineAscent;
        CGFloat lineDescent;
        CGFloat lineLeading;
        CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, &lineLeading);
        
        CFArrayRef runs = CTLineGetGlyphRuns(line);
        // 获得每行的 run
        for (int j = 0; j < CFArrayGetCount(runs); j++) {
            CGFloat runAscent;
            CGFloat runDescent;
            CGPoint lineOrigin = lineOrigins[i];
            CTRunRef run = CFArrayGetValueAtIndex(runs, j);
            // 获取 value
            NSDictionary* attributes = (NSDictionary*)CTRunGetAttributes(run);
            id linkStr = [attributes objectForKey:@"testAttributedKey"];
            
            // 开始获取  runRect
            CGFloat runWidth  = CTRunGetTypographicBounds(run, CFRangeMake(0,0), &runAscent, &runDescent, NULL);
            
            if (viewWidth > 0 && runWidth > viewWidth) {
                runWidth  = viewWidth;
            }
            runRect = CGRectMake(lineOrigin.x + CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL), lineOrigin.y - runDescent, runWidth, runAscent + runDescent);

            // 设置 此处符合某个自己设置的规则,则保存相应的值
            if ([(NSString *)linkStr hasPrefix:@"http"]) {
                [self.testDic setObject:linkStr forKey:[NSValue valueWithCGRect:runRect]];
            }
        }
        
    }
    return runRect;
}

通过上述思路可以了解到 全部文字的时候,可以很好的获取到每一个 subAttributedStringCGRect, 当然如果涉及到 图片这块的,还会涉及到 CTRunDelegateCallbacks:

typedef struct
{
    CFIndex                         version;
    CTRunDelegateDeallocateCallback dealloc; 
    CTRunDelegateGetAscentCallback  getAscent;  
    CTRunDelegateGetDescentCallback getDescent; 
    CTRunDelegateGetWidthCallback   getWidth; 
} CTRunDelegateCallbacks;

这个是为图片设置CTRunDelegate,delegate 决定留给图片的空间大小 ,这块先粗略的了解下。

** 对于此处笔记先重点还是:** 如何 获取到
NSMutableAttributedString 每一个子NSMutableAttributedStringCGRect 才是重点。

通过对上述三个问题的自我反问,大致有点理解了。但其中对于CoreText 某些的知识点,还是不熟悉的:

  • CTFramesetterRef
  • CTFrameRef
  • CFArrayRef
  • CTRunDelegateCallbacks

所以下次的学习笔记决定在这块让自己有新的认识,这次笔记就先到这里,如果上述的记录有问题或疑惑,欢迎告知。

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

推荐阅读更多精彩内容