Search code examples
iosstoryboarduilabel

UILabel Splits Text String onto Multiple Lines


I have a UILabel with a fixed width and height. I've set a minimum font scale for the autoshrink property and truncate tail as the line break property. The lines property is set to 0. (Appended image below)

My issue is that for some text, the UILabel splits a word across two lines. Is there a way to manage this? For example, for the word "friendship", the UILabel has "friendshi" on one line and on the next, it has "p".

I've tried setting different line break types, but the changes don't resolve the issue.

enter image description here

enter image description here


Solution

  • Trying to implement Font scaling and word-wrapping on a multiline label has been a long-standing task.

    The first issue is not directly related to using auto-shrink.

    From Apple's docs on line-break mode:

    case byWordWrapping

    The value that indicates wrapping occurs at word boundaries, unless the word doesn’t fit on a single line.

    So, a UILabel with system font / bold / 60, number of lines: 0, constrained to w: 300.0, h: 160.0, we get this:

    enter image description here

    If we set auto-shrink with min scale of 0.5, we get the same thing... because the label is tall enough to wrap onto multiple lines.

    If we change the height of the table to 120.0 - so it's only tall enough for a single line - font scaling will occur and we get this:

    enter image description here

    Now, I'm assuming - since you have number of lines set to Zero and the label is much taller than needed - you're going to have more text than just that single word... but, let's approach a single-word "fix" first.

    Some searching finds this article (not mine): Dynamic Text Resizing in Swift which uses a couple UILabel extensions to "fit the text to the label." Here is the relevant code - the only modifications are to change the old NSAttributedStringKey to NSAttributedString.Key:

    extension UIFont {
        
        /**
         Will return the best font conforming to the descriptor which will fit in the provided bounds.
         */
        static func bestFittingFontSize(for text: String, in bounds: CGRect, fontDescriptor: UIFontDescriptor, additionalAttributes: [NSAttributedString.Key: Any]? = nil) -> CGFloat {
            let constrainingDimension = min(bounds.width, bounds.height)
            let properBounds = CGRect(origin: .zero, size: bounds.size)
            var attributes = additionalAttributes ?? [:]
            
            let infiniteBounds = CGSize(width: CGFloat.infinity, height: CGFloat.infinity)
            var bestFontSize: CGFloat = constrainingDimension
            
            for fontSize in stride(from: bestFontSize, through: 0, by: -1) {
                let newFont = UIFont(descriptor: fontDescriptor, size: fontSize)
                attributes[.font] = newFont
                
                let currentFrame = text.boundingRect(with: infiniteBounds, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: attributes, context: nil)
                
                if properBounds.contains(currentFrame) {
                    bestFontSize = fontSize
                    break
                }
            }
            return bestFontSize
        }
        
        static func bestFittingFont(for text: String, in bounds: CGRect, fontDescriptor: UIFontDescriptor, additionalAttributes: [NSAttributedString.Key: Any]? = nil) -> UIFont {
            let bestSize = bestFittingFontSize(for: text, in: bounds, fontDescriptor: fontDescriptor, additionalAttributes: additionalAttributes)
            return UIFont(descriptor: fontDescriptor, size: bestSize)
        }
    }
    
    extension UILabel {
        
        /// Will auto resize the contained text to a font size which fits the frames bounds.
        /// Uses the pre-set font to dynamically determine the proper sizing
        func fitTextToBounds() {
            guard let text = text, let currentFont = font else { return }
            
            let bestFittingFont = UIFont.bestFittingFont(for: text, in: bounds, fontDescriptor: currentFont.fontDescriptor, additionalAttributes: basicStringAttributes)
            font = bestFittingFont
        }
        
        private var basicStringAttributes: [NSAttributedString.Key: Any] {
            var attribs = [NSAttributedString.Key: Any]()
            
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.alignment = self.textAlignment
            paragraphStyle.lineBreakMode = self.lineBreakMode
            attribs[.paragraphStyle] = paragraphStyle
            
            return attribs
        }
    }
    

    We then call label.fitTextToBounds() (in viewDidLayoutSubviews() because the label size needs to be valid), and the result - again with system font / bold / 60, number of lines: 0, constrained to w: 300.0, h: 160.0:

    enter image description here

    and we're looking pretty good.

    Two issues though...

    First, that code is calculating the "best fit" point size for the label bounds, and if we change the text to "friend":

    enter image description here

    we end up with a point-size of 111 - which I'm assuming is not your goal.

    So, let's make a small modification:

    extension UILabel {
        
        func fitTextToBounds(maxPointSize: CGFloat) {
            guard let text = text, let currentFont = font else { return }
            
            let bestFittingFont = UIFont.bestFittingFont(for: text, in: bounds, fontDescriptor: currentFont.fontDescriptor, additionalAttributes: basicStringAttributes)
    
            // keep the font size less-than-or-equal-to max
            let ps: CGFloat = min(maxPointSize, bestFittingFont.pointSize)
    
            font = UIFont(descriptor: currentFont.fontDescriptor, size: ps)
        }
        
    }
    

    Now we can call it with label.fitTextToBounds(maxPointSize: 60.0) and the result is:

    enter image description here

    The second issue is that the extension does not account for multiline labels... If we change the string to "FRIENDSHIP is good." we get:

    enter image description here

    So, let's try a little change... we'll split the string into words and find the minimum point size needed so the longest single word will fit in the width:

    extension UILabel {
        
        func multilineFitTextToBounds(maxPointSize: CGFloat) {
            guard let text = text, let currentFont = font else { return }
            
            let a: [String] = text.components(separatedBy: " ")
            var minFS: CGFloat = currentFont.pointSize
            a.forEach { s in
                let bestFittingFont = UIFont.bestFittingFont(for: s, in: bounds, fontDescriptor: currentFont.fontDescriptor, additionalAttributes: basicStringAttributes)
                minFS = min(minFS, bestFittingFont.pointSize)
            }
            minFS = min(minFS, maxPointSize)
            
            font = UIFont(descriptor: currentFont.fontDescriptor, size: minFS)
        }
        
    }
    

    and we're looking better:

    enter image description here

    Unfortunately, if we use this text - "FRIENDSHIP is good for everyone in the world." - the string will exceed the label's height:

    enter image description here

    So, let's also implement auto-shrink:

    label.adjustsFontSizeToFitWidth = true
    label.minimumScaleFactor = 0.5
    
    label.multilineFitTextToBounds(maxPointSize: 60.0)
    

    and...

    enter image description here

    Unless I misunderstood your task, or you have additional requirements, that may be a solution.

    Here's a quick example controller using the above extensions:

    class LabelTestVC: UIViewController {
        
        let label = UILabel()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .systemBackground
            
            label.font = .systemFont(ofSize: 60.0, weight: .bold)
            label.numberOfLines = 0
            label.backgroundColor = .green
            label.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(label)
            
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                label.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                label.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                label.widthAnchor.constraint(equalToConstant: 300.0),
                label.heightAnchor.constraint(equalToConstant: 160.0),
                
            ])
            
            label.adjustsFontSizeToFitWidth = true
            label.minimumScaleFactor = 0.5
            
            label.text = "FRIENDSHIP is good for everyone in the world."
        }
        
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
            label.multilineFitTextToBounds(maxPointSize: 60.0)
        }
        
    }