iOS

AttributeTextParser

Easily build NSAttributedString from XML/HTML like strings.

Posted by Japho on June 26, 2023

前言

iOS 开发过程中,经常需要实现一些较为复杂的文本展示,一般需要通过富文本进行处理。iOS 平台下,富文本 API 调用较为繁琐,为了解决这一问题,AttributeTextParser 提供了一套副文本解析方案。

AttributeTextParser 无需关注复杂繁琐的 NSAttributeString 的 API,只需将需要展示的内容拼接为类 HTML 标签,传入即可进行解析。通过类 HTML 标签对需要展示的富文本进行处理,提升代码阅读性、开发效率。

目前 AttributeTextParser 支持已解析类型:字体颜色图片(静态图片、Gif、序列帧图片)超链

效果展示

语法

  • 每个标记需要包含在<>之间,类似<font><img>
  • 标记中参数格式为 xxx="xxx"

字体 <font>

<font size=\"16.\" color=\"0,1,0,1.\">

font 标签以<font>为标记,包含以下属性

size:字号(非必须)

color:颜色(非必须),以“,”区分,分别为R、G、B、A,可使用

+ (NSString *)colorStringWithHexString:(NSString *)color

生成字符串颜色值。该标记之后,到下个标记之前的所有内容中的颜色、字号,均已该标记为准。

示例

<font size=\"16.\" color=\"0,0,0,1.\">字号16,黑色\n<font size=\"24.\" color=\"1,0,0,1.\">现在为红色,字号24

图片 <img>

<img width=\"40\" height=\"40\" src=\"test.png\"> // 静态图片
<img width=\"40\" height=\"40\" descent=\"0\" src=\"pia\" type=\"1\"> // Gif
<img width=\"40\" height=\"40\" descent=\"0\" src=\"1.png,2.png,3.png\" type=\"2\" fps=\"10\"> // 序列帧

img 标签以<img>为标记,包含以下属性

width:图片宽度(必须❗️)

height:图片宽度(必须❗️)

scr:图片地址(必须❗️),可以为本地文件路径、项目本地图片、以“,”分割的序列帧图片

descent:图片相对于文字行的间距(非必须),默认为0,descent > 0,图片向下偏移,descent < 0,图片向上便宜,具体偏移量视数值而定

type:传递图片的类型(非必须),当图片不是静态图片时,需要传 type 类型。0:静态图片,1:Gif,2:序列帧

fps:序列帧图片的fps(非必须),当图片为序列帧时,默认 fps 为6,如果需要修改,可修改此参数

示例

NSString *text1 = [NSString stringWithFormat:@"<font size=\"16.\" color=\"0,0,0,1.\">字号16,黑色\n
<font size=\"24.\" color=\"1,0,0,1.\">现在为红色,字号24\n\nPng   
<img width=\"200\" height=\"133\" src=\"1.jpeg\">\n\nGif     
<img width=\"250\" height=\"133\" descent=\"0\" src=\"2.gif\" type=\"1\">\n\n序列帧
<img width=\"142\" height=\"34\" descent=\"0\" src=\"%@\" type=\"2\" fps=\"20\">", filePaths];

<link length=\"6\" href=\"www.6.cn\" color=\"0.819608,0.905882,0.713726,1.000000\">链接点击事件

link 标签以<link>为标记,包含以下属性

length:链接有效长度(必须❗️),<img>标签占用1个长度,自该标签后,length 个长度内均响应点击事件,点击事件由代理进行回调。

href:点击事件代理返回超链内容(必须❗️),设置点击事件后,点击该区域,代理会返回该 href 用于逻辑处理。

color:颜色(非必须),以“,”区分,分别为R、G、B、A,可使用

+ (NSString *)colorStringWithHexString:(NSString *)color

生成字符串颜色值。该标记之后,到下个<font><link>标记之前的所有内容中的颜色、字号,均已该标记为准。

示例

<link length=\"6\" href=\"www.6.cn\" color=\"0.819608,0.905882,0.713726,1.000000\">链接点击事件此处非连接点击事件

AttributeTextParser

核心方法

attributeStringFromHtmlString: attributes:

/// 传入格式化标签,返回适用于 AttributeLabel 的 NSAttributedString
/// @param htmlString 格式化标签
/// @param attributes 附加属性
- (NSAttributedString *)attributeStringFromHtmlString:(NSString *)htmlString
                                           attributes:(nullable NSDictionary<AttributedStringKey, id> *)attributes;

该方法将传入的类 HTML 标签转换为NSAttributedString,attributes 参数为附件属性,该附加属性将作用于整个富文本字符串中,目前支持字体名称、行间距、断行模式。

typedef NSString *AttributedStringKey NS_EXTENSIBLE_STRING_ENUM;

OBJC_EXTERN AttributedStringKey const FontAttributedName;
OBJC_EXTERN AttributedStringKey const LineSpacingAttributedName;
OBJC_EXTERN AttributedStringKey const LineBreakModeAttributedName;

getAttributeSizeWithContainerSize: attributeString:

/// 获取富文本尺寸
/// @param containerSize 容器尺寸
/// @param attributeString 富文本字符串
- (CGSize)getAttributeSizeWithContainerSize:(CGSize)containerSize attributeString:(NSAttributedString *)attributeString;

该方法会返回富文本在 containerSize 下所需占用的尺寸。

点击事件回调

当使用<link>处理点击事件时,持有解析器对象的类需遵循以下方法:

@protocol AttributeTextParserDelegate <NSObject>

@optional

/// 超链接点击回调
/// @param textParser textParser
/// @param href 超链接
- (void)textParser:(AttributeTextParser *)textParser linkTouchedWithHref:(NSString *)href;

@end

该方法会将<link>标签中的 href 作为参数回调到代理中,以供业务逻辑代码进行处理。

使用示例

- (void)viewDidLoad {
    AttributeTextParser *parser = [[AttributeTextParser alloc] init];
    parser.delegate = self;
    
    NSString *htmlString = @"<font size=\"16.\" color=\"0,0,0,1.\">字号16,黑色";
    NSAttributedString *attibuteStr = [parser attributeStringFromHtmlString:htmlString attributes:@{
        FontAttributedName : @"PingFangSC-Semibold",
        LineSpacingAttributedName : @(20),
        LineBreakModeAttributedName : @(NSLineBreakByTruncatingTail)
    }];
    
    AttributedLabel *label = [[AttributedLabel alloc] init];
    label.attributedText = attibuteStr;
    [self.view addSubview:label];
}

#pragma mark - AttributeTextParserDelegate
- (void)textParser:(AttributeTextParser *)textParser linkTouchedWithHref:(NSString *)href {
    NSLog(@"Href : %@", href);
}

  • 初始化 AttributeTextParser,如果需要点击事件响应,则需添加代理。
AttributeTextParser *parser = [[AttributeTextParser alloc] init];
parser.delegate = self;
  • 添加代理回调
#pragma mark - AttributeTextParserDelegate
- (void)textParser:(AttributeTextParser *)textParser linkTouchedWithHref:(NSString *)href {
    NSLog(@"Href : %@", href);
}
  • 将拼接好的类 HTML 标签生成 NSAttributedString
NSAttributedString *attibuteStr = [parser attributeStringFromHtmlString:htmlString attributes:@{
	FontAttributedName : @"PingFangSC-Semibold",
	LineSpacingAttributedName : @(20),
	LineBreakModeAttributedName : @(NSLineBreakByTruncatingTail)
}];

这里可以添加附属属性 attributes,以字典的形式传递,分别为:

FontAttributedName:字体名称

LineSpacingAttributedName:行间距

LineBreakModeAttributedName:断行模式

  • 调用 AttributedLabel 或 AttributedTextView 进行展示
AttributedLabel *label = [[AttributedLabel alloc] init];
label.attributedText = attibuteStr;
[self.view addSubview:label];

源码解析


- (NSAttributedString *)attributeStringFromHtmlString:(NSString *)htmlString attributes:(NSDictionary *)attributes {
    NSMutableAttributedString *finalString = [[NSMutableAttributedString alloc] initWithString:htmlString];
    // 通过正则将类 HTML 标签拆分
    NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"<[^>]+>" options:NSRegularExpressionCaseInsensitive error:nil];
    NSArray *resultArray = [regex matchesInString:htmlString options:0 range:NSMakeRange(0, htmlString.length)];
    for (NSTextCheckingResult *result in resultArray) {
        // 获取每个 <> 标签,e.g.:<font size="14.000000">
        NSString *htmlAttributedString = [htmlString substringWithRange:result.range];
        // 去除 <>,font size="14.000000"
        NSString *htmlAttributedContent = [htmlAttributedString substringWithRange:NSMakeRange(1, htmlAttributedString.length - 2)];
        
        // font
        // 获取每个 <font> 标签中字体属性
        NSDictionary *fontInfo = [self fontInfoWithString:htmlAttributedContent attributes:attributes];
        if (fontInfo != nil) {
            // fontInfo 存在,则表示本次 forin 循环遍历为 <font> 标签,定位标签位置
            NSRange fontInfoRange = [finalString.string rangeOfString:htmlAttributedString options:NSLiteralSearch];
            // 移除标记语言 <font>
            [finalString replaceCharactersInRange:fontInfoRange withString:@""];
            // 字体设置范围为自此 <font> 标签后的所有字符串
            NSRange replaceRange = NSMakeRange(fontInfoRange.location, finalString.length - fontInfoRange.location);
            // 设置范围内字体
            if ([[fontInfo allKeys] containsObject:NSFontAttributeName]) {
                UIFont *font = [fontInfo objectForKey:NSFontAttributeName];
                [finalString yy_setFont:font range:replaceRange];
            }
            // 设置范围内颜色
            if ([[fontInfo allKeys] containsObject:NSForegroundColorAttributeName]) {
                UIColor *color = [fontInfo objectForKey:NSForegroundColorAttributeName];
                [finalString yy_setColor:color range:replaceRange];
            }
        }
        
        // image
        // 获取每个 <img> 标签中属性
        NSDictionary *imageInfo = [self imageInfoWithString:htmlAttributedContent attributes:attributes];
        if (imageInfo != nil) {
            // imageInfo 存在,则表示本次 forin 循环遍历为 <img> 标签
            CGFloat width = [[imageInfo objectForKey:@"width"] floatValue];
            CGFloat height = [[imageInfo objectForKey:@"height"] floatValue];
            CGFloat descent = [[imageInfo objectForKey:@"descent"] floatValue]; // 向下偏移量
            NSString *fileName = [imageInfo objectForKey:@"fileName"];
            CGFloat fps = [[imageInfo objectForKey:@"fps"] floatValue];
            CGFloat duration = [[imageInfo objectForKey:@"duration"] floatValue];
            EnumAttributeTextImageType imageType = [[imageInfo objectForKey:@"type"] integerValue];
            
            // 生成图片 attachment
            id attrachmentContent;
            switch (imageType) {
                case EnumAttributeTextImageTypeDefault: { // 静态图片
                    YYImage *image = [self imageWithFilePath:fileName];
                    attrachmentContent = image;
                    break;
                }
                case EnumAttributeTextImageTypeGif: { // 动图 Gif
                    YYImage *image = [YYImage imageNamed:fileName];
                    image.preloadAllAnimatedImageFrames = YES;
                    attrachmentContent = image;
                    break;
                }
                case EnumAttributeTextImageTypeFrame: { // 序列帧
                    NSArray *imageStringArray = [fileName componentsSeparatedByString:@","];
                    // 默认 fps 为 6
                    NSTimeInterval defaultDuration = 1.f / 6.f; // defaultDuration 为每帧时间
                    if (duration != 0 && imageStringArray.count != 0) {
                        defaultDuration = duration / imageStringArray.count; // 动画总时间 / 动画总帧数 = 每帧所用时间
                    }
                    if (fps != 0) {
                        defaultDuration = 1.f / fps;
                    }
                    YYFrameImage *frameImage = [[YYFrameImage alloc] initWithImagePaths:imageStringArray
                                                                       oneFrameDuration:defaultDuration
                                                                              loopCount:0];
                    attrachmentContent = frameImage;
                    break;
                }
            }
            
            NSMutableAttributedString *attachString = [NSMutableAttributedString v6cn_attachmentStringWithImage:attrachmentContent
                                                                                                         ascent:height - descent
                                                                                                        descent:descent
                                                                                                           size:CGSizeMake(width, height)];
            if (attachString) {
                // attachString 存在,替换 <img> 标签
                NSRange imageInfoRange = [finalString.string rangeOfString:htmlAttributedString options:NSLiteralSearch];
                [finalString replaceCharactersInRange:imageInfoRange withAttributedString:attachString];
            }
        }
    }
    
    NSMutableDictionary *linkHrefDictionaryM = [NSMutableDictionary dictionary];
    // 处理完除 link 的其他标签之后,再去处理 link
    for (NSTextCheckingResult *result in resultArray) {
        // 获取每个 <> 标签,e.g.: <link length="4" href="www.6.cn" color="0.819608,0.905882,0.713726,1.000000">
        NSString *htmlAttributedString = [htmlString substringWithRange:result.range];
        // 去除 <>,link length="4" href="www.6.cn" color="0.819608,0.905882,0.713726,1.000000"
        NSString *htmlAttributedContent = [htmlAttributedString substringWithRange:NSMakeRange(1, htmlAttributedString.length - 2)];
        
        //link
        // 获取每个 <link> 标签中属性
        NSDictionary *linkInfoDictionary = [self linkInfoWithString:htmlAttributedContent attributes:attributes];
        if (linkInfoDictionary != nil) {
            NSInteger linkLength = [[linkInfoDictionary objectForKey:@"length"] integerValue];
            NSString *href = [linkInfoDictionary objectForKey:@"href"];
            NSDictionary *linkAttributes = [linkInfoDictionary objectForKey:@"attributes"];
            
            // link 标记符 range,<link length="4" href="www.6.cn" color="0.819608,0.905882,0.713726,1.000000">,<>
            NSRange linkInfoRange = [finalString.string rangeOfString:htmlAttributedString options:NSLiteralSearch];
            
            // 防止 location 越界
            if (linkInfoRange.location > finalString.length - 1) {
                linkInfoRange.location = finalString.length - 1;
            }
            
            // 删除 <link> 标签
            [finalString replaceCharactersInRange:linkInfoRange withString:@""];
            
            if (finalString.length < linkInfoRange.location + linkLength) {
                linkLength = finalString.length - linkInfoRange.location;
            }
            
            if (linkLength < 0) {
                linkLength = 0;
            }
            
            // 超链range,超链之后开始计算,受 length 控制,图片长度计算为 1
            NSRange linkRange = NSMakeRange(linkInfoRange.location, linkLength);
            
            // 用于储存 href,以 range 为 key
            if (href && href.length) {
                [linkHrefDictionaryM setValue:href forKey:NSStringFromRange(linkRange)];
            }
            
            UIColor *highlightColor;
            if (linkAttributes && linkAttributes.allKeys.count) {
                if ([[linkAttributes allKeys] containsObject:NSForegroundColorAttributeName]) {
                    highlightColor = [linkAttributes objectForKey:NSForegroundColorAttributeName];
                }
            }
            
            [finalString yy_setTextHighlightRange:linkRange
                                            color:highlightColor
                                  backgroundColor:[UIColor clearColor]
                                        tapAction:^(UIView * _Nonnull containerView, NSAttributedString * _Nonnull text, NSRange range, CGRect rect) {
                NSArray *linkHrefKeysArray = [linkHrefDictionaryM allKeys];
                for (NSString *tempKey in linkHrefKeysArray) {
                    if ([tempKey isEqualToString:NSStringFromRange(range)]) {
                        NSString *href = [linkHrefDictionaryM objectForKey:tempKey];
                        if (self.delegate && [self.delegate respondsToSelector:@selector(textParser:linkTouchedWithHref:)]) {
                            [self.delegate textParser:self linkTouchedWithHref:href];
                        }
                    }
                }
            }];
        }
    }
    
    // 去除其他 HTML 标签
    NSArray *arrHtmlTagResult = [regex matchesInString:finalString.string options:0 range:NSMakeRange(0, finalString.length)];
    for (NSInteger i = arrHtmlTagResult.count - 1; i >= 0; i--) {
        NSTextCheckingResult *result = arrHtmlTagResult[i];
        [finalString replaceCharactersInRange:result.range withString:@""];
    }
    
    // 设置追加属性
    CGFloat lineSpacing = [[attributes objectForKey:LineSpacingAttributedName] floatValue];
    NSLineBreakMode lineBreakMode = [[attributes objectForKey:LineBreakModeAttributedName] integerValue];
    
    finalString.yy_lineSpacing = lineSpacing;
    finalString.yy_lineBreakMode = lineBreakMode;
    finalString.yy_baseWritingDirection = NSWritingDirectionLeftToRight;
    finalString.yy_writingDirection = @[@(NSWritingDirectionLeftToRight | NSWritingDirectionOverride)];
    
    return finalString;
}

标签拓展

当现有标签不满足需求时,可添加拓展标签,类似

<b>加粗

<i>斜体

<badge id=56>徽章视图

<fans name="张三">粉丝团徽章

<view id=0xdddr3333>插入UIView

只需在attributeStringFromHtmlString: attributes:中继续拓展其他类型即可实现。

关于第三方依赖

目前项目中 AttributeTextParser 集成了 YYKit,通过 YYKit 来实现富文本相关内容,但 AttributeTextParser 不与富文本第三方强耦合,可以使用 NSAttributeString 原生 API 进行实现,也可使用其他富文本第三方进行实现;

关于原生方法实现思路

  • 通过 addAttributes: range: 方法可实现类似字体 NSFontAttributeName、颜色 NSForegroundColorAttributeName、超链 NSLinkAttributeName 等功能;
  • 通过 NSTextAttachment 添加图片,虽然 NSTextAttachment 默认不支持动图,但是可以通过复写NSTextAttachmentContainer类中imageForBounds: textContainer: characterIndex:方法进行实现
  • 关于点击事件,可以为 UITextView 添加 tap 事件,可通过下述方法获取 NSLinkAttributeName 的值,通过 UITextView 回调给外部,进行事件的传递。
UITextPosition *position = [self.textView closestPositionToPoint:[tap locationInView:self.textView]];
NSDictionary<NSAttributedStringKey, id> *textStylings = [self.textView textStylingAtPosition:position inDirection:UITextStorageDirectionForward];
NSString *url = [textStylings objectForKey:NSLinkAttributeName];

One More Thing

在现有架构下,房间公聊解析可进一步容器化,去除本地 socket 解析逻辑,将业务逻辑交给前端、服务端解析,返回一段类 HTML 标签,端上公聊展示,对于单一符文本样式(无定制UI需求),只做展示解析处理,不再处理业务。