Search code examples
iosswifttextkit

TextKit 2: NSTextLayoutManager only gives 1 incorrect text fragment


My goal is to be able to tap on words accurately.... I'm having a small nightmare with TextKit not providing frames for fragments in the way I'd expect, it's clear I'm not setting it up correctly but I can't find the parameter that is causing it.

The NSTextLayoutFragment it returns is just for the first single line in its entirety, I'd like the frames of each word / character / word fragment / whatever it can do to achieve the goal.

class Label: UILabel {

    private var textStorage: NSTextContentStorage? {
        guard let attributedText else { return nil }
        let textStorage = NSTextContentStorage()
        let textContainer = NSTextContainer()
        let textLayoutManager = NSTextLayoutManager()

       // textContainer.size = bounds.size // neither works, nor does 0.0
        textContainer.size = CGSize(width: bounds.size.width, 
                                    height: .greatestFiniteMagnitude)
        textContainer.lineFragmentPadding = 0.0
        textContainer.lineBreakMode = lineBreakMode
       // textContainer.widthTracksTextView = false // makes no difference
        textLayoutManager.textContainer = textContainer
        textStorage.addTextLayoutManager(textLayoutManager)
        textStorage.attributedString = attributedText
       // textStorage.textStorage?.append(attributedText)
        
        return textStorage
    }


    public var characterMap: [CGRect] {
        
        setNeedsLayout()
        layoutIfNeeded()
        
        guard let textStorage,
              let layoutManager = textStorage.textLayoutManagers.first,
              let textContainer = layoutManager.textContainer else { return [] }

        
        layoutManager.enumerateSubstrings(from: textStorage.documentRange.location,
                                          options: [.byWords]) { string, range, textRange, pointer in
            print("String:", string, range, textRange)
        }
        
        var rects = [CGRect]()

        layoutManager.enumerateTextLayoutFragments(from: layoutManager.documentRange.endLocation,
                                                   options: [.reverse, .ensuresLayout]) { layoutFragment in
            rects.append(layoutFragment.layoutFragmentFrame)
            return true
        }

        print("TextFrame:", textContainer.size)
        print(rects)
        return rects
    }

Test string:

"Lorem ipsum dolor ⚡️ sit amet, www.google.com consectetur 🌧😭 adipiscing elit"

layoutManager.enumerateSubstrings works correctly, I get a list of words like this...

String: Optional("Lorem") 0...5 Optional(NSCountableTextRange: {0, 6})
String: Optional("ipsum") 6...11 Optional(NSCountableTextRange: {6, 6})
String: Optional("dolor") 12...17 Optional(NSCountableTextRange: {12, 9})
String: Optional("sit") 21...24 Optional(NSCountableTextRange: {21, 4})
String: Optional("amet") 25...29 Optional(NSCountableTextRange: {25, 6})
String: Optional("www.google.com") 31...45 Optional(NSCountableTextRange: {31, 15})
String: Optional("consectetur") 46...57 Optional(NSCountableTextRange: {46, 17})
String: Optional("adipiscing") 63...73 Optional(NSCountableTextRange: {63, 11})
String: Optional("elit") 74...78 nil

textContainer.size is also correct TextFrame: (361.0, 133.66666666666666)

but layoutManager.enumerateTextLayoutFragments returns only this

[(0.0, 0.0, 357.0078125, 33.4140625)]

What am I missing to get the layoutManager to fragment the way I'd like it? I've tried TextKit 1 and I had the same problem, the scope is limited to the first line.

enter image description here


Solution

  • Ok I've found the reason for the single line... it was the attributedText that overrode the lineBreakMode with its paragraphStyle

        private var textStorage: NSTextStorage? {
            guard let attributedText,
                  let lineBreakMode = (attributes[.paragraphStyle] as? NSMutableParagraphStyle)?.lineBreakMode
            else { return nil }
            
            let layoutManager = NSLayoutManager()
            let textStorage = NSTextStorage(attributedString: attributedText)
            let textContainer = NSTextContainer(size: CGSize(width: bounds.size.width,
                                                             height: CGFloat.greatestFiniteMagnitude))
            textContainer.lineFragmentPadding = 0
            textContainer.maximumNumberOfLines = numberOfLines
            textContainer.lineBreakMode = lineBreakMode
            
            layoutManager.addTextContainer(textContainer)
            textStorage.addLayoutManager(layoutManager)
            
            return textStorage
        }
        
        private var textContentStorage: NSTextContentStorage? {
            
            guard let attributedText,
                  let lineBreakMode = (attributes[.paragraphStyle] as? NSMutableParagraphStyle)?.lineBreakMode
            else { return nil }
                    
            let textStorage = NSTextContentStorage()
            let textLayoutManager = NSTextLayoutManager()
            let textContainer = NSTextContainer(size: CGSize(width: bounds.size.width,
                                                             height: CGFloat.greatestFiniteMagnitude))
    
            textStorage.attributedString = attributedText
            textContainer.lineFragmentPadding = 0.0
            textContainer.lineBreakMode = lineBreakMode // numberOfLines == 1 ? .byTruncatingTail : .byWordWrapping
            textContainer.maximumNumberOfLines = numberOfLines
            
            textLayoutManager.textContainer = textContainer
            textStorage.addTextLayoutManager(textLayoutManager)
            textLayoutManager.ensureLayout(for: textStorage.documentRange)
            textLayoutManager.textViewportLayoutController.layoutViewport()
            
            return textStorage
        }
    

    I used a mixture of TextKit 1 and TextKit 2 to get the word rects... behold!

        public var wordMap: [CGRect] {
    
    //        setNeedsLayout() // up to you if you want to assume layout or not
    //        layoutIfNeeded()
            
            guard let textStorage,
                  let layoutManager = textStorage.layoutManagers.first,
                  let textContainer = layoutManager.textContainers.first,
                  let textContentStorage,
                  let textLayoutManager = textContentStorage.textLayoutManagers.first else { return [] }
       
            var ranges = [NSRange]()
            textLayoutManager.enumerateSubstrings(from: textContentStorage.documentRange.location,
                                              options: [.byWords]) { string, range, textRange, pointer in
                let start = textLayoutManager.offset(from: textContentStorage.documentRange.location,
                                                     to: range.location)
                let length = textLayoutManager.offset(from: range.location,
                                                      to: range.endLocation)
                ranges.append(NSRange(location: start, length: length))
            }
    
            return ranges.map { layoutManager.boundingRect(forGlyphRange: $0, in: textContainer) }
        }