I was not able to get @KaanDedeoglu's solution to work in Swift 5 for multiline labels and text views - for whatever reason - so I just ended up writing a 'by hand' solution keeping the same function signatures as seen in @KaanDedeoglu's answer for those who are interested. Works like a charm for the uses in my program.
Width
extension String {
func width(withConstrainedHeight height: CGFloat, font: UIFont) -> CGFloat {
var wordBoxes = [CGSize]()
var calculatedHeight = CGFloat.zero
var calculatedWidth = CGFloat.zero
for word in self.wordsWithWordSeparators() {
let box = word.boundingRect(with: CGSize.zero, attributes: [.font: font], context: nil)
let boxSize = CGSize(width: box.width, height: box.height)
wordBoxes.append(boxSize)
calculatedHeight += boxSize.height
calculatedWidth = calculatedWidth < boxSize.width ? boxSize.width : calculatedWidth
}
while calculatedHeight > height && wordBoxes.count > 1 {
var bestLineToRelocate = wordBoxes.count - 1
for i in 1..<wordBoxes.count {
let bestPotentialWidth = wordBoxes[bestLineToRelocate - 1].width + wordBoxes[bestLineToRelocate].width
let thisPotentialWidth = wordBoxes[i - 1].width + wordBoxes[i].width
if bestPotentialWidth > thisPotentialWidth {
bestLineToRelocate = i
}
}
calculatedHeight -= wordBoxes[bestLineToRelocate].height
wordBoxes[bestLineToRelocate - 1].width += wordBoxes[bestLineToRelocate].width
wordBoxes.remove(at: bestLineToRelocate)
calculatedWidth = max(wordBoxes[bestLineToRelocate - 1].width, calculatedWidth)
}
return ceil(calculatedWidth)
}
}
Height
extension String {
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
var wordBoxes = [CGSize]()
var calculatedHeight = CGFloat.zero
var currentLine = 0
for word in self.wordsWithWordSeparators() {
let box = word.boundingRect(with: CGSize.zero, attributes: [.font: font], context: nil)
let boxSize = CGSize(width: box.width, height: box.height)
if wordBoxes.isEmpty == true {
wordBoxes.append(boxSize)
}
else if wordBoxes[currentLine].width + boxSize.width > width {
wordBoxes.append(boxSize)
currentLine += 1
}
else {
wordBoxes[currentLine].width += boxSize.width
wordBoxes[currentLine].height = max(wordBoxes[currentLine].height, boxSize.height)
}
}
for wordBox in wordBoxes {
calculatedHeight += wordBox.height
}
return calculatedHeight
}
}
Helper Methods Used
extension String {
// Should work with any language supported by Apple
func wordsWithWordSeparators () -> [String] {
let range = self.startIndex..<self.endIndex
var words = [String]()
self.enumerateSubstrings(in: range, options: .byWords) { (substr, substrRange, enclosingRange, stop) in
let wordWithWordSeparators = String(self[enclosingRange])
words.append(wordWithWordSeparators)
}
return words
}
}
Note: these height and width calculations assume the given label or text view will not split or hyphenate words when performing line breaks. If this is not the case for you, you should only have to substitute words for characters. Also, if you're in a runtime sensitive environment, may want to consider throttling these function calls or caching results since they could be a bit expensive depending on how many words the string contains.