8
votes

This is part of an iPhone application but should apply to Cocoa written in objC in general.

I have a UILabel holding various amounts of text (from single characters to several sentences). The text should always be displayed in the largest possible font that fits all the text within the UILabel. The maximum number of lines is set to 4 and the line break mode is set to word wrap.

Since multiple lines are used, adjustsFontSizeToFitWidth won't work for resizing the text.

Thus I am using a loop to determine the largest possible font size for each string as such:

    //Set the text  
    self.textLabel.text = text;
    //Largest size used  
    NSInteger fsize = 200;  textLabel.font = [UIFont
    fontWithName:@"Verdana-Bold"
    size:fsize];

    //Calculate size of the rendered string with the current parameters
    float height = [text sizeWithFont:textLabel.font
        constrainedToSize:CGSizeMake(textLabel.bounds.size.width,99999) 
        lineBreakMode:UILineBreakModeWordWrap].height;

    //Reduce font size by 5 while too large, break if no height (empty string)
    while (height > textLabel.bounds.size.height and height != 0) {   
        fsize -= 5;  
        textLabel.font = [UIFont fontWithName:@"Verdana-Bold" size:fsize];   
        height = [text sizeWithFont:textLabel.font 
            constrainedToSize:CGSizeMake(textLabel.bounds.size.width,99999) 
            lineBreakMode:UILineBreakModeWordWrap].height;
    };

This approach works well for the most part. The exception are long words. Let's take the string @"The experience foo." as an example. The word "experience", being much longer than the others will be split in half without being word-wrapped correctly and the string split across 4 lines. I am looking for a way to reduce the size further so that each individual word fits in one line.

Example:

-old-

Font size: 60

The
Exper
ience
foo

should be

-new-

Font size: 30

The
Experience
foo

There probably is an easy way to do this but I'm hitting a wall.

5

5 Answers

13
votes

Here is the most elegant (yet somewhat hackish) way I found to make this work:

  1. Split the string into words
  2. Calculate the width of each word using the current font size
  3. Reduce the size of the string until each the word fits into one line

Resource consumption is low enough for this to work even in UITableViews full of strings edited this way.

Here is the new code:

//Set the text  
self.textLabel.text = text;
//Largest size used  
NSInteger fsize = 200;  textLabel.font = [UIFont fontWithName:@"Verdana-Bold"
                                                         size:fsize];

//Calculate size of the rendered string with the current parameters
float height = 
      [text sizeWithFont:textLabel.font
       constrainedToSize:CGSizeMake(textLabel.bounds.size.width,99999) 
           lineBreakMode:UILineBreakModeWordWrap].height;

//Reduce font size by 5 while too large, break if no height (empty string)
while (height > textLabel.bounds.size.height and height != 0) {   
    fsize -= 5;  
    textLabel.font = [UIFont fontWithName:@"Verdana-Bold" size:fsize];   
    height = [text sizeWithFont:textLabel.font 
              constrainedToSize:CGSizeMake(textLabel.bounds.size.width,99999) 
                  lineBreakMode:UILineBreakModeWordWrap].height;
};

// Loop through words in string and resize to fit
for (NSString *word in [text componentsSeparatedByString:@" "]) {
    float width = [word sizeWithFont:textLabel.font].width;
    while (width > textLabel.bounds.size.width and width != 0) {
        fsize -= 3;
        textLabel.font = [UIFont fontWithName:@"Verdana-Bold" size:fsize];
        width = [word sizeWithFont:textLabel.font].width;

    }
}
4
votes

Here's my version of 0x90's answer in a category:

@implementation UILabel (MultilineAutosize)

- (void)adjustFontSizeToFit
{
    //Largest size used
    NSInteger fsize = self.font.pointSize;

    //Calculate size of the rendered string with the current parameters
    float height = [self.text sizeWithFont:self.font
                         constrainedToSize:CGSizeMake(self.bounds.size.width, MAXFLOAT)
                             lineBreakMode:NSLineBreakByWordWrapping].height;

    //Reduce font size by 5 while too large, break if no height (empty string)
    while (height > self.bounds.size.height && height > 0) {
        fsize -= 5;
        self.font = [self.font fontWithSize:fsize];
        height = [self.text sizeWithFont:self.font
                       constrainedToSize:CGSizeMake(self.bounds.size.width, MAXFLOAT)
                           lineBreakMode:NSLineBreakByWordWrapping].height;
    };

    // Loop through words in string and resize to fit
    for (NSString *word in [self.text componentsSeparatedByString:@" "]) {
        float width = [word sizeWithFont:self.font].width;
        while (width > self.bounds.size.width && width > 0) {
            fsize -= 3;
            self.font = [self.font fontWithSize:fsize];
            width = [word sizeWithFont:self.font].width;
        }
    }
}

@end
3
votes

You can use the code above in a Category for UILabel

UILabel+AdjustFontSize.h

@interface UILabel (UILabel_AdjustFontSize)

- (void) adjustsFontSizeToFitWidthWithMultipleLinesFromFontWithName:(NSString*)fontName size:(NSInteger)fsize andDescreasingFontBy:(NSInteger)dSize;

@end

UILabel+AdjustFontSize.m

@implementation UILabel (UILabel_AdjustFontSize)

- (void) adjustsFontSizeToFitWidthWithMultipleLinesFromFontWithName:(NSString*)fontName size:(NSInteger)fsize andDescreasingFontBy:(NSInteger)dSize{

    //Largest size used  
    self.font = [UIFont fontWithName:fontName size:fsize];

    //Calculate size of the rendered string with the current parameters
    float height = [self.text sizeWithFont:self.font
                    constrainedToSize:CGSizeMake(self.bounds.size.width,99999) 
                        lineBreakMode:UILineBreakModeWordWrap].height;

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

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

@end
1
votes

It's a great question, and you would think that using the largest possible font size without breaking words up would be part of the built-in UIKit functionality or a related framework by now. Here's a good visual example of the question:

Font resizing animation

As described by others, the trick is to perform the size search for individual words, as well as the entire text as a whole. This is because when you specify a width to draw single words into, the sizing methods will break the words up since they have no other choice - you are asking them to draw an "unbreakable" string, with a specific font size, into a region that simply doesn't fit.

At the heart of my working solution, I use the following binary search function:

func binarySearch(string: NSAttributedString, minFontSize: CGFloat, maxFontSize: CGFloat, maxSize: CGSize, options: NSStringDrawingOptions) -> CGFloat {
    let avgSize = roundedFontSize((minFontSize + maxFontSize) / 2)
    if avgSize == minFontSize || avgSize == maxFontSize { return minFontSize }
    let singleLine = !options.contains(.usesLineFragmentOrigin)
    let canvasSize = CGSize(width: singleLine ? .greatestFiniteMagnitude : maxSize.width, height: .greatestFiniteMagnitude)
    if maxSize.contains(string.withFontSize(avgSize).boundingRect(with: canvasSize, options: options, context: nil).size) {
      return binarySearch(string: string, minFontSize:avgSize, maxFontSize:maxFontSize, maxSize: maxSize, options: options)
    } else {
      return binarySearch(string: string, minFontSize:minFontSize, maxFontSize:avgSize, maxSize: maxSize, options: options)
    }
  }

This alone is not enough though. You need to use it to first find the maximum size that will fit the longest word inside the bounds. Once you have that, continue searching for a smaller size until the entire text fits. This way no word is ever going to be broken up. There are some additional considerations that are a bit more involved, including finding what the longest word actually is (there's some gotchas!) and iOS font caching performance.

If you only care about showing the text on the screen in an easy way, I have developed a robust implementation in Swift, which I'm also using in a production app. It's a UIView subclass with efficient, automatic font scaling for any input text, including multiple lines. To use it, you'd simply do something like:

let view = AKTextView()
// Use a simple or fancy NSAttributedString
view.attributedText = .init(string: "Some text here")
// Add to the view hierarchy somewhere

That's it! You can find the complete source code here: https://github.com/FlickType/AccessibilityKit

Hope this helps!

0
votes

UILabel extension in Swift 4 based on 0x90's answer:

func adjustFontSizeToFit() {
    guard var font = self.font, let text = self.text else { return }
    let size = self.frame.size
    var maxSize = font.pointSize
    while maxSize >= self.minimumScaleFactor * self.font.pointSize {
        font = font.withSize(maxSize)
        let constraintSize = CGSize(width: size.width, height: CGFloat.greatestFiniteMagnitude)
        let textRect = (text as NSString).boundingRect(with: constraintSize, options: .usesLineFragmentOrigin, attributes: [NSAttributedStringKey.font : font], context: nil)
        let labelSize = textRect.size
        if labelSize.height <= size.height {
            self.font = font
            self.setNeedsLayout()
            break
        }
        maxSize -= 1
    }
    self.font = font;
    self.setNeedsLayout()
}