基于YYKit的富文本自标定体系

01

什么是自标定体系,自标定体系是干什么用的

如上图所示,业务场景中存在评论、图文标题等富文本的编辑场景,一条文本中可能存在@的人、话题、链接等不同类型的内容。编辑结束如何告知下游服务文本中哪部分是特殊类型的内容,这些内容又分别是什么,就成了一个很棘手的问题。因为文本中插入特殊内容后文本还能继续编辑,如果在每次发生改动时更新记录的信息,几乎是无法实现的。如果编辑后采用正则匹配、文本匹配等方式识别,又会存在无法区分手输文本和插入的特殊内容的问题。

所以,如果富文本中的每个特殊内容上都标记了这段是什么内容,那不管怎么编辑,只要从头到尾扫描一次标记就可以获取到所有特殊内容的范围、类型、对应数据,如下图所示:

这就是自标定体系,用于富文本类型内容的生产编辑。

02

自标定体系是如何实现的

@interface NSMutableAttributedString (NSExtendedMutableAttributedString)

@property (readonly, retain) NSMutableString *mutableString;

- (void)addAttribute:(NSAttributedStringKey)name value:(id)value range:(NSRange)range;
- (void)addAttributes:(NSDictionary<NSAttributedStringKey, id> *)attrs range:(NSRange)range;
- (void)removeAttribute:(NSAttributedStringKey)name range:(NSRange)range;

- (void)replaceCharactersInRange:(NSRange)range withAttributedString:(NSAttributedString *)attrString;
- (void)insertAttributedString:(NSAttributedString *)attrString atIndex:(NSUInteger)loc;
- (void)appendAttributedString:(NSAttributedString *)attrString;
- (void)deleteCharactersInRange:(NSRange)range;
- (void)setAttributedString:(NSAttributedString *)attrString;

- (void)beginEditing;
- (void)endEditing;

@end

iOS系统中富文本对应的 API NSMutableAttributedString,可以通过 - (void)addAttributes:(NSDictionary<NSAttributedStringKey, id> *)attrs range:(NSRange)range 方法给 range 标定的字符绑定字典,系统通过这种方式实现富文本中的行间距、高亮、附件等功能:

// Predefined character attributes for text. If the key is not present in the dictionary, it indicates the default value described below.
UIKIT_EXTERN NSAttributedStringKey const NSFontAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));                // UIFont, default Helvetica(Neue) 12
UIKIT_EXTERN NSAttributedStringKey const NSParagraphStyleAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));      // NSParagraphStyle, default defaultParagraphStyle
UIKIT_EXTERN NSAttributedStringKey const NSForegroundColorAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));     // UIColor, default blackColor
UIKIT_EXTERN NSAttributedStringKey const NSBackgroundColorAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));     // UIColor, default nil: no background
UIKIT_EXTERN NSAttributedStringKey const NSLigatureAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));            // NSNumber containing integer, default 1: default ligatures, 0: no ligatures
UIKIT_EXTERN NSAttributedStringKey const NSKernAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));                // NSNumber containing floating point value, in points; amount to modify default kerning. 0 means kerning is disabled.
UIKIT_EXTERN NSAttributedStringKey const NSTrackingAttributeName API_AVAILABLE(macos(11.0), ios(14.0), watchos(7.0), tvos(14.0), visionos(1.0));          // NSNumber containing floating point value, in points; amount to modify default tracking. 0 means tracking is disabled.
UIKIT_EXTERN NSAttributedStringKey const NSStrikethroughStyleAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));  // NSNumber containing integer, default 0: no strikethrough
UIKIT_EXTERN NSAttributedStringKey const NSUnderlineStyleAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));      // NSNumber containing integer, default 0: no underline
UIKIT_EXTERN NSAttributedStringKey const NSStrokeColorAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));         // UIColor, default nil: same as foreground color
UIKIT_EXTERN NSAttributedStringKey const NSStrokeWidthAttributeName API_AVAILABLE(macos(10.0), ios(6.0), tvos(9.0), watchos(2.0), visionos(1.0));         // NSNumber containing floating point value, in percent of font point size, default 0: no stroke; positive for stroke alone, negative for stroke and fill (a typical value for outlined text would be 3.0)
...

实际上 NSAttributedStringKey 就是 NSString 的别名,

typedef NSString * NSAttributedStringKey NS_TYPED_EXTENSIBLE_ENUM;

也就是说,NSMutableAttributedString 自带一个跟文本绑定的 NSString 为 key 的存储空间,那我们只需要自定义一个 NSString 的 Key 用来存取标记数据就可以了。

YYKit 中声明了一个 YYTextBackedStringAttributeName,

NSString *const YYTextBackedStringAttributeName = @"YYTextBackedString";

用来存取 YYTextBackedString:

/**
 YYTextBackedString objects are used by the NSAttributedString class cluster
 as the values for text backed string attributes (stored in the attributed 
 string under the key named YYTextBackedStringAttributeName).

 It may used for copy/paste plain text from attributed string.
 Example: If :) is replace by a custom emoji (such as😊), the backed string can be set to @":)".
 */
@interface YYTextBackedString : NSObject <NSCoding, NSCopying>
+ (instancetype)stringWithString:(nullable NSString *)string;
@property (nullable, nonatomic, copy) NSString *string; ///< backed string
@end

YYTextBackedString 乍一看没有什么用,只能存字符,但是我们可以通过子类扩展属性,增加可存储的数据类型,同时子类的类名就代表了内容的类型:

//@的人
@interface SVAtExtraTextBackedString : YYTextBackedString
@property (nonatomic, strong) AtExtraInfoModel *atExtraInfo;
@end
  
//Seek时间点
@interface SVCommentSeekTextBackedString : YYTextBackedString
@property (nonatomic, strong) CommentSeekInfoModel *seekInfo;
@end

//链接
@interface SVCommentLinkTextBackedString : YYTextBackedString
@property (nonatomic, strong) CommentLinkInfoModel *linkInfo;
@end

03

标定内容如何解析

以@的人为例,用 YYTextBackedStringAttributeName 从前向后遍历,如果是 SVAtExtraTextBackedString 类型就是@的人的数据,相应的range就是“@xxx”文本所在的区域:

- (NSArray<AtExtraInfoModel *> *)parseAtExtraInfoModels {
    NSMutableArray *models = [NSMutableArray array];
    [self enumerateAttribute:YYTextBackedStringAttributeName inRange:self.yy_rangeOfAll options:kNilOptions usingBlock:^(id  _Nullable value, NSRange range, BOOL * _Nonnull stop) {
        SVAtExtraTextBackedString *backedString = [value as:SVAtExtraTextBackedString.class];
        if (backedString && backedString.atExtraInfo) {
            NSInteger location = [self yy_plainTextForRange:NSMakeRange(0, range.location)].length;
            backedString.atExtraInfo.range = NSMakeRange(location, range.length);
            [models addObject:backedString.atExtraInfo];
        }
    }];
    return [models copy];
}

04

动态识别与自标定混用

业务中,图文标题的生产场景,话题类的文本不是点击添加产生的,而是手输自动高亮,通过正则识别:

#[^#\s]* //未完成话题
#[^#\s]+\s{1} //完成话题

这样 “#时间没有绝对@小狐狸210757605” 整段都会被识别为话题,@的信息就和话题冲突了,可以通过查询正则识别区域内是否有标记信息来解决这个问题。以 “图文标题#时间没有绝对@小狐狸210757605” 为例,(location,length)[4,20] 范围内为 “#时间没有绝对@小狐狸210757605” ,查询 (location,length)[4,20] 范围内的 SVAtExtraTextBackedString 为 (location,length)[11,13],所以话题的实际范围应该到@的人前面为 (location,length)[4,7]:

@objc func finishedTopicMatchResults(inRange range: NSRange) -> [NSTextCheckingResult] {
        guardlet currentText = self.attributedText, currentText.length > 0else { return [NSTextCheckingResult]() }
        guard currentText.yy_rangeOfAll().contains(range.lowerBound) && currentText.yy_rangeOfAll().contains(range.upperBound-1) else {
            return [NSTextCheckingResult]()
        }
        do {
            let pattern = "#[^#\\s]+\\s{1}"
            let regex = tryNSRegularExpression(pattern: pattern)
            let matches = regex.matches(in: currentText.string, options: [], range: range)
            var finalMatches = [NSTextCheckingResult]()
            if matches.count > 0 {
                let attributeKey = NSAttributedString.Key(rawValue: YYTextBackedStringAttributeName)
                for matchResult in matches {
                    var currentRange = matchResult.range
                    var matchCouldUse = true
                    currentText.enumerateAttribute(attributeKey, in: currentRange, options: []) { value, range, stop in
                        iflet_ = value as? SVAtExtraTextBackedString {
                            if currentRange.contains(range.location) {
                                currentRange = NSRange(location: currentRange.location, length: range.location - currentRange.location)
                                if currentText.yy_rangeOfAll().contains(currentRange.lowerBound) && currentText.yy_rangeOfAll().contains(currentRange.upperBound-1) {
                                    let topicStr = currentText.attributedSubstring(from: currentRange).string
                                    if topicStr.contains(" ") == false {
                                        matchCouldUse = false
                                    }
                                }
                                stop.pointee = true
                            }
                        }
                    }
                    if matchCouldUse {
                        finalMatches.append(matchResult)
                    }
                }
            }
            return finalMatches
        } catch {
            return [NSTextCheckingResult]()
        }
    }


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值