24

How to top-align text of different sizes within a UILabel? An example is top-aligning smaller-sized cent amount with larger-sized dollar amount in price banners.

Sample Image

UILabel in iOS6 supports NSAttributedStringwhich allows me to have text of different sizes in the same UILabel. However it doesn't seem to have an attribute for top-aligning text. What are the options to implement this? It seems to me that providing a custom drawing logic to do top-alignment based on a custom attributed string key might be best but I have no idea how to go about it.

ejel
  • 4,135
  • 9
  • 32
  • 39
  • See: [Vertically align text within a UILabel](http://stackoverflow.com/questions/1054558/vertically-align-text-within-a-uilabel) – Elliott Mar 23 '13 at 22:42

4 Answers4

56

I was able to achieve your desired result using a single label.

Using a little math you can offset the baseline of the smaller text to achieve your desired result.

Objective-C

- (NSMutableAttributedString *)styleSalePriceLabel:(NSString *)salePrice withFont:(UIFont *)font
{
    if ([salePrice rangeOfString:@"."].location == NSNotFound) {
        return [[NSMutableAttributedString alloc] initWithString:salePrice];
    } else {
        NSRange range = [salePrice rangeOfString:@"."];
        range.length = (salePrice.length - range.location);
        NSMutableAttributedString *stylizedPriceLabel = [[NSMutableAttributedString alloc] initWithString:salePrice];
        UIFont *smallFont = [UIFont fontWithName:font.fontName size:(font.pointSize / 2)];
        NSNumber *offsetAmount = @(font.capHeight - smallFont.capHeight);
        [stylizedPriceLabel addAttribute:NSFontAttributeName value:smallFont range:range];
        [stylizedPriceLabel addAttribute:NSBaselineOffsetAttributeName value:offsetAmount range:range];
        return stylizedPriceLabel;
    }
}

Swift

extension Range where Bound == String.Index {
    func asNSRange() -> NSRange {
        let location = self.lowerBound.encodedOffset
        let length = self.lowerBound.encodedOffset - self.upperBound.encodedOffset
        return NSRange(location: location, length: length)
    }
}

extension String {
    func asStylizedPrice(using font: UIFont) -> NSMutableAttributedString {
        let stylizedPrice = NSMutableAttributedString(string: self, attributes: [.font: font])

        guard var changeRange = self.range(of: ".")?.asNSRange() else {
            return stylizedPrice
        }

        changeRange.length = self.count - changeRange.location
        // forgive the force unwrapping
        let changeFont = UIFont(name: font.fontName, size: (font.pointSize / 2))!
        let offset = font.capHeight - changeFont.capHeight
        stylizedPrice.addAttribute(.font, value: changeFont, range: changeRange)
        stylizedPrice.addAttribute(.baselineOffset, value: offset, range: changeRange)
        return stylizedPrice
    }
}

This yields the following:

Eric Murphey
  • 1,445
  • 15
  • 18
  • 1
    I'm changing the accepted answer to this one as it is more concise and have an easy-to-understand example. Thanks! – ejel Aug 28 '14 at 00:54
  • This doesn't seem to work if you use font scaling. the offsetAmount is pre scaling so the offset will be too large. – mskw Feb 05 '16 at 20:46
  • Thanks a lot. wanted same for my text ($100), $ should be get small. achieved it with your code .. thanks a lot :) – Rohit Wankhede Feb 19 '16 at 07:24
  • 1
    Add a swift implementation of your answer is a very good answer @ErickKenny – Reinier Melian Feb 28 '18 at 09:21
10

The problem with trying to do this simply by aligning the origins of frames is that "normal" characters usually end up with some extra padding around them because the label must accommodate all of the font's characters, including ones will tall ascenders and long descenders. You'll notice in the image you posted that if the smaller "99" were a separate label that was set to the same origin as the bigger text, it would be too high because of the dollar sign's top-most point.

Fortunately, UIFont gives us all the information we need to do this properly. We need to measure the empty ascender space the labels are using and adjust the relative positioning to account for it, like so:

//Make sure the labels hug their contents
[self.bigTextLabel sizeToFit];
[self.smallTextLabel sizeToFit];

//Figure out the "blank" space above normal character height for the big text
UIFont *bigFont = self.bigTextLabel.font;
CGFloat bigAscenderSpace = (bigFont.ascender - bigFont.capHeight);

//Move the small text down by that ammount
CGFloat smallTextOrigin = CGRectGetMinY(self.bigTextLabel.frame) + bigAscenderSpace;

//Figure out the "blank" space above normal character height for the little text
UIFont *smallFont = self.smallTextLabel.font;
CGFloat smallAscenderSpace = smallFont.ascender - smallFont.capHeight;

//Move the small text back up by that ammount
smallTextOrigin -= smallAscenderSpace;

//Actually assign the frames
CGRect smallTextFrame = self.smallTextLabel.frame;
smallTextFrame.origin.y = smallTextOrigin;
self.smallTextLabel.frame = smallTextFrame;

(This code assumes you have two label properties named bigTextLabel and smallTextLabel, respectively)


Edit:

Doing this without two labels is fairly similar. You can make a custom UIView subclass and draw NSAttributedStrings in it with the -drawInRect:options:context: method (make sure to use NSStringDrawingUsesLineFragmentOrigin in your options). The math for working out the top alignment should be the same, the only difference being you get the font from the attributed string via the NSFontAttributeName attribute, not the label. The 2012 WWDC video on attributed string drawing is a good reference for this.

  • 1
    I'm actually looking for a solution that doesn't require two separate UILabel. – ejel Mar 23 '13 at 22:24
  • Would you mind elaborate a little bit more on NSAttributedString approach? The method `drawInRect:options:context` is NSAttributedString instance method. How do I specify different frame to draw on for different parts of the NSAttributedString? – ejel Mar 26 '13 at 06:05
  • 1
    You would still need two separate strings in this case, for the big and small sides. Drawing the same string with two different styles is a much more complex topic, involving a good amount of custom Core Text code. If you absolutely must do it this way, check out the [Befriending Core Text](http://www.cocoanetics.com/2011/01/befriending-core-text/) tutorial by Cocoanetics for a place to start. –  Mar 26 '13 at 06:28
  • From what I tried, my bigAscenderSpace turns out smaller than smallAscenderSpace--causing smallTextOrigin to be negative value. {bigFont.ascender = 24, bigFont.capHeight = 22.176, smallFont.ascender = 11, smallFont.capHeight = 7.788. Any idea what could be wrong? – ejel Mar 28 '13 at 06:23
  • It's possible that having a negative origin is correct. Does it look wrong when drawn? If so, can you add the code you've tried to your answer? –  Mar 28 '13 at 22:49
  • It looks wrong. The offset is on the wrong direction. Here is the code: https://gist.github.com/ejel/5282524 and here is the sample output: http://imgur.com/nKFnIDc – ejel Mar 31 '13 at 23:54
  • It seems like the metrics for "Avenir-Medium" are just weird. If you change `bigFont` to any other font that's available (Even plain old "Avenir") everything works as expected. I added some code to draw the baseline and cap height and these were the results, "Avenir-Medium": http://i.imgur.com/Z8oin2S.png, "Avenir": http://i.imgur.com/z74WxEC.png. –  Apr 01 '13 at 00:36
  • Wow, I wouldn't have imagined that could be the issue. Thank you so much for your patience on helping me with this. Really appreciate it. – ejel Apr 01 '13 at 07:13
2

That's too bad. I don't have enough reputation to comment the answer that I used well after a little bit modification. I just want to point out that use smallfont for cents instead of font, which might be a typo.

Below is the modified code

- (NSMutableAttributedString *)styleSalePriceLabel:(NSString *)salePrice withFont:(UIFont *)font
{
    if ([salePrice rangeOfString:@"."].location == NSNotFound) {
        return [[NSMutableAttributedString alloc]  
                   initWithString:salePrice];
    } else {
        NSRange range = [salePrice rangeOfString:@"."];
        range.length = (salePrice.length - range.location);
        NSMutableAttributedString *stylizedPriceLabel =
            [[NSMutableAttributedString alloc] initWithString:salePrice];
        UIFont *smallfont = [UIFont fontWithName:font.fontName 
                                            size:(font.pointSize / 2)];
        NSNumber *offsetAmount = @(font.capHeight - smallfont.capHeight);
        [stylizedPriceLabel addAttribute:NSFontAttributeName 
                                   value:smallfont 
                                   range:range];
        [stylizedPriceLabel addAttribute:NSBaselineOffsetAttributeName 
                                   value:offsetAmount 
                                   range:range];
        return stylizedPriceLabel;
    }
}
Corey
  • 128
  • 2
  • 10
  • 1
    Thanks for pointing that out Corey. I corrected the code snippet in the accepted answer based on your suggestion. – ejel Oct 14 '15 at 23:31
0

One possible approach would be to make the label the exact size of the text you're putting in.

CGRect labelFrame;
//Set the origin of the label to whatever you want
labelFrame.size = [label.text sizeWithFont:label.font];  //If using NSString
labelFrame.size = [label.text size];                     //If using NSAttributedString
label.frame = labelFrame;

See this similar SO post for more details.

Community
  • 1
  • 1
architectpianist
  • 2,562
  • 1
  • 19
  • 27
  • This solution seems to require 2 UILabels but I'm more looking for a solution that requires one UILabel. Also as @frozendevil pointed out this will not align the text correctly due to extra padding. – ejel Mar 21 '13 at 22:19
  • i think what you ment was CGRect newFrame = self.lblText.frame; newFrame.origin.y -= floor(self.lblText.font.ascender); self.lblText.frame = newFrame; – iMeMyself Aug 06 '13 at 12:20