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.
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:
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:
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
:
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":
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:
The second issue is that the extension does not account for multiline labels... If we change the string to "FRIENDSHIP is good." we get:
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:
Unfortunately, if we use this text - "FRIENDSHIP is good for everyone in the world." - the string will exceed the label's height:
So, let's also implement auto-shrink:
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.multilineFitTextToBounds(maxPointSize: 60.0)
and...
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)
}
}