Search code examples
iosswiftuikittextkit

layoutManager.glyphRange returns full glyph range although textContainer only has a width of 1


I want to determine the "amount" of text that fits into a given text container/bounding box. However, no matter how I setup my text container, I always get all glyphs/characters returned. This is my playground test code:

import UIKit

let height = 30
let width = 50
let text = "Dame Henrietta Barnett (1851-1936) was a social reformer and philanthropist known for her significant contributions to improving the lives of the urban poor in London during the late 19th and early 20th centuries. She is best known as the founder of Hampstead Garden Suburb, a pioneering model of urban planning that aimed to create a harmonious blend of town and country living."

let size = CGSize(width: width, height: height)
let textContainer = NSTextContainer(size: size)
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = .byClipping

let layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)

let font = UIFont.systemFont(ofSize: 18)
let attributes: [NSAttributedString.Key: Any] = [
    .font: font,
]

let textStorage = NSTextStorage(string: text, attributes: attributes)
textStorage.addLayoutManager(layoutManager)


let glyphRange = layoutManager.glyphRange(for: textContainer)
let characterRange = layoutManager.glyphRange(forBoundingRect: CGRect(x: 0, y: 0, width: 1, height: 1), in: textContainer)
let visibleText = (text as NSString).substring(with: characterRange)
NSLog("Visible text: \(visibleText)")

The value of "visibleText" is the full text. If I specify a width / height of 0 the range is 0, which makes sense.

Note: I know that with a width and height it should probably not return any range, I am confused to why it does (specifying any other range e.g. w: 100, height: 40, also returns the full text)

I also tried using

let characterRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)

but this has the same problem.

used Xcode version: 16.0 beta 2


Solution

  • Your problem is the .byClipping. What you almost certainly mean here is .byCharacterWrap, which does what you expect. This will return the range that will completely fit within the bounds (except for a bug I'll discuss at the end).

    While it's poorly documented, I am fairly certain that the point of .byClipping is that the layout manager doesn't need to worry about it. The line is intended to later be cut off by clipping the context, so if the whole string is already laid out, just return the whole range. Importantly, the docs for .byClipping say (emphasis added):

    The value that indicates lines don’t extend past the edge of the text container.

    It is not documented to truncate the line. It is documented that the line doesn't extend beyond the container. Yes, TextKit documentation is vague and confusing. Often. Also sometimes just wrong.

    If you don't need NSLayoutManager for some other reason, it is often much clearer (and often simpler) to implement this stuff directly in Core Text. NSLayoutManager is a mess of insufficient documentation and outright bugs. It works for exactly what NS(UI)TextView needs, and often fails in frustrating ways outside of those precise uses cases.

    Here's the entire code in Core Text for comparison:

    let width = 50.0
    
    let text = "Dame Henrietta Barnett (1851-1936) was a social reformer and philanthropist known for her significant contributions to improving the lives of the urban poor in London during the late 19th and early 20th centuries. She is best known as the founder of Hampstead Garden Suburb, a pioneering model of urban planning that aimed to create a harmonious blend of town and country living."
    
    let font = UIFont.systemFont(ofSize: 18)
    let attributes: [NSAttributedString.Key: Any] = [
        .font: font,
    ]
    let string = NSAttributedString(string: text, attributes: attributes)
    
    let typesetter = CTTypesetterCreateWithAttributedString(string)
    let index = CTTypesetterSuggestClusterBreak(typesetter, 0, width)
    let visibleText = (text as NSString).substring(to: index)
    print(visibleText)
    

    This code is also correct in a way that your original code is not. These lines have a bug:

    let characterRange = layoutManager.glyphRange(forBoundingRect: CGRect(x: 0, y: 0, width: 1, height: 1), in: textContainer)
    let visibleText = (text as NSString).substring(with: characterRange)
    

    What you've called characterRange is actually a range of glyphs. You can't use that directly on an NSString. Glyphs do not map exactly to characters. You need characterRange(forGlyphRange:actualGlyphRange:) to convert between them. With CTTypesetter you don't need to do that since it works in the same index space as NSAttributedString.