Get each line of text in a UILabel
Asked Answered
N

3

6

I'm trying to add each line in a UILabel to an array, but the code I'm using doesn't appear to be terribly accurate.

func getLinesArrayOfStringInLabel(label:UILabel) -> [String] {

    guard let text: NSString = label.text as? NSString else { return [] }
    let font:UIFont = label.font
    let rect:CGRect = label.frame

    let myFont: CTFont = CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil)

    let attStr:NSMutableAttributedString = NSMutableAttributedString(string: text as String)
    attStr.addAttribute(NSAttributedStringKey.font, value:myFont, range: NSMakeRange(0, attStr.length))

    let frameSetter:CTFramesetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)

    let path: CGMutablePath = CGMutablePath()
    path.addRect(CGRect(x: 0, y: 0, width: rect.size.width, height: 100000))

    let frame:CTFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
    let lines = CTFrameGetLines(frame) as NSArray
    var linesArray = [String]()

    for line in lines {
        let lineRange = CTLineGetStringRange(line as! CTLine)
        let range:NSRange = NSMakeRange(lineRange.location, lineRange.length)
        let lineString = text.substring(with: range)
        linesArray.append(lineString as String)
    }
    return linesArray
}


let label = UILabel()
label.numberOfLines = 0
label.frame = CGRect(x: 40, y: 237, width: 265, height: 53)
label.font = UIFont.systemFont(ofSize: 22, weight: UIFont.Weight.regular)
label.text = "Hey there how's it going today?"
label.backgroundColor = .red
bg.addSubview(label)
print(getLinesArrayOfStringInLabel(label: label))

This prints

["Hey there how\'s it going ", "today?"]

But the label looks like this:

enter image description here

I expected to get ["Hey there how\'s it ", "going today?"]. What's going on?

Namedropper answered 25/10, 2017 at 2:36 Comment(2)
@RashwanL Yep. I'm doing exactly as provided in my code.Namedropper
any progress on solving this. I am looking at this at the moment as well. This might even be font specific and that is even more scary. CATextLayer would probably render it as the printed(unexpected result) as well which is also another issue and I would rather not have to use a CATextLayer in place of a uilabel.Scorpaenoid
S
5

So it appears to be something with UILabel and not something wrong with the function you are using. It was my suspicion that a CATextLayer would render the lines how they are returned from that method and I found out sadly :( that I am right.

Here is a picture of my results: enter image description here

The red is the exact code you used to create your UILabel.

The green is a CATextLayer with all of the same characteristics of the UILabel from above including font, fontsize, and frame size.

The yellow is a subclassed UIView that is replacing its own layer and returning a CATextLayer. I am attaching it below. You can continue to build it out to meet your needs but I think this is the real solution and the only one that will have the get lines matching the visible lines the user sees. If you come up with a better solution please let me know.

import UIKit

class AGLabel: UIView {

    var alignment : String = kCAAlignmentLeft{
        didSet{
            configureText()
        }
    }
    var font : UIFont = UIFont.systemFont(ofSize: 16){
        didSet{
            configureText()
        }
    }
    var fontSize : CGFloat = 16.0{
        didSet{
            configureText()
        }
    }
    var textColor : UIColor = UIColor.black{
        didSet{
            configureText()
        }
    }

    var text : String = ""{
        didSet{
            configureText()
        }
    }


    override class var layerClass: AnyClass {
        get {
            return CATextLayer.self
        }
    }

    func configureText(){
        if let textLayer = self.layer as? CATextLayer{
            textLayer.foregroundColor = textColor.cgColor
            textLayer.font = font
            textLayer.fontSize = fontSize
            textLayer.string = text
            textLayer.contentsScale = UIScreen.main.scale
            textLayer.contentsGravity = kCAGravityCenter
            textLayer.isWrapped = true
        }
    }
}

You should also check out Core-Text-Label on GitHub. It renders exactly as the CATextLayers do and would match the return of the get lines. It won't work for my particular needs as I need mine to be resizable and it crashes but if resizing is not need then I would check it out.

Finally I am back again and it appears that it could be a problem of word wrap that was started in iOS 11 where they do not leave an orphan word on a line.

Scorpaenoid answered 30/10, 2017 at 2:50 Comment(0)
J
0

The problem is UILabel uses smart line breaking strategy by default (which is named .standard).
It does not allow single word to sit on a separate line — this is considered bad in typography.

If you clear lineBreakStrategy options, it will use old-style brute-force line breaking algorithm:

label.lineBreakStrategy = []
Jacindajacinta answered 2/10, 2023 at 15:6 Comment(0)
R
0

If you don't want to break lineBreakStrategy, but want to get accurate answer, we can do that using NSTextContainer. I use the code below to calculate accurate geometry for each line:

extension UILabel {
    func getTextGeometry() -> [CGRect] {
        guard let text else { return [] }

        let storage = NSTextStorage(attributedString: attributedText!)

        let layoutManager = NSLayoutManager()
        storage.addLayoutManager(layoutManager)

        let textContainer = NSTextContainer(size: self.frame.size)
        textContainer.lineFragmentPadding = 0
        textContainer.lineBreakMode = .byWordWrapping
        layoutManager.addTextContainer(textContainer)

        var geometry: [CGRect] = []
        var currentRect: CGRect?
        
        var index = text.startIndex
        while index != text.endIndex {
            let range = index..<text.index(after: index)
            // ignore white spaces
            if text[range].trimmingCharacters(in: .whitespaces).isEmpty {
                index = text.index(after: index)
                continue
            }

            let rect = layoutManager.boundingRect(forGlyphRange: NSRange(range, in: text), in: textContainer)
            index = text.index(after: index)

            if currentRect == nil {
                currentRect = rect
            } else if abs(currentRect!.minY - rect.minY) < 1 {
                currentRect = currentRect!.resizeTo(width: rect.maxX, height: currentRect!.height)
            } else {
                geometry.append(currentRect!)
                currentRect = rect
            }
        }
        geometry.append(currentRect!)

        return geometry
    }
}

Resulting rects

Reorder answered 5/11, 2023 at 20:5 Comment(1)
what does resizeTo do? it seems to be custom logic, can you update your answer with it?Truism

© 2022 - 2024 — McMap. All rights reserved.