動(dòng)態(tài)計(jì)算NSAttributedString的寬高的方法
最近在復(fù)盤之前項(xiàng)目中關(guān)于文本寬高計(jì)算的實(shí)現(xiàn), 這里簡(jiǎn)單歸納總結(jié)一下.
1. boundingRectWithSize 方法
文本的寬高計(jì)算的API主要有如下方法:
// NOTE: All of the following methods will default to drawing on a baseline, limiting drawing to a single line.
// To correctly draw and size multi-line text, pass NSStringDrawingUsesLineFragmentOrigin in the options parameter.
@interface NSString (NSExtendedStringDrawing)
- (void)drawWithRect:(CGRect)rect options:(NSStringDrawingOptions)options attributes:(nullable NSDictionary<NSAttributedStringKey, id> *)attributes context:(nullable NSStringDrawingContext *)context API_AVAILABLE(macos(10.11), ios(7.0));
- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options attributes:(nullable NSDictionary<NSAttributedStringKey, id> *)attributes context:(nullable NSStringDrawingContext *)context API_AVAILABLE(macos(10.11), ios(7.0));
@end
@interface NSAttributedString (NSExtendedStringDrawing)
- (void)drawWithRect:(CGRect)rect options:(NSStringDrawingOptions)options context:(nullable NSStringDrawingContext *)context API_AVAILABLE(macos(10.11), ios(6.0));
- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options context:(nullable NSStringDrawingContext *)context API_AVAILABLE(macos(10.11), ios(6.0));
@end
另外關(guān)于配置options:
options表示計(jì)算的類型
NSStringDrawingUsesLineFragmentOrigin:繪制文本時(shí)使用 line fragement origin 而不是 baseline origin圃阳。一般使用這項(xiàng)限佩。
NSStringDrawingUsesFontLeading:根據(jù)字體計(jì)算高度
NSStringDrawingUsesDeviceMetrics:使用象形文字計(jì)算高度
NSStringDrawingTruncatesLastVisibleLine:如果NSStringDrawingUsesLineFragmentOrigin設(shè)置祟同,這個(gè)選項(xiàng)沒(méi)有用
官方文檔中有部分注釋:
This method draws as much of the string as it can inside the specified rectangle, wrapping the string text as needed to make it fit. If the string is too big to fit completely inside the rectangle, the method scales the font or adjusts the letter spacing to make the string fit within the given bounds.
If newline characters are present in the string, those characters are honored and cause subsequent text to be placed on the next line underneath the starting point. To correctly draw and size multi-line text, pass NSStringDrawingUsesLineFragmentOrigin in the options parameter.
因此, 是計(jì)算多行文本信息在NSStringDrawingOptions
選項(xiàng), 一般需要添加如下的配置, 不然計(jì)算出來(lái)的高度不準(zhǔn)確:
NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading
另外在計(jì)算出CGSize以后, 一般得到的寬高是浮點(diǎn)數(shù), 如果需要用這個(gè)Size配置給某個(gè)View作為View的Size, 還需要通過(guò)
ceil
方法向上取整得到更加準(zhǔn)確的CGSize, 也就是 ceilf(size.height)另外計(jì)算string size 時(shí), 使用的屬性, 一定需要與View中設(shè)置文本時(shí), 使用的屬性一致!
特殊情況: 使用該方法計(jì)算完文本中中有\n
或者\r\n
, 會(huì)導(dǎo)致計(jì)算寬高不準(zhǔn)確, 這里貼一個(gè)網(wǎng)上針對(duì)這個(gè)問(wèn)題的解決方法(但是本人并不建議這樣做):
由于這個(gè)方法計(jì)算字符串的大小的通過(guò)取得字符串的size來(lái)計(jì)算, 如果你計(jì)算的字符串中包含`\n\r `這樣的字符, 也只會(huì)把它當(dāng)成字符來(lái)計(jì)算泞坦。但是在顯示的時(shí)候就是`\n`是轉(zhuǎn)義字符贰锁,那么顯示的計(jì)算的高度就不一樣了, 所以可以采用:
計(jì)算的高度 = boundingRectWithSize計(jì)算出來(lái)的高度 + \n\r轉(zhuǎn)義字符出現(xiàn)的個(gè)數(shù) * 單行文本的高度滤蝠。
2. 使用UILabel的sizeThatFits:
方法
這里以指定的UILabel來(lái)作為富文本展示示例
// useBound label size: (0.0, 0.0, 88.0078125, 76.375)
func useBound() -> UILabel {
let label = UILabel()
let attr: [NSAttributedString.Key : Any] = [
.font: UIFont.systemFont(ofSize: 16),
.foregroundColor : UIColor.red.cgColor,
]
let attrStr = NSAttributedString(string: "hello world1\n\n\nhello world2", attributes: attr)
let rect = attrStr.boundingRect(with: CGSize(width: 100, height: Int.max), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
print("useBound label size: \(rect.size)")
label.frame = CGRect(x: 100, y: 100, width: ceil(rect.width), height: ceil(rect.height));
label.attributedText = attrStr
label.numberOfLines = 0
label.backgroundColor = .green
return label
}
// useLabel label size: (88.33333333333333, 76.66666666666667)
func useLabel() -> UILabel {
let label = UILabel()
let attrStr = NSAttributedString(string: "hello world1\n\n\nhello world2", attributes: [
.font: UIFont.systemFont(ofSize: 16),
.foregroundColor : UIColor.blue.cgColor,
])
label.attributedText = attrStr
label.numberOfLines = 0
let size = label.sizeThatFits(CGSize.init(width: 100, height: Int.max))
print("useLabel label size: \(size)")
label.frame = CGRect(x: 0, y: 100, width: size.width, height: size.height);
label.backgroundColor = .green
return label
}
最后展示出來(lái)兩者大小差不多的, 但是注意第一條中使用ceil
向上取整操作!
3. 使用CoreText中的CTFrame計(jì)算
網(wǎng)上關(guān)于CoreText相關(guān)信息非常多, 簡(jiǎn)單來(lái)說(shuō)就是使用iOS系統(tǒng)的排版, 然后通過(guò)排版以后結(jié)果信息來(lái)獲取系統(tǒng)排版渲染以后的結(jié)果.
CTFramesetter是CTFrame的創(chuàng)建工廠, NSAttributedString需要通過(guò)CTFrame繪制到界面上锣险,得到CTFrameSetter后芯肤,創(chuàng)建path(繪制路徑), 然后得到CTFrame, 最后通過(guò)CTFrameDraw方法繪制到界面上
CTFramesetter關(guān)聯(lián)NSAttributedString, 此時(shí)CTTypesetter實(shí)例將自動(dòng)創(chuàng)建, 它管理了字體压鉴。然后使用CTFramesetter 創(chuàng)建您要用于渲染文本的一個(gè)或多個(gè)幀油吭。當(dāng)創(chuàng)建幀時(shí), 指定一個(gè)用于此幀矩形內(nèi)的子文本范圍
每行文本會(huì)自動(dòng)創(chuàng)建成CTLine , 并在CTLine內(nèi)創(chuàng)建多個(gè) CTRun文本分段, 每個(gè)CTRun內(nèi)的文本有著同樣的格式
同時(shí)每個(gè) CTRun 對(duì)象可以采用不同的屬性上鞠,所以你可以精確的控制字距,連字世曾,寬度谴咸,高度等更多屬性
常見(jiàn)使用CTFrame計(jì)算文本高度有三種方法, 其中建議使用CTFramesetterSuggestFrameSizeWithConstraints
進(jìn)行計(jì)算
3.1 獲取每條CTLine信息,累加
CGFloat heightValue = 0;
//string 為要計(jì)算高的NSAttributedString
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)string);
//這里的高要設(shè)置足夠大
CGFloat height = 10000;
CGRect drawingRect = CGRectMake(0, 0, width, height);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, drawingRect);
CTFrameRef textFrame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0,0), path, NULL);
CGPathRelease(path);
CFRelease(framesetter);
CFArrayRef lines = CTFrameGetLines(textFrame);
CGPoint lineOrigins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), lineOrigins);
/******************
* 逐行l(wèi)ineHeight累加
******************/
heightValue = 0;
for (int i = 0; i < CFArrayGetCount(lines); i++) {
CTLineRef line = CFArrayGetValueAtIndex(lines, i);
CGFloat lineAscent;//上行行高
CGFloat lineDescent;//下行行高
CGFloat lineLeading;//行距
CGFloat lineHeight;//行高
//獲取每行的高度
CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, &lineLeading);
lineHeight = lineAscent + fabs(lineDescent) + lineLeading;
heightValue = heightValue + lineHeight;
}
heightValue = CGFloat_ceil(heightValue);
3.2 最后一行原點(diǎn)y坐標(biāo)加最后一行高度:
CGFloat heightValue = 0;
//string 為要計(jì)算高的NSAttributedString
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)string);
//這里的高要設(shè)置足夠大
CGFloat height = 10000;
CGRect drawingRect = CGRectMake(0, 0, width, height);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, drawingRect);
CTFrameRef textFrame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0,0), path, NULL);
CGPathRelease(path);
CFRelease(framesetter);
CFArrayRef lines = CTFrameGetLines(textFrame);
CGPoint lineOrigins[CFArrayGetCount(lines)];
CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), lineOrigins);
/******************
* 最后一行原點(diǎn)y坐標(biāo)加最后一行下行行高跟行距
******************/
heightValue = 0;
CGFloat line_y = (CGFloat)lineOrigins[CFArrayGetCount(lines)-1].y; //最后一行l(wèi)ine的原點(diǎn)y坐標(biāo)
CGFloat lastAscent = 0;//上行行高
CGFloat lastDescent = 0;//下行行高
CGFloat lastLeading = 0;//行距
CTLineRef lastLine = CFArrayGetValueAtIndex(lines, CFArrayGetCount(lines)-1);
CTLineGetTypographicBounds(lastLine, &lastAscent, &lastDescent, &lastLeading);
//height - line_y為除去最后一行的字符原點(diǎn)以下的高度轮听,descent + leading為最后一行不包括上行行高的字符高度
heightValue = height - line_y + (CGFloat)(fabs(lastDescent) + lastLeading);
heightValue = CGFloat_ceil(heightValue);
3.3 直接使用CTFramesetterSuggestFrameSizeWithConstraints
- (CGSize)sizeThatFits:(CGSize)size {
NSAttributedString *drawString = self.data.attributeStringToDraw;
if (drawString == nil) {
return CGSizeZero;
}
// 通過(guò)CTFrame 獲取計(jì)算的結(jié)果
CFAttributedStringRef attributedStringRef = (__bridge CFAttributedStringRef)drawString;
// 使用 attr Sttr 創(chuàng)建 FramesetterRef 內(nèi)容
// 在 main thread 中運(yùn)行
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString(attributedStringRef);
CFRange range = CFRangeMake(0, 0);
// 渲染問(wèn)題
if (_numberOfLines > 0 && framesetter) {
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake(0, 0, size.width, size.height));
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
// 獲取全局的 lines
CFArrayRef lines = CTFrameGetLines(frame);
// 計(jì)算行
if (nil != lines && CFArrayGetCount(lines) > 0) {
// 最小的展示行
NSInteger lastVisibleLineIndex = MIN(_numberOfLines, CFArrayGetCount(lines)) - 1;
CTLineRef lastVisibleLine = CFArrayGetValueAtIndex(lines, lastVisibleLineIndex);
// 獲取最后一行可見(jiàn)行的 rangeToLayout
CFRange rangeToLayout = CTLineGetStringRange(lastVisibleLine);
// 搞定range -> 0 到最后一行
range = CFRangeMake(0, rangeToLayout.location + rangeToLayout.length);
}
CFRelease(frame);
CFRelease(path);
}
CFRange fitCFRange = CFRangeMake(0, 0);
// 針對(duì) CTFramesetter 構(gòu)造一個(gè)constriants 信息
CGSize newSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, range, NULL, size, &fitCFRange);
if (framesetter) {
CFRelease(framesetter);
}
// 計(jì)算出來(lái)的 newSize
return newSize;
}
4. 猜測(cè)一下使用UILabel的sizeThatFits的原理
網(wǎng)上也有使用intrinsicContentSize
結(jié)合preferredMaxLayoutWidth
計(jì)算自適應(yīng)高度的內(nèi)容
UIlabel擁有intrinsicContentSize
方法調(diào)用邏輯可能如下:
- (CGSize)intrinsicContentSize {
return [self sizeThatFits:CGSizeMake(self.bounds.size.width, MAXFLOAT)];
}
- (CGSize)sizeThatFits:(CGSize)size {
// 通過(guò)計(jì)算attrString構(gòu)造CFTrame
// 然后通過(guò) CTFrame相關(guān)排版服務(wù)計(jì)算寬高
// 然后處理UILabel 的 UIRectEdge 信息
}