Свифт 4
Очень слабо основано на ответе Грэма Перка. Я не мог заставить его код работать как есть, но после трех часов работы я создал что-то, что прекрасно работает! Если вы предпочитаете полную реализацию этого, а также кучу отличных дополнений по производительности и функциям (ссылки, асинхронное рисование и т. Д.), Ознакомьтесь с моей единой библиотекой файлов DYLabel . Если нет, то читайте дальше.
Я все объясняю в комментариях. Это метод draw, который вызывается из drawRect:
/// Draw text on a given context. Supports superscript using NSBaselineOffsetAttributeName
///
/// This method works by drawing the text backwards (i.e. last line first). This is very very important because it's how we ensure superscripts don't overlap the text above it. In other words, we need to start from the bottom, get the height of the text we just drew, and then draw the next text above it. This could be done in a forward direction but you'd have to use lookahead which IMO is more work.
///
/// If you have to modify on this, remember that CT uses a mathmatical origin (i.e. 0,0 is bottom left like a cartisian plane)
/// - Parameters:
/// - context: A core graphics draw context
/// - attributedText: An attributed string
func drawText(context:CGContext, attributedText: NSAttributedString) {
//Create our CT boiler plate
let framesetter = CTFramesetterCreateWithAttributedString(attributedText)
let textRect = bounds
let path = CGPath(rect: textRect, transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
//Fetch our lines, bridging to swift from CFArray
let lines = CTFrameGetLines(frame) as [AnyObject]
let lineCount = lines.count
//Get the line origin coordinates. These are used for calculating stock line height (w/o baseline modifications)
var lineOrigins = [CGPoint](repeating: CGPoint.zero, count: lineCount)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &lineOrigins);
//Since we're starting from the bottom of the container we need get our bottom offset/padding (so text isn't slammed to the bottom or cut off)
var ascent:CGFloat = 0
var descent:CGFloat = 0
var leading:CGFloat = 0
if lineCount > 0 {
CTLineGetTypographicBounds(lines.last as! CTLine, &ascent, &descent, &leading)
}
//This variable holds the current draw position, relative to CT origin of the bottom left
//https://stackoverflow.com/a/27631737/1166266
var drawYPositionFromOrigin:CGFloat = descent
//Again, draw the lines in reverse so we don't need look ahead
for lineIndex in (0..<lineCount).reversed() {
//Calculate the current line height so we can accurately move the position up later
let lastLinePosition = lineIndex > 0 ? lineOrigins[lineIndex - 1].y: textRect.height
let currentLineHeight = lastLinePosition - lineOrigins[lineIndex].y
//Throughout the loop below this variable will be updated to the tallest value for the current line
var maxLineHeight:CGFloat = currentLineHeight
//Grab the current run glyph. This is used for attributed string interop
let glyphRuns = CTLineGetGlyphRuns(lines[lineIndex] as! CTLine) as [AnyObject]
for run in glyphRuns {
let run = run as! CTRun
//Convert the format range to something we can match to our string
let runRange = CTRunGetStringRange(run)
let attribuetsAtPosition = attributedText.attributes(at: runRange.location, effectiveRange: nil)
var baselineAdjustment: CGFloat = 0.0
if let adjust = attribuetsAtPosition[NSAttributedStringKey.baselineOffset] as? NSNumber {
//We have a baseline offset!
baselineAdjustment = CGFloat(adjust.floatValue)
}
//Check if this glyph run is tallest, and move it if it is
maxLineHeight = max(currentLineHeight + baselineAdjustment, maxLineHeight)
//Move the draw head. Note that we're drawing from the unupdated drawYPositionFromOrigin. This is again thanks to CT cartisian plane where we draw from the bottom left of text too.
context.textPosition = CGPoint.init(x: lineOrigins[lineIndex].x, y: drawYPositionFromOrigin)
//Draw!
CTRunDraw(run, context, CFRangeMake(0, 0))
}
//Move our position because we've completed the drawing of the line which is at most `maxLineHeight`
drawYPositionFromOrigin += maxLineHeight
}
}
Я также создал метод, который вычисляет требуемую высоту текста с учетом ширины. Это точно такой же код, за исключением того, что он ничего не рисует.
/// Calculate the height if it were drawn using `drawText`
/// Uses the same code as drawText except it doesn't draw.
///
/// - Parameters:
/// - attributedText: The text to calculate the height of
/// - width: The constraining width
/// - estimationHeight: Optional paramater, default 30,000px. This is the container height used to layout the text. DO NOT USE CGFLOATMAX AS IT CORE TEXT CANNOT CREATE A FRAME OF THAT SIZE.
/// - Returns: The size required to fit the text
static func size(of attributedText:NSAttributedString,width:CGFloat, estimationHeight:CGFloat?=30000) -> CGSize {
let framesetter = CTFramesetterCreateWithAttributedString(attributedText)
let textRect = CGRect.init(x: 0, y: 0, width: width, height: estimationHeight!)
let path = CGPath(rect: textRect, transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
//Fetch our lines, bridging to swift from CFArray
let lines = CTFrameGetLines(frame) as [AnyObject]
let lineCount = lines.count
//Get the line origin coordinates. These are used for calculating stock line height (w/o baseline modifications)
var lineOrigins = [CGPoint](repeating: CGPoint.zero, count: lineCount)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &lineOrigins);
//Since we're starting from the bottom of the container we need get our bottom offset/padding (so text isn't slammed to the bottom or cut off)
var ascent:CGFloat = 0
var descent:CGFloat = 0
var leading:CGFloat = 0
if lineCount > 0 {
CTLineGetTypographicBounds(lines.last as! CTLine, &ascent, &descent, &leading)
}
//This variable holds the current draw position, relative to CT origin of the bottom left
var drawYPositionFromOrigin:CGFloat = descent
//Again, draw the lines in reverse so we don't need look ahead
for lineIndex in (0..<lineCount).reversed() {
//Calculate the current line height so we can accurately move the position up later
let lastLinePosition = lineIndex > 0 ? lineOrigins[lineIndex - 1].y: textRect.height
let currentLineHeight = lastLinePosition - lineOrigins[lineIndex].y
//Throughout the loop below this variable will be updated to the tallest value for the current line
var maxLineHeight:CGFloat = currentLineHeight
//Grab the current run glyph. This is used for attributed string interop
let glyphRuns = CTLineGetGlyphRuns(lines[lineIndex] as! CTLine) as [AnyObject]
for run in glyphRuns {
let run = run as! CTRun
//Convert the format range to something we can match to our string
let runRange = CTRunGetStringRange(run)
let attribuetsAtPosition = attributedText.attributes(at: runRange.location, effectiveRange: nil)
var baselineAdjustment: CGFloat = 0.0
if let adjust = attribuetsAtPosition[NSAttributedStringKey.baselineOffset] as? NSNumber {
//We have a baseline offset!
baselineAdjustment = CGFloat(adjust.floatValue)
}
//Check if this glyph run is tallest, and move it if it is
maxLineHeight = max(currentLineHeight + baselineAdjustment, maxLineHeight)
//Skip drawing since this is a height calculation
}
//Move our position because we've completed the drawing of the line which is at most `maxLineHeight`
drawYPositionFromOrigin += maxLineHeight
}
return CGSize.init(width: width, height: drawYPositionFromOrigin)
}
Как и все, что я пишу, я также провел некоторые тесты для некоторых публичных библиотек и системных функций (хотя они здесь не будут работать). Я использовал огромную сложную строку, чтобы никто не допустил несправедливых сочетаний клавиш.
---HEIGHT CALCULATION---
Runtime for 1000 iterations (ms) BoundsForRect: 5415.030002593994
Runtime for 1000 iterations (ms) layoutManager: 5370.990991592407
Runtime for 1000 iterations (ms) CTFramesetterSuggestFrameSizeWithConstraints: 2372.151017189026
Runtime for 1000 iterations (ms) CTFramesetterCreateFrame ObjC: 2300.302028656006
Runtime for 1000 iterations (ms) CTFramesetterCreateFrame-Swift: 2313.6669397354126
Runtime for 1000 iterations (ms) THIS ANSWER size(of:): 2566.351056098938
---RENDER---
Runtime for 1000 iterations (ms) AttributedLabel: 35.032033920288086
Runtime for 1000 iterations (ms) UILabel: 45.948028564453125
Runtime for 1000 iterations (ms) TTTAttributedLabel: 301.1329174041748
Runtime for 1000 iterations (ms) THIS ANSWER: 20.398974418640137
Итак, итоговое время: мы отлично справились! size(of...)
почти соответствует стандартной компоновке CT, что означает, что наше дополнение для надстрочного индекса довольно дешево, несмотря на использование поиска в хеш-таблице. Тем не менее, мы выигрываем в ничью. Я подозреваю, что это из-за очень дорогого кадра оценки 30k пикселей, который мы должны создать. Если мы сделаем лучшую оценку, производительность будет лучше. Я уже работаю около трех часов, поэтому я называю это увольнением и оставляю это в качестве упражнения для читателя.