Multiline UILabel with adjustsFontSizeToFitWidth

25,699

Solution 1

In this question, 0x90 provides a solution that - although a bit ugly - does what I want. Specifically, it deals correctly with the situation that a single word does not fit the width at the initial font size. I've slightly modified the code so that it works as a category on NSString:

- (CGFloat)fontSizeWithFont:(UIFont *)font constrainedToSize:(CGSize)size {
    CGFloat fontSize = [font pointSize];
    CGFloat height = [self sizeWithFont:font constrainedToSize:CGSizeMake(size.width,FLT_MAX) lineBreakMode:UILineBreakModeWordWrap].height;
    UIFont *newFont = font;

    //Reduce font size while too large, break if no height (empty string)
    while (height > size.height && height != 0) {   
        fontSize--;  
        newFont = [UIFont fontWithName:font.fontName size:fontSize];   
        height = [self sizeWithFont:newFont constrainedToSize:CGSizeMake(size.width,FLT_MAX) lineBreakMode:UILineBreakModeWordWrap].height;
    };

    // Loop through words in string and resize to fit
    for (NSString *word in [self componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]) {
        CGFloat width = [word sizeWithFont:newFont].width;
        while (width > size.width && width != 0) {
            fontSize--;
            newFont = [UIFont fontWithName:font.fontName size:fontSize];   
            width = [word sizeWithFont:newFont].width;
        }
    }
    return fontSize;
}

To use it with a UILabel:

    CGFloat fontSize = [label.text fontSizeWithFont:[UIFont boldSystemFontOfSize:15] constrainedToSize:label.frame.size];
    label.font = [UIFont boldSystemFontOfSize:fontSize];

EDIT: Fixed the code to initialize newFont with font. Fixes a crash under certain circumstances.

Solution 2

In some cases, changing "Line Breaks" from "Word Wrap" to "Truncate Tail" may be all you need, if you know how many lines you want (e.g. "2"): Credit: Becky Hansmeyer

Solution 3

For a fully working solution, see the bottom of my answer 👇

To manually measure the dimensions of the text / attributedText of your UILabel in order to find the appropriate font size using your own strategy, you have a few options:

  1. Use NSString's size(withAttributes:) or NSAttributedString's size() function. These are only partially useful because they assume the text is one line.

  2. Use NSAttributedString's boundingRect() function, which takes a few drawing options, making sure you supply .usesLineFragmentOrigin to support multiple lines:

    var textToMeasure = label.attributedText
    
    // Modify the font size in `textToMeasure` as necessary
    
    // Now measure
    let rect = textToMeasure.boundingRect(with: label.bounds, options: [. usesLineFragmentOrigin], context: nil)
    
  3. Use TextKit and your own NSLayoutManager:

    var textToMeasure = label.attributedText
    
    // Modify the font size in `textToMeasure` as necessary
    
    // Now measure
    let layoutManager = NSLayoutManager()
    let textContainer = NSTextContainer(size: CGSize(width: label.bounds.width, height: .greatestFiniteMagnitude))
    let textStorage = NSTextStorage(attributedString: string)
    textStorage.addLayoutManager(layoutManager)
    layoutManager.addTextContainer(textContainer)
    let glyphRange = layoutManager.glyphRange(for: textContainer)
    let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
    
  4. Use CoreText, a more powerful and low-level API for laying out text. This would probably be unnecessarily complicated for this task.

Regardless of what you choose to use to measure text, you will probably need to do two passes: The first pass is to account for long words that should not be broken up over multiple lines, where you will need to find the largest font size that fits the largest (~longest) word fully within the label's bounds. In the second pass, you can continue your search downwards from the result of the first pass to find an even smaller font size as required to fit the entire text this time.

When doing the largest word measurement (rather than the entire text), you don't want to restrict the width parameter that you supply to some of the above sizing functions, otherwise the system will have no choice but to break up the single word you gave it and return incorrect results for your purposes. You will need to replace the width argument of the above methods with CGFloat.greatestFiniteMagnitude:

  • the width part of the size argument of boundingRect().
  • the width part of the size argument of NSTextContainer().

You will also need to consider the actual algorithm that you use for your search, since measuring text is an expensive operation and a linear search might be too slow, depending on your needs. If you want to be more efficient you can apply a binary search instead. 🚀

For a robust working solution based on the above, see my open-source framework AccessibilityKit. A couple of examples of AKLabel in action:

Example1

Example2

Hope this helps!

Solution 4

Thanks, with that and a little more from someone else I did this custom UILabel, that will respect the minimum font size and there's a bonus option to align the text to top.

h:

@interface EPCLabel : UILabel {
    float originalPointSize;
    CGSize originalSize;
}

@property (nonatomic, readwrite) BOOL alignTextOnTop;
@end

m:

#import "EPCLabel.h"

@implementation EPCLabel
@synthesize alignTextOnTop;

-(void)verticalAlignTop {
    CGSize maximumSize = originalSize;
    NSString *dateString = self.text;
    UIFont *dateFont = self.font;
    CGSize dateStringSize = [dateString sizeWithFont:dateFont 
                                   constrainedToSize:CGSizeMake(self.frame.size.width, maximumSize.height)
                                       lineBreakMode:self.lineBreakMode];

    CGRect dateFrame = CGRectMake(self.frame.origin.x, self.frame.origin.y, self.frame.size.width, dateStringSize.height);

    self.frame = dateFrame;
}

- (CGFloat)fontSizeWithFont:(UIFont *)font constrainedToSize:(CGSize)size {
    CGFloat fontSize = [font pointSize];
    CGFloat height = [self.text sizeWithFont:font             
                           constrainedToSize:CGSizeMake(size.width,FLT_MAX)  
                               lineBreakMode:UILineBreakModeWordWrap].height;
    UIFont *newFont = font;

    //Reduce font size while too large, break if no height (empty string)
    while (height > size.height && height != 0 && fontSize > self.minimumFontSize) { 
        fontSize--;  
        newFont = [UIFont fontWithName:font.fontName size:fontSize];   
        height = [self.text sizeWithFont:newFont  
                       constrainedToSize:CGSizeMake(size.width,FLT_MAX) 
                           lineBreakMode:UILineBreakModeWordWrap].height;
    };

    // Loop through words in string and resize to fit
    if (fontSize > self.minimumFontSize) {
        for (NSString *word in [self.text componentsSeparatedByString:@" "]) {
            CGFloat width = [word sizeWithFont:newFont].width;
            while (width > size.width && width != 0 && fontSize > self.minimumFontSize) {
                fontSize--;
                newFont = [UIFont fontWithName:font.fontName size:fontSize];   
                width = [word sizeWithFont:newFont].width;
            }
        }
    }
    return fontSize;
}

-(void)setText:(NSString *)text {
    [super setText:text];
    if (originalSize.height == 0) {
        originalPointSize = self.font.pointSize;
        originalSize = self.frame.size;
    }

    if (self.adjustsFontSizeToFitWidth && self.numberOfLines > 1) {
        UIFont *origFont = [UIFont fontWithName:self.font.fontName size:originalPointSize];
        self.font = [UIFont fontWithName:origFont.fontName size:[self fontSizeWithFont:origFont constrainedToSize:originalSize]];
    }

    if (self.alignTextOnTop) [self verticalAlignTop];
}

-(void)setAlignTextOnTop:(BOOL)flag {
    alignTextOnTop = YES;
    if (alignTextOnTop && self.text != nil)
        [self verticalAlignTop];
}

@end

I hope it helps.

Solution 5

There is an ObjC extension provided in comments, that calculate fontsize required to fit multiline text into UILabel. It was rewritten in Swift (since it is 2016):

//
//  NSString+KBAdditions.swift
//
//  Created by Alexander Mayatsky on 16/03/16.
//
//  Original code from http://stackoverflow.com/a/4383281/463892 & http://stackoverflow.com/a/18951386
//

import Foundation
import UIKit

protocol NSStringKBAdditions {
    func fontSizeWithFont(font: UIFont, constrainedToSize size: CGSize, minimumScaleFactor: CGFloat) -> CGFloat
}

extension NSString : NSStringKBAdditions {
    func fontSizeWithFont(font: UIFont, constrainedToSize size: CGSize, minimumScaleFactor: CGFloat) -> CGFloat {
        var fontSize = font.pointSize
        let minimumFontSize = fontSize * minimumScaleFactor


        var attributedText = NSAttributedString(string: self as String, attributes:[NSFontAttributeName: font])
        var height = attributedText.boundingRectWithSize(CGSize(width: size.width, height: CGFloat.max), options:NSStringDrawingOptions.UsesLineFragmentOrigin, context:nil).size.height

        var newFont = font
        //Reduce font size while too large, break if no height (empty string)
        while (height > size.height && height != 0 && fontSize > minimumFontSize) {
            fontSize--;
            newFont = UIFont(name: font.fontName, size: fontSize)!

            attributedText = NSAttributedString(string: self as String, attributes:[NSFontAttributeName: newFont])
            height = attributedText.boundingRectWithSize(CGSize(width: size.width, height: CGFloat.max), options:NSStringDrawingOptions.UsesLineFragmentOrigin, context:nil).size.height
        }

        // Loop through words in string and resize to fit
        for word in self.componentsSeparatedByCharactersInSet(NSCharacterSet.whitespaceAndNewlineCharacterSet()) {
            var width = word.sizeWithAttributes([NSFontAttributeName:newFont]).width
            while (width > size.width && width != 0 && fontSize > minimumFontSize) {
                fontSize--
                newFont = UIFont(name: font.fontName, size: fontSize)!
                width = word.sizeWithAttributes([NSFontAttributeName:newFont]).width
            }
        }
        return fontSize;
    }
}

Link to full code: https://gist.github.com/amayatsky/e6125a2288cc2e4f1bbf

Share:
25,699

Related videos on Youtube

Ortwin Gentz
Author by

Ortwin Gentz

Head of FutureTap, developer of Where To? for iPhone and Streets for iPhone and iPad.

Updated on November 21, 2020

Comments

  • Ortwin Gentz
    Ortwin Gentz over 3 years

    I have a multiline UILabel whose font size I'd like to adjust depending on the text length. The whole text should fit into the label's frame without truncating it.

    Unfortunately, according to the documentation the adjustsFontSizeToFitWidth property "is effective only when the numberOfLines property is set to 1".

    I tried to determine the adjusted font size using

    -[NSString (CGSize)sizeWithFont:(UIFont *)font constrainedToSize:(CGSize)size lineBreakMode:(UILineBreakMode)lineBreakMode]
    

    and then decrementing the font size until it fits. Unfortunately, this method internally truncates the text to fit into the specified size and returns the size of the resulting truncated string.

  • Jilouc
    Jilouc about 12 years
    I've found more accurate to use [self componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] instead of just splitting by space.
  • kevboh
    kevboh almost 12 years
    This is great. I added support for minimum font size and a category on UILabel for convenience and uploaded it as a gist for anyone who's interested.
  • Klaas
    Klaas almost 12 years
    Great method. You might consider adding the line break mode as another parameter.
  • Duane Fields
    Duane Fields about 11 years
    With all these approaches however, if your text won't fit into the specified number of lines at the starting (now maximum) font size, the constrainedToSize call happily truncates the text for you based on your line break, in order to fit the width. thus you end up shrinking only to fit whatever was left, rather than what I think you really want, which is "shrink to fit all this text into n-number of lines"
  • RanLearns
    RanLearns about 11 years
    How do you make use of this fontSizeWithFont method so you can call it in your .m implementation on a UILabel? I'm getting "No visible @interface for 'NSString' declares the selector 'sizeWithFont:constrainedToSize:'"
  • Ortwin Gentz
    Ortwin Gentz about 11 years
    @ObjectiveFlash, create a category on NSString with the method in it, then #import the category where you need it.
  • RanLearns
    RanLearns about 11 years
    Not sure if I'm doing it the way you are, but after some more searching I found a way that works! In the header file, after the "end" of the '@interface', create another '"@interface NSString (NSStringAdditions)"' with the fontSizeWithFont:constrainedToSize: method in it, and then create an '"@implementation NSString (NSStringAdditions)"' in the .m of the viewController where you need to use this, after the "end" of the main '@implementation'
  • Ortwin Gentz
    Ortwin Gentz about 11 years
    @ObjectiveFlash, certainly possible albeit not the best style. Usually you'd put it in an extra NSString+Additions.h/.m file. Then you can import it anywhere you need it.
  • coneybeare
    coneybeare almost 11 years
    these methods are deprecated in iOS[REDACTED]
  • RyanG
    RyanG over 10 years
    I tried converting this to be iOS 7 friendly but I cannot get the same results. Does anything have a similar meted for iOS 7?