Search code examples
iosuitextviewnsattributedstringcore-texttextkit

Aligning glyphs to the top of a UITextView after sizeToFit


The app I'm working on supports hundreds of different fonts. Some of these fonts, particularly the script fonts, have significant ascenders and descenders. When sizeToFit() is called on a UITextView with some of these fonts, I end up with significant top and bottom padding (the image on the left). The goal is to end up with the image on the right, such that the tallest glyph is aligned flush with the top of the text view's bounding box.

UITextView Layout Examples

Here's the log for the image above:

Point Size: 59.0
Ascender:  70.21
Descender:  -33.158
Line Height:  103.368
Leading: 1.416
TextView Height: 105.0

My first thought was to look at the height of each glyph in the first line of text, and then calculate the offset between the top of the container and the top of the tallest glyph. Then I could use textContainerInset to adjust the top margin accordingly.

I tried something like this in my UITextView subclass:

for location in 0 ..< lastGlyphIndexInFirstLine {
    let glphyRect = self.layoutManager.boundingRect(forGlyphRange: NSRange(location: location, length: 1), in: self.textContainer)
    print(glphyRect.size.height) // prints 104.78399999999999 for each glyph
}

Unfortunately, this doesn't work because boundRect(forGlyphRange:in:) doesn't appear to return the rect of the glyph itself (I'm guessing this is always the same value because it's returning the height of the line fragment?).

Is this the simplest way to solve this problem? If it is, how can I calculate the distance between the top of the text view and the top of the tallest glyph in the first line of text?


Solution

  • This doesn't appear to be possible using TextKit, but it is possible using CoreText directly. Specifically, CGFont's getGlyphBBoxes returns the correct rect in glyph space units, which can then be converted to points relative to the font size.

    Credit goes to this answer for making me aware of getGlyphBBoxes as well as documenting how to convert the resulting rects to points.

    Below is the complete solution. This assumes you have a UITextView subclass with the following set beforehand:

    self.contentInset = .zero
    self.textContainerInset = .zero
    self.textContainer.lineFragmentPadding = 0.0
    

    This function will now return the distance from the top of the text view's bounds to the top of the tallest used glyph:

    private var distanceToGlyphs: CGFloat {
        // sanity
        guard
            let font = self.font,
            let fontRef = CGFont(font.fontName as CFString),
            let attributedText = self.attributedText,
            let firstLine = attributedText.string.components(separatedBy: .newlines).first
        else { return 0.0 }
    
        // obtain the first line of text as an attributed string
        let attributedFirstLine = attributedText.attributedSubstring(from: NSRange(location: 0, length: firstLine.count)) as CFAttributedString
    
        // create the line for the first line of attributed text
        let line = CTLineCreateWithAttributedString(attributedFirstLine)
    
        // get the runs within this line (there will typically only be one run when using a single font)
        let glyphRuns = CTLineGetGlyphRuns(line) as NSArray
        guard let runs = glyphRuns as? [CTRun] else { return 0.0 }
    
        // this will store the maximum distance from the baseline
        var maxDistanceFromBaseline: CGFloat = 0.0
    
        // iterate each run
        for run in runs {
            // get the total number of glyphs in this run
            let glyphCount = CTRunGetGlyphCount(run)
    
            // initialize empty arrays of rects and glyphs
            var rects = Array<CGRect>(repeating: .zero, count: glyphCount)
            var glyphs = Array<CGGlyph>(repeating: 0, count: glyphCount)
    
            // obtain the glyphs
            self.layoutManager.getGlyphs(in: NSRange(location: 0, length: glyphCount), glyphs: &glyphs, properties: nil, characterIndexes: nil, bidiLevels: nil)
    
            // obtain the rects per-glyph in "glyph space units", each of which needs to be scaled using units per em and the font size
            fontRef.getGlyphBBoxes(glyphs: &glyphs, count: glyphCount, bboxes: &rects)
    
            // iterate each glyph rect
            for rect in rects {
                // obtain the units per em from the font ref so we can convert the rect
                let unitsPerEm = CGFloat(fontRef.unitsPerEm)
    
                // sanity to prevent divide by zero
                guard unitsPerEm != 0.0 else { continue }
    
                // calculate the actual distance up or down from the glyph's baseline
                let glyphY = (rect.origin.y / unitsPerEm) * font.pointSize
    
                // calculate the actual height of the glyph
                let glyphHeight = (rect.size.height / unitsPerEm) * font.pointSize
    
                // calculate the distance from the baseline to the top of the glyph
                let glyphDistanceFromBaseline = glyphHeight + glyphY
    
                // store the max distance amongst the glyphs
                maxDistanceFromBaseline = max(maxDistanceFromBaseline, glyphDistanceFromBaseline)
            }
        }
    
        // the final top margin, calculated by taking the largest ascender of all the glyphs in the font and subtracting the max calculated distance from the baseline
        return font.ascender - maxDistanceFromBaseline
    }
    

    You can now set the text view's top contentInset to -distanceToGlyphs to achieve the desired result.