iOS 可点击文本实现方案

需求:实现label部分文字点击,如下图

WeChatd75f411816a3283acba36fb3138bb914.png

要求《业务委托书》《个人信息采集及征信查询授权书》两部分可以点击,其他不能点击。

最容易实现的是UITextView,UITextView有三种实现方法。

1.使用属性字符串

代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    NSString *str = @"本人确认阅读并同意签署《业务委托书》及《个人信息采集及征信查询授权书》";
    NSMutableAttributedString *attribute = [[NSMutableAttributedString alloc] initWithString:str];
    [attribute addAttribute:NSLinkAttributeName value:@"labelAction://type1" range:[str rangeOfString:@"《业务委托书》"]];
    [attribute addAttribute:NSLinkAttributeName value:@"labelAction://type2" range:[str rangeOfString:@"《个人信息采集及征信查询授权书》"]];
    self.textView.attributedText = attribute;
    self.textView.delegate = self;
    self.textView.linkTextAttributes = @{NSForegroundColorAttributeName:[UIColor colorWithRed:1 green:0 blue:0 alpha:1],NSFontAttributeName:[UIFont systemFontOfSize:30]};
    self.textView.editable = NO;
}
- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction {
    NSLog(@"%@",URL);
    return YES;
}

效果图:


WeChat5935df9f8d91d48380e6bbab23237c98.png

点击效果:

2019-09-20 17:11:45.895663+0800 文本文字点击[13700:5109072] labelaction://type1

代理方法iOS10几以后才可以使用,解决思路是自定义URL Types,将它作为自定义链接,可以在AppDelegate中的openURL:方法中截获。

这种方式有几个问题:

  • 无法给textView设置属性(尝试基本的font和attributeFont都没有效果,如果你知道怎么解决,欢迎在下方留言)
  • 长按链接会弹出系统的actionSheet(尝试很多手段,无法禁用)

2.使用HTML链接

代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    NSString *html = @"本人确认阅读并同意签署《业务委托书》《个人信息采集及征信查询授权书》";
    NSData *htmlData = [html dataUsingEncoding:NSUnicodeStringEncoding];
    NSAttributedString *attribute = [[NSAttributedString alloc] initWithData:htmlData options:@{NSDocumentTypeDocumentAttribute : NSHTMLTextDocumentType} documentAttributes:NULL error:nil];
    self.textView.attributedText = attribute;
    self.textView.delegate = self;
    self.textView.linkTextAttributes = @{NSForegroundColorAttributeName:[UIColor colorWithRed:1 green:0 blue:0 alpha:1]};
    self.textView.editable = NO;
}

- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction {
    NSLog(@"%@",URL);
    return YES;
}

效果图:


WeChat9ca083213425b6c0488ec6f7c4aea775.png

点击效果:

2019-09-20 17:13:45.895663+0800 文本文字点击[13700:5109072] labelaction://type1

和第一种一样,依靠自定义链接或代理方法截获。

这种方式有几个问题:

  • 富文本属性得设置在html中;
  • 长按链接会弹出系统的actionSheet。

3.使用系统API来获取指定文本的rect,当触摸事件发生时,判断点击区域是否和文本的区域重叠

代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    NSString *str = @"本人确认阅读并同意签署《业务委托书》及《个人信息采集及征信查询授权书》";
    self.textView.text = str;
    
    NSRange range = [str rangeOfString:@"《业务委托书》"];
    self.textView.selectedRange = range;
    UITextRange *textRange = self.textView.selectedTextRange;
    NSArray *rects = [self.textView selectionRectsForRange:textRange];
    for (UITextSelectionRect *selectionRect in rects) {
        NSLog(@"%@",NSStringFromCGRect(selectionRect.rect));
        UIView *view = [[UIView alloc] initWithFrame:selectionRect.rect];
        view.backgroundColor = [UIColor colorWithRed:1 green:0 blue:0 alpha:0.3];
        [self.textView insertSubview:view atIndex:0];
    }
}

效果图:


WeChat94116b22e21dccc2896ab8410d43b17f.png

在该方法中,只做了匹配第一个字符串,第二个同理。

此时,可以根据touchBegan方法的点来判断是否被包含在匹配的rect中,从而回调相应事件。

实际上,使用UILabel也可以实现(须借助coreText框架)

使用UILabel,和UITextView的第三种思路一样,获取点击字符串的rect,判断点击范围是否在rect中。
代码如下:

#import "UILabel+JHTapLabel.h"
#import 

@interface UILabel ()
@property (nonatomic, strong) NSMutableArray *ranges;
@property (nonatomic, weak) id target;
@end

@implementation UILabel (JHTapLabel)

- (void)setRanges:(NSMutableArray *)ranges {
    objc_setAssociatedObject(self, @selector(ranges), ranges, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSMutableArray *)ranges {
    return objc_getAssociatedObject(self, @selector(ranges));
}

- (void)setTarget:(id)target {
    objc_setAssociatedObject(self, @selector(target), target, OBJC_ASSOCIATION_ASSIGN);
}

- (id)target {
    return objc_getAssociatedObject(self, @selector(target));
}


- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {

}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView:self];
    for (NSDictionary *info in self.ranges) {
        CGRect rect = [info[@"rect"] CGRectValue];
        if (CGRectContainsPoint(rect, point)) {
            SEL sel = NSSelectorFromString(info[@"sel"]);
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
            [self.target performSelector:sel];
#pragma clang diagnostic pop
        }
    }
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
    
}

- (void)addTarget:(id)target selector:(SEL)sel range:(NSRange)range {
    self.target = target;
    if (!self.ranges) {
        self.ranges = [NSMutableArray array];
    }
    self.userInteractionEnabled = YES;
    NSArray *lineRanges = [self lines];
    NSRange targetRange = range;
    for (int i = 0; i < lineRanges.count; i++) {
        NSRange lineRange = [lineRanges[i] rangeValue];
        NSRange intersectionRange = NSIntersectionRange(targetRange, lineRange);
        // 两个range有相交
        if (intersectionRange.length != 0) {
            // 如果targetRange的范围超出了lineRange
            if (NSMaxRange(targetRange) > NSMaxRange(lineRange)) {
                [self addTarget:target selector:sel range:intersectionRange];
                [self addTarget:target selector:sel range:NSMakeRange(NSMaxRange(intersectionRange), targetRange.length - intersectionRange.length)];
            }else {
                CGRect rangeRect = [self boundingRectForCharacterRange:range];
                [self.ranges addObject:@{@"sel":NSStringFromSelector(sel),
                                         @"rect":[NSValue valueWithCGRect:rangeRect]
                                         }];
            }
            /*
             一旦有相交,则相交的range如果是多行,会被拆分成多个range。
             原始的range就不再使用了,这里直接跳出循环*/
            break;
        }
    }
}

- (CGRect)boundingRectForCharacterRange:(NSRange)range
{
    NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:[self attributedText]];
    [textStorage addAttributes:@{NSFontAttributeName:self.font} range:NSMakeRange(0, textStorage.string.length)];
    NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
    [textStorage addLayoutManager:layoutManager];
    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(CGRectGetWidth(self.frame), CGFLOAT_MAX)];
    textContainer.lineFragmentPadding = 0;
    textContainer.lineBreakMode = self.lineBreakMode;
    [layoutManager addTextContainer:textContainer];
    NSRange glyphRange;
    [layoutManager characterRangeForGlyphRange:range actualGlyphRange:&glyphRange];
    CGRect rect = [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer];
    return rect;
}

- (NSArray *)lines {
    NSMutableAttributedString *attStr = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText];
    
    [attStr addAttribute:NSFontAttributeName value:self.font range:NSMakeRange(0, attStr.length)];
    
    CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attStr);
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddRect(path, NULL, CGRectMake(0,0,CGRectGetWidth(self.frame), 100000));
    CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, NULL);
    NSArray *lines = (__bridge NSArray *)CTFrameGetLines(frame);
    NSMutableArray *linesArray = [[NSMutableArray alloc]init];
    for (id line in lines) {
        CTLineRef lineRef = (__bridge CTLineRef )line;
        CFRange lineRange = CTLineGetStringRange(lineRef);
        NSRange range = NSMakeRange(lineRange.location, lineRange.length);
        [linesArray addObject:[NSValue valueWithRange:range]];
    }
    CFRelease(frameSetter);
    CFRelease(path);
    CFRelease(frame);
    
    return linesArray;
}
@end

具体的思路:

  1. - (void)addTarget:(id)target selector:(SEL)sel range:(NSRange)range给指定range添加事件;
  2. - (CGRect)boundingRectForCharacterRange:(NSRange)range获取指定range的范围。这个方法在range是同一行时没有问题,但是如果链接刚好换行,形成多行,那么此时rect获取不正确;
  3. 为了解决第二步的问题,基本思路是判断一个range是否被换行。如果换行,那么将range按照行来截断,给每一段分别再次添加同一个事件;
  4. 使用coreText获取每一行文本的range;
  5. 根据点击范围来进行判断,数组ranges记录了每个range对应的sel。如果相交则调用[self.target performSelector:sel];

例子中一个UILabeltarget只能是同一个对象,你可以进行改造,使之适用于自己的业务逻辑。

2019-11-26补充:

如果使用最后一种label实现方案,此时需要注意:
如果外部可能给label设置了各种属性,比如对齐方式,文本截断方式等等,那么在- (NSArray *)lines方法和- (CGRect)boundingRectForCharacterRange:(NSRange)range方法中,分别给attStrtextStorage设置富文本格式时,一定要和外部label设置的匹配,否则可能导致这两个函数计算范围出现误差。

比如在- (NSArray *)lines方法中,给 attStr设置font,对齐方式等等:

NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
    style.lineBreakMode = self.lineBreakMode;
    style.alignment = self.textAlignment;
    [attStr addAttribute:NSFontAttributeName value:self.font range:NSMakeRange(0, attStr.length)];
    [attStr addAttribute:NSParagraphStyleAttributeName value:style range:NSMakeRange(0, attStr.length)];

- (CGRect)boundingRectForCharacterRange:(NSRange)range方法中同样要给textStorage设置:

NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
    style.lineBreakMode = self.lineBreakMode;
    style.alignment = self.textAlignment;
    [textStorage addAttributes:@{NSFontAttributeName:self.font,NSParagraphStyleAttributeName:style} range:NSMakeRange(0, textStorage.string.length)];

你可能感兴趣的:(iOS 可点击文本实现方案)