原生实现富文本展示踩坑指南
原生实现富文本展示踩坑指南
#knowledge/Blog
在过去的一年中维护的业务中,碰到了不少展示富文本的坑
- 在 ComponentKit 中使用 TTTAttributedLabel,以及遇到的文本高度计算的问题。
项目中引用了 Facebook 的 ComponentKit 来实现列表,又因为要实现链接样式的自定义和点击,就想用 TTT 来实现,这样我们就需要用 CK 将 TTT 包装一层:
CKComponent *c = [super newWithView:{ [TTTAttributedLabel class], { {@selector(setText:), text}, {@selector(setLinkAttributes:), @{}}, {@selector(setActiveLinkAttributes:), @{}}, {@selector(setNumberOfLines:), 0}, {@selector(setLineBreakMode:), NSLineBreakByTruncatingTail}, CKComponentTapGestureAttribute(@selector(didTapLink)), config, nil} } } size:{}];
因为是自己封装的,所以 CK 是不能够自己计算出这个组件的高度的,所以我们需要重写 computeLayoutThatFits 方法并返回这个组件正确的高度,鉴于这个组件是用来展示富文本的,所以计算组件高度的问题就转为成了在固定宽度下计算富文本高度的问题:
//计算高度并返回
- (CKComponentLayout)computeLayoutThatFits:(CKSizeRange)constrainedSize {
CGSize computerSize = [self sizeLabelToFitToSize:constrainedSize.max numberLines:self.numOfLine font:self.normalFont attributeString:self.attributeString];
return {
self,
constrainedSize.clamp({
CKCeilPixelValue(computerSize.width),
CKCeilPixelValue(computerSize.height)
}),
};
}
- (CGSize)sizeLabelToFitToSize:(CGSize)size numberLines:(NSInteger)numberLines font:(UIFont *)font {
UILabel *temLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, size.width, size.height)];
temLabel.text = self;
temLabel.font = font;
temLabel.numberOfLines = numberLines;
[temLabel sizeToFit];
CGSize attributeSize = temLabel.frame.size;
return CGSizeMake(ceil(attributeSize.width), ceil(attributeSize.height));
}
看起来一切都很完美,结果上线以后发现计算高度的地方有一定概率会崩溃,原因是在非主线程调用了 UI 相关的代码,的确如此,因为我们在计算高度的时候用到了 UILabel,而 CK 为了提高渲染效率,component 的生成和计算都是在非主线程完成的,所以导致了这个问题。
于是我改成了用 TextKit 的方式来计算:
- (CGSize)sizeLabelToFitToSize:(CGSize)size numberLines:(NSInteger)numberLines font:(UIFont *)font attributeString:(NSAttributedString *)attributeString {
// 替换成 TextKit 计算, TextKit 可以后台调用
NSTextStorage *storage = [[NSTextStorage alloc] initWithAttributedString:attributeString];
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:size];
textContainer.maximumNumberOfLines = numberLines;
textContainer.lineFragmentPadding = 0;
textContainer.lineBreakMode = NSLineBreakByWordWrapping;
[layoutManager addTextContainer:textContainer];
[storage addLayoutManager:layoutManager];
[layoutManager ensureLayoutForTextContainer:textContainer];
CGRect rect = [layoutManager usedRectForTextContainer:textContainer];
CGSize attributeSize = rect.size;
return CGSizeMake(ceil(attributeSize.width), ceil(attributeSize.height));
}
结果发现在字体比较大的情况下,TextKit 的方式计算不准确(并不知道为什么),最后参考了 TTT 的源码使用了 coreText 的方式,才最终把问题解决了。
- (CGSize)sizeLabelToFitToSize:(CGSize)size numberLines:(NSInteger)numberOfLines font:(UIFont *)font {
CGFloat ZHFLOAT_MAX = 100000;
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)self);
CFRange rangeToSize = CFRangeMake(0, (CFIndex)[self length]);
CGSize constraints = CGSizeMake(size.width, ZHFLOAT_MAX);
if (numberOfLines == 1 || size.width < 0 ) {
// If there is one line, the size that fits is the full width of the line
constraints = CGSizeMake(ZHFLOAT_MAX, ZHFLOAT_MAX);
} else 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, ZHFLOAT_MAX));
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);
CFRelease(framesetter);
return CGSizeMake(ceil(suggestedSize.width), ceil(suggestedSize.height));
}
-
TTTAttributedLabel 不支持 NSTextAttachment 业务迭代到一定阶段,需要在文本中插入小 icon,但是文本的展示是用 TTT 实现的,发现 TTT 不支持 NSTextAttachment Issue #447 Documenting lack of support for NSTextAttachment · TTTAttributedLabel/TTTAttributedLabel@c1e1ee5 · GitHub 后来用原生的 UITextView 替代了 TTT,还好之前的 TTT 有自己封装过一层,所以替换比较无痛
-
Coretext 方法计算含有 NSTextAttachment 的字符串时高度不正确 第一点中提到的使用 Coretext 方法计算字符串高度,后续加上 NSTextAttachment 功能以后发现计算的结果没有将小 icon 计算进去,也就是说计算的结果比实际的值要小。 后来我有尝试改回使用 TextKit 的方式计算,发现解决了问题,且之前使用 TTT 的时候出现的大字体情况下计算值不准确的问题也没有出现,所以可以确认的是 TextKit 的方式用在 TTT 上是有问题的,在原生 UITextView 是可以正常 work 的。 在计算文本高度上真是一波三折。
-
UITextView 在 iOS11 上的点按手势与之前的版本有所不同 升了 iOS11 后发现有的链接点击不跳转了,但在 iOS10 上没有问题,在网上找到了类似的问题 ios11 - Xcode 9 UITextView links no longer clickable - Stack Overflow 这个问题下讲到了「Changes in the way UITextView responds to touches in iOS11 means that clicking links requires more of a press rather than just a tap which previously worked in iOS10」目测是为了支持 iOS11 的 drag and drop 所以对手势的处理做了调整。
虽然这是系统行为导致的,但是用户才不管那么多呢,就觉得这是个 bug。 所以为了更好的用户体验,我还是尝试解决了一下这个问题: 1、实现一个 CTTextview 继承自 UITextView 2、重写 setAttributedText 方法,在 set 的同时将文本中链接的 url 和链接在文本中的范围(NSRange)一一对应的存储一份:
- (void)setAttributedText:(NSAttributedString *)attributedText {
[super setAttributedText:attributedText];
self.linkRanges = @{}.mutableCopy;
self.classNameRanges = @{}.mutableCopy;
@weakify(self)
[attributedText enumerateAttribute:NSLinkAttributeName
inRange:NSMakeRange(0, [attributedText length])
options:0
usingBlock:^(id value, NSRange range, BOOL *stop) {
@strongify(self)
if (value) {
[self.linkRanges setObject:value forKey:[NSValue valueWithRange:range]];
}
}];
}
3、重写 touchesEnded 方法,将手指触摸点转换为在文本中的位置(CGPoint),再检查这个位置是否在链接所在的区域之中,也就是步骤 2 存储的那些个 NSRange 中:
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
CGPoint point = [[touches anyObject] locationInView:self];
@weakify(self)
[self.linkRanges enumerateKeysAndObjectsUsingBlock:^(NSValue * _Nonnull key, NSString * _Nonnull URLStr, BOOL * _Nonnull stop) {
@strongify(self)
NSRange range = key.rangeValue;
UITextPosition *beginning = self.beginningOfDocument;
UITextPosition *start = [self positionFromPosition:beginning offset:range.location];
UITextPosition *end = [self positionFromPosition:start offset:range.length];
UITextRange *textRange = [self textRangeFromPosition:start toPosition:end];
CGRect rect = [self firstRectForRange:textRange];
if (CGRectContainsPoint(rect, point)) {
// 如果点击处在链接区域内
[self.delegate didTapLink:self URL:[NSURL URLWithString:URLStr]];
}
}];
}
这里只是简单的实现了点击链接能够跳转,点击的效果等都没有涉及到。
CTTextview 类存在的本意是为了使用户轻触也能让链接跳转,但是真正使用起来会发现当触摸的力度比轻触再用力一些些,有一定概率会使链接跳转两次,一次是 CTTextview 内部的逻辑触发的,另一次是 UITextview 触发的,所以以上说到的这种解决方案还是会存在问题,而且我到现在也没有找到好的解决办法…
- 在 iOS 11 下长按带有 NSTextAttachment 的链接会崩溃 把 textDragInteraction.enabled 设置为 NO 解决了这个问题