0

When I draw an attributed string with a fixed line height with Text Kit, the characters always get aligned to the bottom of the line fragment. While this would make sense on one line with characters varying in size, this breaks the flow of the text with multiple lines. The baselines appear decided by the largest descender for each line.

I've found an article from the people behind Sketch explaining this exact problem in a bit more detail and showing what their solution does, but obviously not explaining how they achieved this.

This is what I want basically: bad: shifted baseline, good: baselines remain constant

When showing two lines with a large line height, this result is far from ideal: characters not optically aligned

The code I'm using:

let smallFont = UIFont.systemFont(ofSize: 15)
let bigFont = UIFont.systemFont(ofSize: 25)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.minimumLineHeight = 22
paragraphStyle.maximumLineHeight = 22
var attributes = [
    NSFontAttributeName: smallFont,
    NSParagraphStyleAttributeName: paragraphStyle
]

let textStorage = NSTextStorage()
let textContainer = NSTextContainer(size: CGSize(width: 250, height: 500))
let layoutManager = NSLayoutManager()
textStorage.append(NSAttributedString(string: "It is a long established fact that a reader will be ", attributes:attributes))
attributes[NSFontAttributeName] = bigFont
textStorage.append(NSAttributedString(string: "distracted", attributes:attributes))
attributes[NSFontAttributeName] = smallFont
textStorage.append(NSAttributedString(string: " by the readable content of a page when looking at its layout.", attributes:attributes))

layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)

let textView = UITextView(frame: self.view.bounds, textContainer:textContainer)
view.addSubview(textView)
Pim
  • 86
  • 5

1 Answers1

2

I managed to get this working, but had to drop support for iOS 8 and macOS 10.10 unfortunately.

If you implement the following delegate call of the NSLayoutManager, you get to decide what to do with the baselineOffset for each line fragment:

optional func layoutManager(_ layoutManager: NSLayoutManager, 
 shouldSetLineFragmentRect lineFragmentRect: UnsafeMutablePointer<CGRect>, 
                       lineFragmentUsedRect: UnsafeMutablePointer<CGRect>, 
                             baselineOffset: UnsafeMutablePointer<CGFloat>, 
                           in textContainer: NSTextContainer, 
                   forGlyphRange glyphRange: NSRange) -> Bool

When the NSTextStorage is created and for each subsequent change, I enumerate all used font, calculate it's default line height (NSLayoutManager.defaultLineHeightForFont()) and store the biggest line height. In the implementation of the above mentioned delegate method I check the current line height of the NSParagraphStyle for the provided line fragment and align the font's line height within that value. From there the baseline offset can be calculated with the knowledge that the baseline sits between the font's ascender and descender. Update the baselineOffset value with baselineOffset.memory(newOffset) and everything should be aligned as you'd like.

Note: I'm not going in too much detail about the actual code used to implement this because I'm not sure I'm using the right values throughout these calculations. I might update this in the near future when the whole approach is tried and proven.

Update: Implementation of adjusting baseline. Every time the textContainer changes I recalculate the biggest line height and biggest descender. Then I basically do this in the layout manager's delegate function:

var baseline: CGFloat = (lineFragmentRect.pointee.height - biggestLineHeight) / 2
baseline += biggestLineHeight
baseline -= biggestDescender
baseline = min(max(baseline, 0), lineFragmentRect.pointee.height)
baselineOffset.pointee = floor(baseline)
Pim
  • 86
  • 5