user2393462435
user2393462435

Reputation: 2652

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?

Upvotes: 4

Views: 420

Answers (1)

user2393462435
user2393462435

Reputation: 2652

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.

Upvotes: 2

Related Questions