Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Vertically Align UILabel text with constraints and no wrap (auto layout, single line)

Tags:

ios

autolayout

So I have my view setup in IB such that this text label aligns with the top of the thumbnail via constraints.

enter image description here

However as we know, you can't vertically align text in a UILabel. My text updates the font size based on the length of the content. Full size text looks great, while small text is significantly lower on the view.

enter image description here

The existing solution involves either calling sizeToFit or updating the frame of the uilabel to match the height of the text. Unfortunately the latter (albeit ugly) solution doesn't play well with constraints where you aren't supposed to update the frame. The former solution basically doesn't work when you need to have the text autoshrink until it truncates. (So it doesn't work with a restricted number of lines and autoshrink).

Now as to why the intrinsic size (height) of the uilabel doesn't update like the width does when it's set to it's natural size via "Size to fit content" is beyond me. Seems like it definitely should, but it doesn't.

So I'm left looking for alternative solutions. As far as I can see, you might have to set a height constraint on the label, and adjust the height constant after calculating the height of the text. Anyone have a good solution?

like image 521
Bob Spryn Avatar asked Mar 19 '13 21:03

Bob Spryn


3 Answers

This problem is a real PITA to solve. It doesn't help that the API's that work are deprecated in iOS7, or that the iOS7 replacement API's are broken. Blah!

Your solution is nice, however it uses a deprecated API (sizeWithFont:minFontSize:actualFontSize:forWidth:lineBreakMode:), and it's not very well encapsulated - you need to copy this code around to any cells or views where you want this behavior. On the plus side it's fairly efficient! One bug may be that the label hasn't been laid out yet when you do your calculation, but you perform your calculation based on its width.

I propose that you encapsulate this behavior in a UILabel subclass. By placing the sizing calculation in an overridden intrinsicContentSize method the label will auto-size itself. I wrote the following, which incorporates your code that will execute on iOS6, and my version using non-deprecated API's for iOS7 or better:

@implementation TSAutoHeightLabel

- (CGSize) intrinsicContentSize
{
    NSAssert( self.baselineAdjustment == UIBaselineAdjustmentAlignCenters, @"Please ensure you are using UIBaselineAdjustmentAlignCenters!" );

    NSAssert( self.numberOfLines == 1, @"This is only for single-line labels!" );

    CGSize intrinsicContentSize;

    if ( [self.text respondsToSelector: @selector( boundingRectWithSize:options:attributes:context: )] )
    {
        NSStringDrawingContext* context = [NSStringDrawingContext new];
        context.minimumScaleFactor = self.minimumScaleFactor;

        CGSize inaccurateSize = [self.text boundingRectWithSize: CGSizeMake( self.bounds.size.width, CGFLOAT_MAX )
                                                        options: NSStringDrawingUsesLineFragmentOrigin
                                                     attributes: @{ NSFontAttributeName : self.font }
                                                        context: context].size;

        CGSize accurateSize = [self.text sizeWithAttributes: @{ NSFontAttributeName : [UIFont fontWithName: self.font.fontName size: 12.0] } ];

        CGFloat accurateHeight = accurateSize.height * inaccurateSize.width / accurateSize.width;

        intrinsicContentSize = CGSizeMake( inaccurateSize.width, accurateHeight);
    }
    else
    {
        CGFloat actualFontSize;

#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"

        [self.text sizeWithFont: self.font
                    minFontSize: self.minimumFontSize
                 actualFontSize: &actualFontSize
                       forWidth: self.frame.size.width
                  lineBreakMode: NSLineBreakByTruncatingTail];

#pragma GCC diagnostic pop

        CGRect lineBox = CTFontGetBoundingBox((__bridge CTFontRef)([UIFont fontWithName: self.font.fontName size: actualFontSize]));

        intrinsicContentSize = lineBox.size;
    }

    return intrinsicContentSize;
}

@end

This implementation isn't perfect. I had to ensure using baselineAdjustment == UIBaselineAdjustmentAlignCenters, and I'm not 100% certain I understand why. And I'm not happy with the hoops I had to jump through to get an accurate text height. There's also a few pixel difference between what my calculation produces, and yours. Feel free to play with it and adjust as necessary :)

The boundingRectWithSize:options:attributes:context API seems pretty broken to me. While it (mostly!) correctly constrains the text to the input size, it doesn't calculate the correct height! The height it returns is based on the line-height of the supplied font, even if a scaling is in play. My guess is this is why UILabel doesn't have this behavior by default? My workaround is to calculate an unconstrained size where both the height and width are accurate, then use the ratio between the constrained and unconstrained widths to calculate the accurate height for the constrained size. What a PITA. There are lots of complaints in the Apple dev forums and here on SO that point out that this API has a number of issues like this.

like image 197
TomSwift Avatar answered Sep 18 '22 22:09

TomSwift


So I found a workaround. It's a little dicey, but it works.

So what I did was add a height constraint to my line of text in IB, and grab a reference to that in my view.

Then in layoutSubviews, I update my constraint height based on the size of the font, which I have to calculate:

- (void)layoutSubviews {
    if (self.titleLabel.text) {
        CGFloat actualFontSize;
        CGSize titleSize = [self.titleLabel.text sizeWithFont:self.titleLabel.font minFontSize:9.0 actualFontSize:&actualFontSize forWidth:self.titleLabel.frame.size.width lineBreakMode:NSLineBreakByTruncatingTail];
        CGRect lineBox = CTFontGetBoundingBox((__bridge CTFontRef)([UIFont fontWithName:@"ProximaNova-Regular" size:actualFontSize]));
        self.titleHeightConstraint.constant = lineBox.size.height;
    }
    [super layoutSubviews];
}

At first I was just setting it to the actual font size, but even with an adjustment (*1.2) it was still clipping the smaller font sizes. The key was using CTFontGetBoundingBox with the font size determined from my calculation.

This is pretty unfortunate, and I'm hoping there's a better way. Perhaps I should switch to wrapping.

like image 31
Bob Spryn Avatar answered Sep 20 '22 22:09

Bob Spryn


TomSwift thanks for your answer, i really struggled with this issue.

If someone is still getting weird behaviour, i had to change:

intrinsicContentSize = CGSizeMake( inaccurateSize.width, accurateHeight);

to

intrinsicContentSize = CGSizeMake( inaccurateSize.width, accurateHeight * 2);

then it worked like charm.

like image 1
Ehud Avatar answered Sep 21 '22 22:09

Ehud